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

@@ -0,0 +1,20 @@
<template>
<VTooltip
location="bottom"
max-width="320"
open-on-click
>
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="16"
color="medium-emphasis"
class="cursor-help"
/>
</template>
<div class="text-body-2">
<slot />
</div>
</VTooltip>
</template>

View File

@@ -2,13 +2,17 @@
import { VForm } from 'vuetify/components/VForm'
import { useCreateShift, useUpdateShift } from '@/composables/api/useShifts'
import { useTimeSlotList } from '@/composables/api/useTimeSlots'
import { useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import { useTimeSlotDropdown } from '@/composables/useTimeSlotDropdown'
import InfoTooltip from '@/components/common/InfoTooltip.vue'
import { requiredValidator } from '@core/utils/validators'
import type { Shift, ShiftStatus } from '@/types/section'
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
const props = withDefaults(defineProps<{
eventId: string
sectionId: string
section?: FestivalSection | null
shift?: Shift | null
isSubEvent?: boolean
}>(), {
@@ -26,9 +30,26 @@ const eventIdRef = computed(() => props.eventId)
const sectionIdRef = computed(() => props.sectionId)
const isEditing = computed(() => !!props.shift)
const isSubEventRef = computed(() => props.isSubEvent)
const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, { includeParent: isSubEventRef })
// Get full event detail for hierarchy info
const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef)
const sectionRef = computed(() => props.section ?? null)
// Determine dropdown scenario
const { scenario, showInfoTooltip, tooltipText, fetchParams, groupTimeSlots } = useTimeSlotDropdown(
eventDetail,
sectionRef,
)
// Fetch time slots based on scenario
const includeParentRef = computed(() => fetchParams.value.includeParent)
const includeChildrenRef = computed(() => fetchParams.value.includeChildren)
const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, {
includeParent: includeParentRef,
includeChildren: includeChildrenRef,
})
const { mutate: createShift, isPending: isCreating } = useCreateShift(orgIdRef, eventIdRef, sectionIdRef)
const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(orgIdRef, eventIdRef, sectionIdRef)
@@ -74,44 +95,30 @@ watch(
{ immediate: true },
)
function formatTimeSlotItem(ts: { id: string; name: string; date: string; start_time: string; end_time: string }) {
return {
title: `${ts.name}${ts.date} ${ts.start_time}${ts.end_time}`,
value: ts.id,
}
}
const timeSlotItems = computed(() => {
// Group time slots for the dropdown
const flattenedTimeSlots = computed(() => {
// While loading, show the current shift's time slot so the dropdown doesn't flash a raw ULID
if (!timeSlots.value?.length) {
if (props.shift?.time_slot) {
return [formatTimeSlotItem(props.shift.time_slot)]
const ts = props.shift.time_slot
return [{
id: ts.id,
name: ts.name,
timeRange: `${ts.start_time} ${ts.end_time}`,
displayLabel: ts.name,
_isGroupHeader: false,
_isDimmed: false,
groupName: '',
}]
}
return []
}
const hasFestival = timeSlots.value.some(ts => ts.source === 'festival')
if (!hasFestival) {
return timeSlots.value.map(formatTimeSlotItem)
}
const subEventSlots = timeSlots.value.filter(ts => ts.source !== 'festival')
const festivalSlots = timeSlots.value.filter(ts => ts.source === 'festival')
const items: Array<{ title: string; value?: string; type?: string }> = []
if (subEventSlots.length) {
items.push({ title: subEventSlots[0]?.event_name ?? 'Programma', type: 'subheader' })
items.push(...subEventSlots.map(formatTimeSlotItem))
}
if (festivalSlots.length) {
items.push({ title: festivalSlots[0]?.event_name ?? 'Festival', type: 'subheader' })
items.push(...festivalSlots.map(formatTimeSlotItem))
}
return items
return groupTimeSlots(timeSlots.value)
})
const hasTimeSlots = computed(() => flattenedTimeSlots.value.some(i => !i._isGroupHeader))
const statusOptions = [
{ title: 'Concept', value: 'draft' },
{ title: 'Open', value: 'open' },
@@ -193,17 +200,75 @@ function onSubmit() {
@submit.prevent="onSubmit"
>
<VCardText>
<!-- Cross_event section context indicator -->
<div
v-if="section?.type === 'cross_event'"
class="mb-4"
>
<span class="text-body-2 text-medium-emphasis">
{{ section.name }}
</span>
<VChip
size="x-small"
color="info"
variant="tonal"
class="ml-2"
>
festival-breed
</VChip>
</div>
<div
v-else-if="isSubEvent && section"
class="mb-4"
>
<span class="text-body-2 text-medium-emphasis">
{{ section.name }} · {{ eventDetail?.name }}
</span>
</div>
<VRow>
<VCol cols="12">
<AppSelect
v-if="timeSlotsLoading || timeSlotItems.length"
<VAutocomplete
v-if="timeSlotsLoading || hasTimeSlots"
v-model="form.time_slot_id"
label="Time Slot"
:items="timeSlotItems"
:items="flattenedTimeSlots.filter(i => !i._isGroupHeader)"
item-title="displayLabel"
item-value="id"
label="Tijdslot"
:loading="timeSlotsLoading"
:rules="[requiredValidator]"
:error-messages="errors.time_slot_id"
/>
>
<template
v-if="showInfoTooltip"
#prepend-inner
>
<InfoTooltip>
<p>{{ tooltipText?.main }}</p>
<div class="mt-2 pa-2 bg-surface rounded text-caption">
<strong>Tip:</strong> {{ tooltipText?.tip }}
</div>
</InfoTooltip>
</template>
<template #item="{ props: itemProps, item }">
<VListItem
v-if="!item.raw._isGroupHeader"
v-bind="itemProps"
:class="{ 'opacity-65': item.raw._isDimmed }"
>
<template #append>
<span class="text-caption text-medium-emphasis">
{{ item.raw.timeRange }}
</span>
</template>
</VListItem>
</template>
<template #selection="{ item }">
{{ item.raw.name }} · {{ item.raw.timeRange }}
</template>
</VAutocomplete>
<VAlert
v-else
type="info"

View File

@@ -2,6 +2,7 @@
import draggable from 'vuedraggable'
import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections'
import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
import { useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
@@ -9,7 +10,9 @@ import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue'
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
import InfoTooltip from '@/components/common/InfoTooltip.vue'
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
import type { EventItem } from '@/types/event'
const shiftDetailStore = useShiftDetailStore()
const authStore = useAuthStore()
@@ -17,11 +20,17 @@ const authStore = useAuthStore()
const props = defineProps<{
eventId: string
isSubEvent?: boolean
parentEvent?: EventItem | null
}>()
const orgIdRef = computed(() => authStore.currentOrganisation?.id ?? '')
const eventIdRef = computed(() => props.eventId)
// Event detail for context banner
const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef)
const isSubEvent = computed(() => props.isSubEvent ?? false)
// --- Section list ---
const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(orgIdRef, eventIdRef)
const { mutate: reorderSections } = useReorderSections(orgIdRef, eventIdRef)
@@ -252,7 +261,19 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<span>Secties</span>
<!-- Contextual help tooltip -->
<InfoTooltip v-if="isSubEvent && parentEvent">
<p>
Sommige secties zijn <strong>festival-breed</strong>: ze zijn bij elk
programmaonderdeel actief en worden centraal beheerd. Je herkent ze aan
de festivalnaam achter de sectienaam.
</p>
<div class="mt-2 pa-2 bg-surface rounded text-caption">
<strong>Voorbeeld:</strong> EHBO en Security zijn festival-breed ze
staan bij elk programmaonderdeel.
</div>
</InfoTooltip>
<VTooltip
v-else
location="bottom"
max-width="300"
>
@@ -319,18 +340,15 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
class="me-1"
/>
</template>
<VListItemTitle>{{ element.name }}</VListItemTitle>
<template #append>
<VChip
v-if="element.type === 'cross_event'"
size="x-small"
color="success"
variant="tonal"
class="me-1"
<VListItemTitle>
<span>{{ element.name }}</span>
<span
v-if="element.type === 'cross_event' && parentEvent"
class="text-caption text-medium-emphasis ml-1"
>
Overkoepelend
</VChip>
</template>
· {{ parentEvent.name }}
</span>
</VListItemTitle>
</VListItem>
</template>
</draggable>
@@ -356,6 +374,28 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
<!-- Section selected -->
<template v-else>
<!-- Context banner for cross_event sections viewed from sub-event -->
<VAlert
v-if="activeSection.type === 'cross_event' && isSubEvent && parentEvent"
type="info"
variant="tonal"
density="compact"
class="mb-4"
>
<div class="d-flex justify-space-between align-center flex-wrap gap-2">
<span>
Je bekijkt {{ activeSection.name }} vanuit
<strong>{{ eventDetail?.name }}</strong>
</span>
<RouterLink
:to="{ name: 'events-id-sections', params: { id: activeSection.event_id } }"
class="text-caption"
>
Bekijk alle diensten
</RouterLink>
</div>
</VAlert>
<!-- Header -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center justify-space-between flex-wrap gap-2">
@@ -370,8 +410,9 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
v-if="activeSection.type === 'cross_event'"
size="small"
color="info"
variant="tonal"
>
Overkoepelend
festival-breed
</VChip>
<span
v-if="activeSection.crew_need"
@@ -600,6 +641,7 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
v-model="isCreateShiftOpen"
:event-id="activeSectionEventId"
:section-id="activeSection.id"
:section="activeSection"
:shift="editingShift"
:is-sub-event="isSubEvent"
/>

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 ?? '',
}
}

View File

@@ -19,6 +19,7 @@ const eventId = computed(() => String((route.params as { id: string }).id))
<SectionsShiftsPanel
:event-id="eventId"
:is-sub-event="event?.is_sub_event ?? false"
:parent-event="event?.parent ?? null"
/>
</template>
</EventTabsNav>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { useTimeSlotList, useDeleteTimeSlot } from '@/composables/api/useTimeSlots'
import { useEventChildren } from '@/composables/api/useEvents'
import { useEventChildren, useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue'
import InfoTooltip from '@/components/common/InfoTooltip.vue'
import { PersonType } from '@/types/timeSlot'
import type { TimeSlot, PersonType as PersonTypeValue } from '@/types/timeSlot'
import type { EventItem } from '@/types/event'
@@ -117,6 +118,29 @@ function getTimeSlotContext(ts: TimeSlot): { label: string; color: string } | nu
return null
}
// --- Festival time slots (read-only, for sub-events) ---
const { data: eventDetail } = useEventDetail(orgId, eventId)
const parentEventId = computed(() => eventDetail.value?.parent_event_id ?? '')
const isSubEvent = computed(() => eventDetail.value?.is_sub_event ?? false)
const parentEvent = computed(() => eventDetail.value?.parent ?? null)
const { data: festivalTimeSlots } = useTimeSlotList(orgId, parentEventId, {
includeParent: computed(() => false),
includeChildren: computed(() => false),
})
const festivalTimeSlotsGrouped = computed(() => {
if (!festivalTimeSlots.value?.length) return new Map<string, TimeSlot[]>()
const groups = new Map<string, TimeSlot[]>()
for (const slot of festivalTimeSlots.value) {
const existing = groups.get(slot.date) || []
existing.push(slot)
groups.set(slot.date, existing)
}
return groups
})
// --- Fill rate ---
function fillRatePercent(ts: TimeSlot): number {
if (ts.total_slots === 0) return 0
@@ -411,6 +435,79 @@ function onDeleteExecute() {
</div>
</template>
<!-- Festival time slots (read-only, sub-events only) -->
<template v-if="isSubEvent && parentEvent && festivalTimeSlots?.length">
<VDivider
class="my-6"
style="border-style: dashed;"
/>
<div class="d-flex align-center gap-2 mb-3">
<VIcon
icon="tabler-lock"
size="14"
color="medium-emphasis"
/>
<span class="text-caption text-medium-emphasis">
{{ parentEvent.name }} alleen-lezen
</span>
<InfoTooltip>
<p>
Tijdsloten van <strong>{{ parentEvent.name }}</strong> gelden
festival-breed en worden daar beheerd.
</p>
</InfoTooltip>
</div>
<div
v-for="[date, slots] in festivalTimeSlotsGrouped"
:key="`festival-${date}`"
class="mb-4 opacity-55"
>
<div class="d-flex align-center gap-x-2 mb-2">
<h6 class="text-subtitle-1">
{{ formatGroupDate(date) }}
</h6>
</div>
<VCard variant="outlined">
<VList density="compact">
<VListItem
v-for="ts in slots"
:key="ts.id"
>
<div class="d-flex align-center gap-x-4 py-2 flex-wrap">
<div style="min-inline-size: 200px;">
<span class="text-body-1 font-weight-medium">
{{ ts.name }}
</span>
<div class="text-body-2 text-disabled">
{{ ts.start_time }} {{ ts.end_time }}
<template v-if="ts.duration_hours">
· {{ formatDuration(ts) }}
</template>
</div>
</div>
<VChip
size="small"
:color="personTypeColor[ts.person_type]"
>
{{ personTypeLabel[ts.person_type] }}
</VChip>
</div>
</VListItem>
</VList>
</VCard>
</div>
<RouterLink
:to="{ name: 'events-id-time-slots', params: { id: parentEvent.id } }"
class="text-caption text-info mt-2 d-inline-block"
>
Beheer tijdsloten van {{ parentEvent.name }}
</RouterLink>
</template>
<!-- Create/Edit dialog -->
<CreateTimeSlotDialog
v-model="isCreateDialogOpen"

View File

@@ -8,7 +8,7 @@ export const PersonType = {
export type PersonType = (typeof PersonType)[keyof typeof PersonType]
export type TimeSlotSource = 'sub_event' | 'festival'
export type TimeSlotSource = 'sub_event' | 'festival' | 'own' | string
export interface TimeSlot {
id: string