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

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