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">
|
<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 { dutchPlural } from '@/lib/dutch-plural'
|
||||||
|
import { eventStatusLabelNl, transitionVisualKind } from '@/lib/event-status'
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||||
import EditEventDialog from '@/components/events/EditEventDialog.vue'
|
import EditEventDialog from '@/components/events/EditEventDialog.vue'
|
||||||
import RegistrationLinkCard from '@/components/events/RegistrationLinkCard.vue'
|
import RegistrationLinkCard from '@/components/events/RegistrationLinkCard.vue'
|
||||||
@@ -10,12 +13,80 @@ import type { EventStatus } from '@/types/event'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const orgStore = useOrganisationStore()
|
const orgStore = useOrganisationStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
|
||||||
const eventId = computed(() => String((route.params as { id: string }).id))
|
const eventId = computed(() => String((route.params as { id: string }).id))
|
||||||
|
|
||||||
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
|
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
|
// Children count for programmaonderdelen badge — only for festivals
|
||||||
const { data: children } = useEventChildren(orgId, eventId)
|
const { data: children } = useEventChildren(orgId, eventId)
|
||||||
|
|
||||||
@@ -133,8 +204,8 @@ const backRoute = computed(() => {
|
|||||||
|
|
||||||
<template v-else-if="event">
|
<template v-else-if="event">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-space-between align-center mb-6">
|
<div class="d-flex flex-wrap justify-space-between align-center gap-y-4 mb-6">
|
||||||
<div class="d-flex align-center gap-x-3">
|
<div class="d-flex flex-wrap align-center gap-x-3 gap-y-2">
|
||||||
<VBtn
|
<VBtn
|
||||||
icon="tabler-arrow-left"
|
icon="tabler-arrow-left"
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -164,8 +235,24 @@ const backRoute = computed(() => {
|
|||||||
:color="statusColor[event.status]"
|
:color="statusColor[event.status]"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{ event.status }}
|
{{ eventStatusLabelNl(event.status) }}
|
||||||
</VChip>
|
</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">
|
<span class="text-body-1 text-disabled">
|
||||||
{{ formatDate(event.start_date) }} – {{ formatDate(event.end_date) }}
|
{{ formatDate(event.start_date) }} – {{ formatDate(event.end_date) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -178,6 +265,38 @@ const backRoute = computed(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</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) -->
|
<!-- Registration link (top-level events only) -->
|
||||||
<RegistrationLinkCard
|
<RegistrationLinkCard
|
||||||
v-if="!event.parent_event_id"
|
v-if="!event.parent_event_id"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CreateEventPayload,
|
CreateEventPayload,
|
||||||
EventItem,
|
EventItem,
|
||||||
EventStats,
|
EventStats,
|
||||||
|
EventStatus,
|
||||||
UpdateEventPayload,
|
UpdateEventPayload,
|
||||||
} from '@/types/event'
|
} 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>) {
|
export function useUploadEventImage(orgId: Ref<string>, eventId: Ref<string>) {
|
||||||
const queryClient = useQueryClient()
|
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
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
status: EventStatus
|
status: EventStatus
|
||||||
|
/** Valid next statuses from the API state machine (detail + transition responses). */
|
||||||
|
allowed_transitions?: EventStatus[]
|
||||||
event_type: EventTypeEnum
|
event_type: EventTypeEnum
|
||||||
event_type_label: string | null
|
event_type_label: string | null
|
||||||
sub_event_label: string | null
|
sub_event_label: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user