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

@@ -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"
/>