feat: fix time slot hierarchy — seeder, API include_children, frontend dropdown, navigation

Restructure the festival hierarchy end-to-end:

Seeder: Remove duplicate festival-level VOLUNTEER time slots, keep only CREW
operational slots. Rename sub-events to "Dag 1/2/3 — ..." pattern. Change
Nachtsecurity to Security (cross_event). EHBO/Security shifts now use sub-event
time slots via cross_event exception. Add flat event "Braderie Dorpstown 2026".

API: Add ?include_children=true to TimeSlotController for festivals, returning
all sub-event time slots with source and event_name fields. Update
StoreShiftRequest and UpdateShiftRequest to accept child time slots for
cross_event sections.

Frontend: Create useTimeSlotDropdown composable with 4-scenario dropdown logic.
Replace AppSelect with VAutocomplete in CreateShiftDialog with grouped items,
dimmed festival slots, and info tooltips. Add InfoTooltip reusable component.
Show festival context labels on cross_event sections in sub-event section lists.
Add read-only festival time slots on sub-event time-slots page. Add cross_event
context banner with "Bekijk alle diensten" link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:07:37 +02:00
parent acb7fb2c3a
commit 7bc0f1a0c7
16 changed files with 829 additions and 120 deletions

View File

@@ -13,13 +13,17 @@ interface PaginatedResponse<T> {
data: T[]
}
export function useTimeSlotList(orgId: Ref<string>, eventId: Ref<string>, options?: { includeParent?: Ref<boolean> }) {
export function useTimeSlotList(orgId: Ref<string>, eventId: Ref<string>, options?: { includeParent?: Ref<boolean>; includeChildren?: Ref<boolean> }) {
const includeParent = options?.includeParent
const includeChildren = options?.includeChildren
return useQuery({
queryKey: ['time-slots', eventId, includeParent],
queryKey: ['time-slots', eventId, includeParent, includeChildren],
queryFn: async () => {
const params = includeParent?.value ? { include_parent: 'true' } : {}
const params: Record<string, string> = {}
if (includeParent?.value) params.include_parent = 'true'
if (includeChildren?.value) params.include_children = 'true'
const { data } = await apiClient.get<PaginatedResponse<TimeSlot>>(
`/organisations/${orgId.value}/events/${eventId.value}/time-slots`,
{ params },

View File

@@ -0,0 +1,161 @@
import { computed, type Ref } from 'vue'
import type { EventItem } from '@/types/event'
import type { FestivalSection } from '@/types/section'
import type { TimeSlot } from '@/types/timeSlot'
export type DropdownScenario = 'flat' | 'sub_event_standard' | 'cross_event' | 'festival_standard'
interface TimeSlotDropdownItem {
id: string
name: string
timeRange: string
displayLabel: string
_isGroupHeader: boolean
_isDimmed: boolean
groupName: string
}
interface TooltipContent {
main: string
tip: string
}
export function useTimeSlotDropdown(
event: Ref<EventItem | null | undefined>,
section: Ref<FestivalSection | null | undefined>,
) {
const scenario = computed<DropdownScenario>(() => {
if (!event.value) return 'flat'
const isSubEvent = !!event.value.parent_event_id
const hasChildren = event.value.has_children
const isCrossEvent = section.value?.type === 'cross_event'
// Flat event — no hierarchy
if (!isSubEvent && !hasChildren) return 'flat'
// Cross_event section — needs all time slots
if (isCrossEvent) return 'cross_event'
// Standard section on sub-event — own + parent
if (isSubEvent) return 'sub_event_standard'
// Standard section on festival level — own only
return 'festival_standard'
})
const showInfoTooltip = computed(() => scenario.value !== 'flat')
const tooltipText = computed<TooltipContent | null>(() => {
const eventName = event.value?.name ?? ''
const sectionName = section.value?.name ?? ''
const festivalName = event.value?.parent?.name ?? eventName
switch (scenario.value) {
case 'sub_event_standard':
return {
main: `Kies het tijdvenster voor deze dienst. Je ziet de tijdsloten van ${eventName} en de algemene tijdsloten van ${festivalName}.`,
tip: `Voor een reguliere dienst kies je een tijdslot van ${eventName}. Voor een opbouw- of afbraakdienst kies je een festivaltijdslot.`,
}
case 'cross_event':
return {
main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`,
tip: `Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).`,
}
case 'festival_standard':
return {
main: `Kies een tijdslot van ${eventName} voor deze dienst.`,
tip: `Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.`,
}
default:
return null
}
})
const fetchParams = computed(() => {
switch (scenario.value) {
case 'flat':
case 'festival_standard':
return { includeParent: false, includeChildren: false }
case 'sub_event_standard':
return { includeParent: true, includeChildren: false }
case 'cross_event':
return { includeParent: false, includeChildren: true }
}
})
function groupTimeSlots(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
if (scenario.value === 'flat') {
return timeSlots.map(ts => toDropdownItem(ts, false))
}
// Group by event_name
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
for (const ts of timeSlots) {
const key = ts.event_name ?? 'Onbekend'
if (!groups.has(key)) {
const isOwn = ts.source === 'own' || ts.source === 'sub_event' || ts.source === 'festival'
? (scenario.value === 'sub_event_standard'
? ts.source === 'sub_event'
: ts.source === 'own')
: false
groups.set(key, { slots: [], isOwn })
}
groups.get(key)!.slots.push(ts)
}
const items: TimeSlotDropdownItem[] = []
// Own group first, then others
const sortedGroups = [...groups.entries()].sort(([, a], [, b]) => {
if (a.isOwn && !b.isOwn) return -1
if (!a.isOwn && b.isOwn) return 1
return 0
})
for (const [groupName, { slots, isOwn }] of sortedGroups) {
// Add group header
items.push({
id: `header-${groupName}`,
name: groupName,
timeRange: '',
displayLabel: groupName,
_isGroupHeader: true,
_isDimmed: false,
groupName,
})
// Determine if slots should be dimmed
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
for (const ts of slots) {
items.push(toDropdownItem(ts, isDimmed))
}
}
return items
}
return {
scenario,
showInfoTooltip,
tooltipText,
fetchParams,
groupTimeSlots,
}
}
function toDropdownItem(ts: TimeSlot, isDimmed: boolean): TimeSlotDropdownItem {
const timeRange = `${ts.start_time} ${ts.end_time}`
return {
id: ts.id,
name: ts.name,
timeRange,
displayLabel: ts.name,
_isGroupHeader: false,
_isDimmed: isDimmed,
groupName: ts.event_name ?? '',
}
}