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
333 lines
10 KiB
Vue
333 lines
10 KiB
Vue
<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">»</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>
|