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
This commit is contained in:
2026-04-12 22:20:36 +02:00
parent f6e3568011
commit 1172c41d33
4 changed files with 184 additions and 4 deletions

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
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'
@@ -10,12 +13,80 @@ 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)
@@ -133,8 +204,8 @@ const backRoute = computed(() => {
<template v-else-if="event">
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="d-flex align-center gap-x-3">
<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"
@@ -164,8 +235,24 @@ const backRoute = computed(() => {
:color="statusColor[event.status]"
size="small"
>
{{ event.status }}
{{ 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>
@@ -178,6 +265,38 @@ const backRoute = computed(() => {
</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"

View File

@@ -5,6 +5,7 @@ import type {
CreateEventPayload,
EventItem,
EventStats,
EventStatus,
UpdateEventPayload,
} from '@/types/event'
@@ -132,6 +133,25 @@ export function useUpdateEvent(orgId: Ref<string>, id: Ref<string>) {
})
}
export function useTransitionEventStatus(orgId: Ref<string>, eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (status: EventStatus) => {
const { data } = await apiClient.post<ApiResponse<EventItem>>(
`/organisations/${orgId.value}/events/${eventId.value}/transition`,
{ status },
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['events', orgId.value, eventId.value] })
queryClient.invalidateQueries({ queryKey: ['event-children', eventId.value] })
},
})
}
export function useUploadEventImage(orgId: Ref<string>, eventId: Ref<string>) {
const queryClient = useQueryClient()

View File

@@ -0,0 +1,39 @@
import type { EventStatus } from '@/types/event'
const STATUS_ORDER: Record<EventStatus, number> = {
draft: 0,
published: 1,
registration_open: 2,
buildup: 3,
showday: 4,
teardown: 5,
closed: 6,
}
export const EVENT_STATUS_LABEL_NL: Record<EventStatus, string> = {
draft: 'Concept',
published: 'Gepubliceerd',
registration_open: 'Registratie open',
buildup: 'Opbouw',
showday: 'Showdag',
teardown: 'Afbouw',
closed: 'Afgesloten',
}
export function eventStatusLabelNl(status: EventStatus): string {
return EVENT_STATUS_LABEL_NL[status]
}
/** UI styling: forward in lifecycle, rollback, or closing the event. */
export function transitionVisualKind(
currentStatus: EventStatus,
targetStatus: EventStatus,
): 'forward' | 'backward' | 'dangerous' {
if (targetStatus === 'closed')
return 'dangerous'
const current = STATUS_ORDER[currentStatus]
const next = STATUS_ORDER[targetStatus]
return next < current ? 'backward' : 'forward'
}

View File

@@ -16,6 +16,8 @@ export interface EventItem {
name: string
slug: string
status: EventStatus
/** Valid next statuses from the API state machine (detail + transition responses). */
allowed_transitions?: EventStatus[]
event_type: EventTypeEnum
event_type_label: string | null
sub_event_label: string | null