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:
20
apps/app/src/components/common/InfoTooltip.vue
Normal file
20
apps/app/src/components/common/InfoTooltip.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user