Files
crewli/apps/app/src/composables/useTimeSlotDropdown.ts
bert.hausmans 7bc0f1a0c7 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>
2026-04-14 22:07:37 +02:00

162 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? '',
}
}