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:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
39
apps/app/src/lib/event-status.ts
Normal file
39
apps/app/src/lib/event-status.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user