From e70904741ddd2f392df9704e3f13fdb2df0ebb04 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 15:47:36 +0200 Subject: [PATCH] feat(app): dedicated Tijdsloten tab with grouped view and fill rates Extract time slots from Secties & Shifts into a dedicated Tijdsloten tab. New tab groups time slots by date with Dutch date headers, person type filter pills, fill rate progress bars, and sections count. Includes duplicate, edit, and delete actions with confirmation dialog. - Create types/timeSlot.ts with enriched TimeSlot interface - Add Tijdsloten tab to EventTabsNav between Publiekslijsten and Secties - Create time-slots page with loading, error, and empty states - Remove time slots panel from SectionsShiftsPanel - Update CreateShiftDialog to navigate to time slots tab Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/components.d.ts | 3 + .../src/components/events/EventTabsNav.vue | 12 +- .../components/sections/CreateShiftDialog.vue | 9 +- .../sections/SectionsShiftsPanel.vue | 476 +----------------- apps/app/src/composables/api/useTimeSlots.ts | 2 +- .../src/pages/events/[id]/sections/index.vue | 8 - .../pages/events/[id]/time-slots/index.vue | 460 +++++++++++++++++ apps/app/src/types/section.ts | 33 +- apps/app/src/types/timeSlot.ts | 40 ++ apps/app/typed-router.d.ts | 2 + 10 files changed, 525 insertions(+), 520 deletions(-) create mode 100644 apps/app/src/pages/events/[id]/time-slots/index.vue create mode 100644 apps/app/src/types/timeSlot.ts diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 4dae24b4..468101ce 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -10,6 +10,7 @@ declare module 'vue' { AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default'] AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default'] AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default'] + AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default'] AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default'] AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default'] AppCardActions: typeof import('./src/@core/components/cards/AppCardActions.vue')['default'] @@ -37,6 +38,8 @@ declare module 'vue' { CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default'] CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default'] CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default'] + CrowdListDetailPanel: typeof import('./src/components/crowd-lists/CrowdListDetailPanel.vue')['default'] + CrowdListFormDialog: typeof import('./src/components/crowd-lists/CrowdListFormDialog.vue')['default'] CrowdTypesManager: typeof import('./src/components/organisations/CrowdTypesManager.vue')['default'] CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default'] CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default'] diff --git a/apps/app/src/components/events/EventTabsNav.vue b/apps/app/src/components/events/EventTabsNav.vue index 5e85bcc9..705e0773 100644 --- a/apps/app/src/components/events/EventTabsNav.vue +++ b/apps/app/src/components/events/EventTabsNav.vue @@ -54,6 +54,7 @@ const baseTabs = [ { label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' }, { label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' }, { label: 'Publiekslijsten', icon: 'tabler-list', route: 'events-id-crowd-lists' }, + { label: 'Tijdsloten', icon: 'tabler-clock', route: 'events-id-time-slots' }, { label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' }, { label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' }, { label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' }, @@ -71,7 +72,7 @@ const programmaonderdelenLabel = computed(() => { const tabs = computed(() => { if (!event.value?.is_festival) return baseTabs - // Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen + // Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen const festivalTab = { label: programmaonderdelenLabel.value, icon: 'tabler-calendar-event', @@ -81,12 +82,13 @@ const tabs = computed(() => { return [ baseTabs[0], // Overzicht festivalTab, - baseTabs[3], // Secties & Shifts + baseTabs[3], // Tijdsloten + baseTabs[4], // Secties & Shifts baseTabs[1], // Personen baseTabs[2], // Publiekslijsten - baseTabs[4], // Artiesten - baseTabs[5], // Briefings - baseTabs[6], // Instellingen + baseTabs[5], // Artiesten + baseTabs[6], // Briefings + baseTabs[7], // Instellingen ] }) diff --git a/apps/app/src/components/sections/CreateShiftDialog.vue b/apps/app/src/components/sections/CreateShiftDialog.vue index 3ff0154d..7ca0cce9 100644 --- a/apps/app/src/components/sections/CreateShiftDialog.vue +++ b/apps/app/src/components/sections/CreateShiftDialog.vue @@ -14,12 +14,11 @@ const props = withDefaults(defineProps<{ isSubEvent: false, }) -const emit = defineEmits<{ - openTimeSlots: [] -}>() - const modelValue = defineModel({ required: true }) +const router = useRouter() +const route = useRoute() + const eventIdRef = computed(() => props.eventId) const sectionIdRef = computed(() => props.sectionId) @@ -209,7 +208,7 @@ function onSubmit() { variant="text" color="primary" prepend-icon="tabler-clock" - @click="emit('openTimeSlots'); modelValue = false" + @click="modelValue = false; router.push({ name: 'events-id-time-slots', params: { id: (route.params as { id: string }).id } })" > Time Slots beheren diff --git a/apps/app/src/components/sections/SectionsShiftsPanel.vue b/apps/app/src/components/sections/SectionsShiftsPanel.vue index 44300826..9d747094 100644 --- a/apps/app/src/components/sections/SectionsShiftsPanel.vue +++ b/apps/app/src/components/sections/SectionsShiftsPanel.vue @@ -2,126 +2,18 @@ import draggable from 'vuedraggable' import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections' import { useShiftList, useDeleteShift } from '@/composables/api/useShifts' -import { useTimeSlotList, useDeleteTimeSlot } from '@/composables/api/useTimeSlots' -import { useSectionsUiStore } from '@/stores/useSectionsUiStore' import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue' import EditSectionDialog from '@/components/sections/EditSectionDialog.vue' -import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue' import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue' import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue' -import type { FestivalSection, Shift, ShiftStatus, TimeSlot, PersonType } from '@/types/section' -import type { EventItem } from '@/types/event' +import type { FestivalSection, Shift, ShiftStatus } from '@/types/section' -const props = withDefaults(defineProps<{ +const props = defineProps<{ eventId: string - children?: EventItem[] isSubEvent?: boolean -}>(), { - children: () => [], - isSubEvent: false, -}) +}>() const eventIdRef = computed(() => props.eventId) -const isSubEventRef = computed(() => props.isSubEvent) - -// --- Time Slots --- -const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(eventIdRef, { includeParent: isSubEventRef }) -const { mutate: deleteTimeSlotMutation, isPending: isDeletingTimeSlot } = useDeleteTimeSlot(eventIdRef) - -const personTypeLabel: Record = { - VOLUNTEER: 'Vrijwilliger', - CREW: 'Crew', - PRESS: 'Pers', - PHOTO: 'Fotograaf', - PARTNER: 'Partner', -} - -const personTypeColor: Record = { - VOLUNTEER: 'success', - CREW: 'primary', - PRESS: 'warning', - PHOTO: 'info', - PARTNER: 'secondary', -} - -// Group time slots by source when on a sub-event with parent time slots included -const hasFestivalTimeSlots = computed(() => - timeSlots.value?.some(ts => ts.source === 'festival') ?? false, -) - -const subEventTimeSlots = computed(() => - timeSlots.value?.filter(ts => ts.source !== 'festival') ?? [], -) - -const festivalTimeSlots = computed(() => - timeSlots.value?.filter(ts => ts.source === 'festival') ?? [], -) - -const timeSlotsSummary = computed(() => { - const slots = timeSlots.value - if (!slots?.length) return '' - - const count = slots.length - const dates = slots.map(ts => ts.date).sort() - const minDate = dates[0] - const maxDate = dates[dates.length - 1] - - const fmt = new Intl.DateTimeFormat('nl-NL', { day: 'numeric', month: 'short' }) - const fmtYear = new Intl.DateTimeFormat('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' }) - - if (minDate === maxDate) { - return `${count} time slot${count > 1 ? 's' : ''} \u00b7 ${fmtYear.format(new Date(minDate))}` - } - - return `${count} time slots \u00b7 ${fmt.format(new Date(minDate))} \u2013 ${fmtYear.format(new Date(maxDate))}` -}) - -function formatTimeSlotDate(iso: string) { - if (!iso) return '' - return new Intl.DateTimeFormat('nl-NL', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(iso)) -} - -function formatTime(time: string) { - return time?.slice(0, 5) ?? '' -} - -// --- Time slot context labels (Opbouw / Afbraak / Transitie) --- -const hasChildren = computed(() => props.children.length > 0) - -const childDateRange = computed(() => { - if (!hasChildren.value) return null - - const starts = props.children.map(c => c.start_date).sort() - const ends = props.children.map(c => c.end_date).sort() - - return { - firstStart: starts[0], - lastEnd: ends[ends.length - 1], - dates: props.children.map(c => ({ start: c.start_date, end: c.end_date })), - } -}) - -function getTimeSlotContext(ts: TimeSlot): { label: string; color: string } | null { - if (!childDateRange.value) return null - - const tsDate = ts.date - const { firstStart, lastEnd, dates } = childDateRange.value - - if (tsDate < firstStart) return { label: 'Opbouw', color: 'warning' } - if (tsDate > lastEnd) return { label: 'Afbraak', color: 'error' } - - // Check if this date falls between two sub-events (transition) - const sorted = [...dates].sort((a, b) => a.start.localeCompare(b.start)) - for (let i = 0; i < sorted.length - 1; i++) { - const currentEnd = sorted[i].end - const nextStart = sorted[i + 1].start - if (tsDate > currentEnd && tsDate < nextStart) { - return { label: 'Transitie', color: 'info' } - } - } - - return null -} // --- Section list --- const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(eventIdRef) @@ -199,18 +91,14 @@ const shiftsByTimeSlot = computed(() => { }) // --- Dialogs --- -const sectionsUiStore = useSectionsUiStore() const isCreateSectionOpen = ref(false) const isEditSectionOpen = ref(false) -const isCreateTimeSlotOpen = ref(false) const isCreateShiftOpen = ref(false) const isAssignShiftOpen = ref(false) const editingShift = ref(null) const assigningShift = ref(null) -const editingTimeSlot = ref(null) -const duplicatingTimeSlot = ref(null) // Delete section const isDeleteSectionOpen = ref(false) @@ -234,43 +122,6 @@ function onDeleteSectionExecute() { }) } -// Delete time slot -const isDeleteTimeSlotOpen = ref(false) -const deletingTimeSlotId = ref(null) - -function onDeleteTimeSlotConfirm(ts: TimeSlot) { - deletingTimeSlotId.value = ts.id - isDeleteTimeSlotOpen.value = true -} - -function onDeleteTimeSlotExecute() { - if (!deletingTimeSlotId.value) return - deleteTimeSlotMutation(deletingTimeSlotId.value, { - onSuccess: () => { - isDeleteTimeSlotOpen.value = false - deletingTimeSlotId.value = null - }, - }) -} - -function onEditTimeSlot(ts: TimeSlot) { - editingTimeSlot.value = ts - duplicatingTimeSlot.value = null - isCreateTimeSlotOpen.value = true -} - -function onDuplicateTimeSlot(ts: TimeSlot) { - editingTimeSlot.value = null - duplicatingTimeSlot.value = ts - isCreateTimeSlotOpen.value = true -} - -function onAddTimeSlot() { - editingTimeSlot.value = null - duplicatingTimeSlot.value = null - isCreateTimeSlotOpen.value = true -} - // Delete shift const isDeleteShiftOpen = ref(false) const deletingShiftId = ref(null) @@ -309,15 +160,6 @@ function onEditSection() { isEditSectionOpen.value = true } -watch(isCreateTimeSlotOpen, (open) => { - if (!open) duplicatingTimeSlot.value = null -}) - -function onOpenTimeSlotsFromShiftDialog() { - isCreateShiftOpen.value = false - sectionsUiStore.expandTimeSlots() -} - // Status styling const statusColor: Record = { draft: 'default', @@ -380,282 +222,6 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;