Files
crewli/apps/app/src/components/events/EventTabsNav.vue
bert.hausmans 1172c41d33 feat(app): event status transitions on detail header
Add transition buttons from allowed_transitions with Dutch labels,
confirmation dialog, TanStack mutation + cache invalidation, and
422/generic error handling via notification store.

Made-with: Cursor
2026-04-12 22:20:36 +02:00

333 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import axios from 'axios'
import { useEventDetail, useEventChildren, useTransitionEventStatus } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { eventStatusLabelNl, transitionVisualKind } from '@/lib/event-status'
import { useAuthStore } from '@/stores/useAuthStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import EditEventDialog from '@/components/events/EditEventDialog.vue'
import RegistrationLinkCard from '@/components/events/RegistrationLinkCard.vue'
import type { EventStatus } from '@/types/event'
const route = useRoute()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
const notificationStore = useNotificationStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
const {
mutateAsync: transitionEventStatus,
isPending: isTransitionPending,
} = useTransitionEventStatus(orgId, eventId)
const confirmTransitionOpen = ref(false)
const pendingTransitionStatus = ref<EventStatus | null>(null)
const allowedTransitions = computed(() => event.value?.allowed_transitions ?? [])
function transitionButtonColor(current: EventStatus, target: EventStatus): string {
const kind = transitionVisualKind(current, target)
if (kind === 'dangerous')
return 'error'
if (kind === 'backward')
return 'secondary'
return 'primary'
}
function transitionButtonVariant(current: EventStatus, target: EventStatus): 'flat' | 'outlined' {
return transitionVisualKind(current, target) === 'backward' ? 'outlined' : 'flat'
}
function openTransitionConfirm(target: EventStatus) {
pendingTransitionStatus.value = target
confirmTransitionOpen.value = true
}
function cancelTransitionConfirm() {
confirmTransitionOpen.value = false
pendingTransitionStatus.value = null
}
function transition422Message(err: unknown): string {
if (!axios.isAxiosError(err))
return ''
const body = err.response?.data as { message?: string; errors?: string[] } | undefined
if (body?.errors?.length)
return body.errors.join(' ')
if (body?.message)
return body.message
return ''
}
async function confirmTransition() {
const target = pendingTransitionStatus.value
if (!target)
return
try {
await transitionEventStatus(target)
notificationStore.show('De status is bijgewerkt.', 'success')
cancelTransitionConfirm()
}
catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 422) {
const msg = transition422Message(err)
notificationStore.show(msg || 'Status wijzigen is op dit moment niet mogelijk.', 'error')
}
else {
notificationStore.show('Status wijzigen is mislukt. Probeer het opnieuw.', 'error')
}
}
}
// Children count for programmaonderdelen badge — only for festivals
const { data: children } = useEventChildren(orgId, eventId)
// Set active event in store
watch(eventId, (id) => {
if (id) orgStore.setActiveEvent(id)
}, { immediate: true })
const isEditDialogOpen = ref(false)
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
registration_open: 'cyan',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'error',
}
const eventTypeColor: Record<string, string> = {
festival: 'purple',
series: 'info',
}
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const baseTabs = [
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
{ label: 'Publiekslijsten', icon: 'tabler-list', route: 'events-id-crowd-lists' },
{ label: 'Tijdsloten', icon: 'tabler-clock', route: 'events-id-time-slots' },
{ label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
{ label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' },
{ label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' },
{ label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' },
]
const programmaonderdelenLabel = computed(() => {
const label = event.value?.sub_event_label
? dutchPlural(event.value.sub_event_label)
: 'Programmaonderdelen'
const count = children.value?.length ?? event.value?.children_count ?? 0
return `${label} (${count})`
})
const tabs = computed(() => {
if (!event.value?.is_festival) return baseTabs
// Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen
const festivalTab = {
label: programmaonderdelenLabel.value,
icon: 'tabler-calendar-event',
route: 'events-id-programmaonderdelen',
}
return [
baseTabs[0], // Overzicht
festivalTab,
baseTabs[3], // Tijdsloten
baseTabs[4], // Secties & Shifts
baseTabs[1], // Personen
baseTabs[2], // Publiekslijsten
baseTabs[5], // Artiesten
baseTabs[6], // Briefings
baseTabs[7], // Instellingen
]
})
const activeTab = computed(() => {
const name = route.name as string
return tabs.value.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
})
const backRoute = computed(() => {
if (event.value?.is_sub_event && event.value.parent_event_id) {
return { name: 'events-id' as const, params: { id: event.value.parent_event_id } }
}
return { name: 'events' as const }
})
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon evenement niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="event">
<!-- Header -->
<div class="d-flex flex-wrap justify-space-between align-center gap-y-4 mb-6">
<div class="d-flex flex-wrap align-center gap-x-3 gap-y-2">
<VBtn
icon="tabler-arrow-left"
variant="text"
:to="backRoute"
/>
<h4 class="text-h4">
<template v-if="event.is_sub_event && event.parent && event.parent_event_id">
<RouterLink
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
class="text-medium-emphasis text-decoration-none"
>
{{ event.parent.name }}
</RouterLink>
<span class="text-medium-emphasis mx-1">&raquo;</span>
</template>
{{ event.name }}
</h4>
<VChip
v-if="event.event_type === 'festival' || event.event_type === 'series'"
:color="eventTypeColor[event.event_type]"
size="small"
variant="tonal"
>
{{ event.event_type_label ?? (event.event_type === 'festival' ? 'Festival' : 'Serie') }}
</VChip>
<VChip
:color="statusColor[event.status]"
size="small"
>
{{ eventStatusLabelNl(event.status) }}
</VChip>
<div
v-if="allowedTransitions.length"
class="d-flex flex-wrap align-center gap-2"
>
<VBtn
v-for="target in allowedTransitions"
:key="target"
size="small"
:color="transitionButtonColor(event.status, target)"
:variant="transitionButtonVariant(event.status, target)"
:disabled="isTransitionPending"
@click="openTransitionConfirm(target)"
>
{{ eventStatusLabelNl(target) }}
</VBtn>
</div>
<span class="text-body-1 text-disabled">
{{ formatDate(event.start_date) }} {{ formatDate(event.end_date) }}
</span>
</div>
<VBtn
prepend-icon="tabler-edit"
@click="isEditDialogOpen = true"
>
Bewerken
</VBtn>
</div>
<VDialog
v-model="confirmTransitionOpen"
max-width="440"
>
<VCard>
<VCardTitle class="text-h6">
Status wijzigen
</VCardTitle>
<VCardText class="text-body-1">
Weet je zeker dat je de status wilt wijzigen naar
<strong>{{ pendingTransitionStatus ? eventStatusLabelNl(pendingTransitionStatus) : '' }}</strong>?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
:disabled="isTransitionPending"
@click="cancelTransitionConfirm"
>
Annuleren
</VBtn>
<VBtn
:color="pendingTransitionStatus && transitionVisualKind(event.status, pendingTransitionStatus) === 'dangerous' ? 'error' : 'primary'"
:loading="isTransitionPending"
@click="confirmTransition"
>
Bevestigen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Registration link (top-level events only) -->
<RegistrationLinkCard
v-if="!event.parent_event_id"
:event="event"
/>
<!-- Horizontal tabs -->
<VTabs
:model-value="activeTab"
class="mb-6"
>
<VTab
v-for="tab in tabs"
:key="tab.route"
:value="tab.route"
:prepend-icon="tab.icon"
:to="{ name: tab.route, params: { id: eventId } }"
>
{{ tab.label }}
</VTab>
</VTabs>
<!-- Tab content -->
<slot :event="event" />
<EditEventDialog
v-model="isEditDialogOpen"
:event="event"
:org-id="orgId"
/>
</template>
</div>
</template>