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>
162 lines
4.8 KiB
TypeScript
162 lines
4.8 KiB
TypeScript
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 ?? '',
|
||
}
|
||
}
|