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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:47:36 +02:00
parent d9f99a4cf1
commit e70904741d
10 changed files with 525 additions and 520 deletions

View File

@@ -10,6 +10,7 @@ declare module 'vue' {
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default'] AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default'] AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.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'] AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default']
AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default'] AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default']
AppCardActions: typeof import('./src/@core/components/cards/AppCardActions.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'] CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default']
CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default'] CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default']
CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.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'] CrowdTypesManager: typeof import('./src/components/organisations/CrowdTypesManager.vue')['default']
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.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'] CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']

View File

@@ -54,6 +54,7 @@ const baseTabs = [
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' }, { label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' }, { label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
{ label: 'Publiekslijsten', icon: 'tabler-list', route: 'events-id-crowd-lists' }, { 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: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
{ label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' }, { label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' },
{ label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' }, { label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' },
@@ -71,7 +72,7 @@ const programmaonderdelenLabel = computed(() => {
const tabs = computed(() => { const tabs = computed(() => {
if (!event.value?.is_festival) return baseTabs 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 = { const festivalTab = {
label: programmaonderdelenLabel.value, label: programmaonderdelenLabel.value,
icon: 'tabler-calendar-event', icon: 'tabler-calendar-event',
@@ -81,12 +82,13 @@ const tabs = computed(() => {
return [ return [
baseTabs[0], // Overzicht baseTabs[0], // Overzicht
festivalTab, festivalTab,
baseTabs[3], // Secties & Shifts baseTabs[3], // Tijdsloten
baseTabs[4], // Secties & Shifts
baseTabs[1], // Personen baseTabs[1], // Personen
baseTabs[2], // Publiekslijsten baseTabs[2], // Publiekslijsten
baseTabs[4], // Artiesten baseTabs[5], // Artiesten
baseTabs[5], // Briefings baseTabs[6], // Briefings
baseTabs[6], // Instellingen baseTabs[7], // Instellingen
] ]
}) })

View File

@@ -14,12 +14,11 @@ const props = withDefaults(defineProps<{
isSubEvent: false, isSubEvent: false,
}) })
const emit = defineEmits<{
openTimeSlots: []
}>()
const modelValue = defineModel<boolean>({ required: true }) const modelValue = defineModel<boolean>({ required: true })
const router = useRouter()
const route = useRoute()
const eventIdRef = computed(() => props.eventId) const eventIdRef = computed(() => props.eventId)
const sectionIdRef = computed(() => props.sectionId) const sectionIdRef = computed(() => props.sectionId)
@@ -209,7 +208,7 @@ function onSubmit() {
variant="text" variant="text"
color="primary" color="primary"
prepend-icon="tabler-clock" 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 Time Slots beheren
</VBtn> </VBtn>

View File

@@ -2,126 +2,18 @@
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections' import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections'
import { useShiftList, useDeleteShift } from '@/composables/api/useShifts' 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 CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue' import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue'
import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue' import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue' import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue'
import type { FestivalSection, Shift, ShiftStatus, TimeSlot, PersonType } from '@/types/section' import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
import type { EventItem } from '@/types/event'
const props = withDefaults(defineProps<{ const props = defineProps<{
eventId: string eventId: string
children?: EventItem[]
isSubEvent?: boolean isSubEvent?: boolean
}>(), { }>()
children: () => [],
isSubEvent: false,
})
const eventIdRef = computed(() => props.eventId) 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<PersonType, string> = {
VOLUNTEER: 'Vrijwilliger',
CREW: 'Crew',
PRESS: 'Pers',
PHOTO: 'Fotograaf',
PARTNER: 'Partner',
}
const personTypeColor: Record<PersonType, string> = {
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 --- // --- Section list ---
const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(eventIdRef) const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(eventIdRef)
@@ -199,18 +91,14 @@ const shiftsByTimeSlot = computed(() => {
}) })
// --- Dialogs --- // --- Dialogs ---
const sectionsUiStore = useSectionsUiStore()
const isCreateSectionOpen = ref(false) const isCreateSectionOpen = ref(false)
const isEditSectionOpen = ref(false) const isEditSectionOpen = ref(false)
const isCreateTimeSlotOpen = ref(false)
const isCreateShiftOpen = ref(false) const isCreateShiftOpen = ref(false)
const isAssignShiftOpen = ref(false) const isAssignShiftOpen = ref(false)
const editingShift = ref<Shift | null>(null) const editingShift = ref<Shift | null>(null)
const assigningShift = ref<Shift | null>(null) const assigningShift = ref<Shift | null>(null)
const editingTimeSlot = ref<TimeSlot | null>(null)
const duplicatingTimeSlot = ref<TimeSlot | null>(null)
// Delete section // Delete section
const isDeleteSectionOpen = ref(false) const isDeleteSectionOpen = ref(false)
@@ -234,43 +122,6 @@ function onDeleteSectionExecute() {
}) })
} }
// Delete time slot
const isDeleteTimeSlotOpen = ref(false)
const deletingTimeSlotId = ref<string | null>(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 // Delete shift
const isDeleteShiftOpen = ref(false) const isDeleteShiftOpen = ref(false)
const deletingShiftId = ref<string | null>(null) const deletingShiftId = ref<string | null>(null)
@@ -309,15 +160,6 @@ function onEditSection() {
isEditSectionOpen.value = true isEditSectionOpen.value = true
} }
watch(isCreateTimeSlotOpen, (open) => {
if (!open) duplicatingTimeSlot.value = null
})
function onOpenTimeSlotsFromShiftDialog() {
isCreateShiftOpen.value = false
sectionsUiStore.expandTimeSlots()
}
// Status styling // Status styling
const statusColor: Record<ShiftStatus, string> = { const statusColor: Record<ShiftStatus, string> = {
draft: 'default', draft: 'default',
@@ -380,282 +222,6 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
</script> </script>
<template> <template>
<!-- -->
<!-- TIME SLOTS PANEL (collapsible, event-scoped) -->
<!-- -->
<VCard class="mb-4">
<!-- Header clickable to toggle -->
<VCardTitle
class="d-flex align-center justify-space-between cursor-pointer"
@click="sectionsUiStore.toggleTimeSlots()"
>
<div class="d-flex align-center gap-x-2">
<span>Time Slots</span>
<!-- Contextual help tooltip -->
<VTooltip
location="bottom"
max-width="300"
>
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
class="text-disabled"
@click.stop
/>
</template>
Time Slots zijn de tijdblokken van je evenement (bijv. 'Opbouw dag 1', 'Afbraak'). Ze gelden voor het hele evenement. Alle secties delen dezelfde Time Slots.
</VTooltip>
<!-- Collapsed summary -->
<span
v-if="!sectionsUiStore.timeSlotsExpanded && timeSlotsSummary"
class="text-body-2 text-disabled"
>
&middot; {{ timeSlotsSummary }}
</span>
</div>
<div class="d-flex align-center gap-x-1">
<VBtn
icon="tabler-plus"
variant="text"
size="small"
title="Time Slot toevoegen"
@click.stop="onAddTimeSlot"
/>
<VIcon
:icon="sectionsUiStore.timeSlotsExpanded ? 'tabler-chevron-up' : 'tabler-chevron-down'"
size="20"
/>
</div>
</VCardTitle>
<!-- Expanded content -->
<VExpandTransition>
<div v-show="sectionsUiStore.timeSlotsExpanded">
<VDivider />
<VCardText>
<!-- Loading -->
<VSkeletonLoader
v-if="timeSlotsLoading"
type="chip@4"
/>
<!-- Empty state -->
<div
v-else-if="!timeSlots?.length"
class="text-center py-4"
>
<VIcon
icon="tabler-clock"
size="40"
class="mb-3 text-disabled"
/>
<p class="text-body-1 text-disabled mb-4">
Nog geen time slots. Maak eerst time slots aan voordat je shifts kunt plannen.
</p>
<VBtn
prepend-icon="tabler-plus"
@click="onAddTimeSlot"
>
Time Slot aanmaken
</VBtn>
</div>
<!-- Time Slot cards -->
<template v-else>
<!-- Grouped view when parent festival time slots are included -->
<template v-if="hasFestivalTimeSlots">
<!-- Sub-event's own time slots -->
<div
v-if="subEventTimeSlots.length"
class="mb-4"
>
<div class="text-caption text-uppercase font-weight-medium text-disabled mb-2">
{{ subEventTimeSlots[0]?.event_name ?? 'Programma' }}
</div>
<div class="d-flex flex-wrap gap-3">
<VCard
v-for="ts in subEventTimeSlots"
:key="ts.id"
variant="outlined"
class="pa-3"
style="min-inline-size: 180px; max-inline-size: 220px;"
>
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-1 font-weight-medium text-truncate">
{{ ts.name }}
</span>
<div class="d-flex">
<VBtn
icon="tabler-copy"
variant="text"
size="x-small"
title="Dupliceren"
@click="onDuplicateTimeSlot(ts)"
/>
<VBtn
icon="tabler-edit"
variant="text"
size="x-small"
title="Bewerken"
@click="onEditTimeSlot(ts)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="x-small"
color="error"
title="Verwijderen"
@click="onDeleteTimeSlotConfirm(ts)"
/>
</div>
</div>
<div class="text-body-2 text-disabled">
{{ formatTimeSlotDate(ts.date) }} &middot; {{ formatTime(ts.start_time) }}&ndash;{{ formatTime(ts.end_time) }}
</div>
<div class="d-flex align-center gap-x-1 mt-1">
<VChip
size="x-small"
:color="personTypeColor[ts.person_type]"
>
{{ personTypeLabel[ts.person_type] }}
</VChip>
</div>
</VCard>
</div>
</div>
<!-- Festival-level time slots (read-only view) -->
<div v-if="festivalTimeSlots.length">
<div class="text-caption text-uppercase font-weight-medium text-disabled mb-2">
{{ festivalTimeSlots[0]?.event_name ?? 'Festival' }}
</div>
<div class="d-flex flex-wrap gap-3">
<VCard
v-for="ts in festivalTimeSlots"
:key="ts.id"
variant="outlined"
class="pa-3"
style="min-inline-size: 180px; max-inline-size: 220px; opacity: 0.75;"
>
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-1 font-weight-medium text-truncate">
{{ ts.name }}
</span>
</div>
<div class="text-body-2 text-disabled">
{{ formatTimeSlotDate(ts.date) }} &middot; {{ formatTime(ts.start_time) }}&ndash;{{ formatTime(ts.end_time) }}
</div>
<div class="d-flex align-center gap-x-1 mt-1">
<VChip
size="x-small"
:color="personTypeColor[ts.person_type]"
>
{{ personTypeLabel[ts.person_type] }}
</VChip>
<VChip
v-if="getTimeSlotContext(ts)"
size="x-small"
:color="getTimeSlotContext(ts)!.color"
variant="outlined"
>
{{ getTimeSlotContext(ts)!.label }}
</VChip>
</div>
</VCard>
</div>
</div>
</template>
<!-- Flat view (default, for non-sub-events) -->
<div
v-else
class="d-flex flex-wrap gap-3"
>
<VCard
v-for="ts in timeSlots"
:key="ts.id"
variant="outlined"
class="pa-3"
style="min-inline-size: 180px; max-inline-size: 220px;"
>
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-1 font-weight-medium text-truncate">
{{ ts.name }}
</span>
<div class="d-flex">
<VBtn
icon="tabler-copy"
variant="text"
size="x-small"
title="Dupliceren"
@click="onDuplicateTimeSlot(ts)"
/>
<VBtn
icon="tabler-edit"
variant="text"
size="x-small"
title="Bewerken"
@click="onEditTimeSlot(ts)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="x-small"
color="error"
title="Verwijderen"
@click="onDeleteTimeSlotConfirm(ts)"
/>
</div>
</div>
<div class="text-body-2 text-disabled">
{{ formatTimeSlotDate(ts.date) }} &middot; {{ formatTime(ts.start_time) }}&ndash;{{ formatTime(ts.end_time) }}
</div>
<div class="d-flex align-center gap-x-1 mt-1">
<VChip
size="x-small"
:color="personTypeColor[ts.person_type]"
>
{{ personTypeLabel[ts.person_type] }}
</VChip>
<!-- Context label (Opbouw / Afbraak / Transitie) -->
<VChip
v-if="getTimeSlotContext(ts)"
size="x-small"
:color="getTimeSlotContext(ts)!.color"
variant="outlined"
>
{{ getTimeSlotContext(ts)!.label }}
</VChip>
</div>
<div
v-if="ts.shifts_count != null"
class="text-caption text-disabled mt-1"
>
{{ ts.shifts_count }} shift{{ ts.shifts_count === 1 ? '' : 's' }}
</div>
</VCard>
</div>
</template>
</VCardText>
</div>
</VExpandTransition>
</VCard>
<!-- --> <!-- -->
<!-- SECTIONS + SHIFTS (two-column layout) --> <!-- SECTIONS + SHIFTS (two-column layout) -->
<!-- --> <!-- -->
@@ -988,13 +554,6 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
@updated="onSectionUpdated" @updated="onSectionUpdated"
/> />
<CreateTimeSlotDialog
v-model="isCreateTimeSlotOpen"
:event-id="eventId"
:time-slot="editingTimeSlot"
:duplicate-from="duplicatingTimeSlot"
/>
<CreateShiftDialog <CreateShiftDialog
v-if="activeSection" v-if="activeSection"
v-model="isCreateShiftOpen" v-model="isCreateShiftOpen"
@@ -1002,7 +561,6 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
:section-id="activeSection.id" :section-id="activeSection.id"
:shift="editingShift" :shift="editingShift"
:is-sub-event="isSubEvent" :is-sub-event="isSubEvent"
@open-time-slots="onOpenTimeSlotsFromShiftDialog"
/> />
<AssignShiftDialog <AssignShiftDialog
@@ -1040,34 +598,6 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
</VCard> </VCard>
</VDialog> </VDialog>
<!-- Delete time slot confirmation -->
<VDialog
v-model="isDeleteTimeSlotOpen"
max-width="400"
>
<VCard title="Time Slot verwijderen">
<VCardText>
Weet je zeker dat je dit time slot wilt verwijderen? Alle shifts die dit time slot gebruiken worden ook verwijderd.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteTimeSlotOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeletingTimeSlot"
@click="onDeleteTimeSlotExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Delete shift confirmation --> <!-- Delete shift confirmation -->
<VDialog <VDialog
v-model="isDeleteShiftOpen" v-model="isDeleteShiftOpen"

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios' import { apiClient } from '@/lib/axios'
import type { CreateTimeSlotPayload, TimeSlot, UpdateTimeSlotPayload } from '@/types/section' import type { CreateTimeSlotPayload, TimeSlot, UpdateTimeSlotPayload } from '@/types/timeSlot'
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean success: boolean

View File

@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import EventTabsNav from '@/components/events/EventTabsNav.vue' import EventTabsNav from '@/components/events/EventTabsNav.vue'
import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue' import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue'
import { useEventChildren } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({ definePage({
meta: { meta: {
@@ -11,13 +9,8 @@ definePage({
}) })
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id)) const eventId = computed(() => String((route.params as { id: string }).id))
// Load children for festivals — needed for time slot context chips (Opbouw/Afbraak/Transitie)
const { data: children } = useEventChildren(orgId, eventId)
</script> </script>
<template> <template>
@@ -26,7 +19,6 @@ const { data: children } = useEventChildren(orgId, eventId)
<SectionsShiftsPanel <SectionsShiftsPanel
:event-id="eventId" :event-id="eventId"
:is-sub-event="event?.is_sub_event ?? false" :is-sub-event="event?.is_sub_event ?? false"
:children="children ?? []"
/> />
</template> </template>
</EventTabsNav> </EventTabsNav>

View File

@@ -0,0 +1,460 @@
<script setup lang="ts">
import { useTimeSlotList, useDeleteTimeSlot } from '@/composables/api/useTimeSlots'
import { useEventChildren } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue'
import { PersonType } from '@/types/timeSlot'
import type { TimeSlot, PersonType as PersonTypeValue } from '@/types/timeSlot'
import type { EventItem } from '@/types/event'
definePage({
meta: {
navActiveLink: 'events',
},
})
const route = useRoute()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { data: timeSlots, isLoading, isError, refetch } = useTimeSlotList(eventId)
const { mutate: deleteTimeSlotMutation, isPending: isDeletingTimeSlot } = useDeleteTimeSlot(eventId)
// Load children for festivals — needed for time slot context chips
const { data: children } = useEventChildren(orgId, eventId)
// --- Person type filter ---
const activeFilter = ref<PersonTypeValue | null>(null)
const personTypeLabel: Record<PersonTypeValue, string> = {
VOLUNTEER: 'Vrijwilliger',
CREW: 'Crew',
PRESS: 'Pers',
PHOTO: 'Foto',
PARTNER: 'Partner',
}
const personTypeColor: Record<PersonTypeValue, string> = {
VOLUNTEER: 'success',
CREW: 'info',
PRESS: 'warning',
PHOTO: 'secondary',
PARTNER: 'default',
}
const personTypeFilters = [
{ label: 'Alle types', value: null },
...Object.entries(PersonType).map(([, value]) => ({
label: personTypeLabel[value],
value,
})),
]
const filteredSlots = computed(() => {
if (!timeSlots.value) return []
if (!activeFilter.value) return timeSlots.value
return timeSlots.value.filter(s => s.person_type === activeFilter.value)
})
// --- Date grouping ---
const dutchDateFormatter = new Intl.DateTimeFormat('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
function formatGroupDate(iso: string): string {
const formatted = dutchDateFormatter.format(new Date(iso))
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
const groupedByDate = computed(() => {
const groups = new Map<string, TimeSlot[]>()
filteredSlots.value.forEach(slot => {
const existing = groups.get(slot.date) || []
existing.push(slot)
groups.set(slot.date, existing)
})
return groups
})
// --- Summary ---
const summary = computed(() => {
const slots = timeSlots.value
if (!slots?.length) return ''
const dates = new Set(slots.map(s => s.date))
return `${slots.length} slots · ${dates.size} ${dates.size === 1 ? 'dag' : 'dagen'}`
})
// --- Time slot context labels (Opbouw / Afbraak / Transitie) ---
const childDateRange = computed(() => {
if (!children.value?.length) return null
const starts = children.value.map((c: EventItem) => c.start_date).sort()
const ends = children.value.map((c: EventItem) => c.end_date).sort()
return {
firstStart: starts[0],
lastEnd: ends[ends.length - 1],
dates: children.value.map((c: EventItem) => ({ 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' }
const sorted = [...dates].sort((a, b) => a.start.localeCompare(b.start))
for (let i = 0; i < sorted.length - 1; i++) {
if (tsDate > sorted[i].end && tsDate < sorted[i + 1].start) {
return { label: 'Transitie', color: 'info' }
}
}
return null
}
// --- Fill rate ---
function fillRatePercent(ts: TimeSlot): number {
if (ts.total_slots === 0) return 0
return Math.round((ts.filled_slots / ts.total_slots) * 100)
}
function fillRateColor(ts: TimeSlot): string {
const pct = fillRatePercent(ts)
if (pct >= 80) return 'success'
if (pct >= 50) return 'warning'
return 'error'
}
// --- Duration formatting ---
function formatDuration(ts: TimeSlot): string {
if (ts.duration_hours == null) return ''
const h = Math.floor(ts.duration_hours)
const m = Math.round((ts.duration_hours - h) * 60)
if (m === 0) return `${h} uur`
return `${h},${m < 10 ? '0' : ''}${m} uur`
}
// --- CRUD dialog state ---
const isCreateDialogOpen = ref(false)
const editingTimeSlot = ref<TimeSlot | null>(null)
const duplicatingTimeSlot = ref<TimeSlot | null>(null)
function onAddTimeSlot() {
editingTimeSlot.value = null
duplicatingTimeSlot.value = null
isCreateDialogOpen.value = true
}
function onEditTimeSlot(ts: TimeSlot) {
editingTimeSlot.value = ts
duplicatingTimeSlot.value = null
isCreateDialogOpen.value = true
}
function onDuplicateTimeSlot(ts: TimeSlot) {
editingTimeSlot.value = null
duplicatingTimeSlot.value = ts
isCreateDialogOpen.value = true
}
watch(isCreateDialogOpen, (open) => {
if (!open) duplicatingTimeSlot.value = null
})
// --- Delete confirmation ---
const isDeleteDialogOpen = ref(false)
const deletingTimeSlot = ref<TimeSlot | null>(null)
function onDeleteConfirm(ts: TimeSlot) {
deletingTimeSlot.value = ts
isDeleteDialogOpen.value = true
}
function onDeleteExecute() {
if (!deletingTimeSlot.value) return
deleteTimeSlotMutation(deletingTimeSlot.value.id, {
onSuccess: () => {
isDeleteDialogOpen.value = false
deletingTimeSlot.value = null
},
})
}
</script>
<template>
<EventTabsNav>
<template #default="{ event }">
<!-- Header -->
<div class="d-flex align-center justify-space-between flex-wrap gap-2 mb-4">
<div class="d-flex align-center gap-x-2">
<h5 class="text-h5">
Tijdsloten
</h5>
<VChip
v-if="summary"
size="small"
variant="tonal"
>
{{ summary }}
</VChip>
</div>
<VBtn
prepend-icon="tabler-plus"
@click="onAddTimeSlot"
>
Tijdslot aanmaken
</VBtn>
</div>
<!-- Filter pills -->
<div
v-if="timeSlots?.length"
class="d-flex flex-wrap gap-2 mb-4"
>
<VChip
v-for="filter in personTypeFilters"
:key="String(filter.value)"
:color="activeFilter === filter.value ? 'primary' : 'default'"
:variant="activeFilter === filter.value ? 'flat' : 'outlined'"
class="cursor-pointer"
@click="activeFilter = filter.value"
>
{{ filter.label }}
</VChip>
</div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="table"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon tijdsloten niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty -->
<VCard
v-else-if="!timeSlots?.length"
class="text-center pa-12"
>
<VIcon
icon="tabler-clock"
size="64"
class="mb-4 text-disabled"
/>
<h6 class="text-h6 mb-2">
Nog geen tijdsloten aangemaakt
</h6>
<p class="text-body-1 text-disabled mb-6">
Maak je eerste tijdslot aan om shifts te kunnen plannen.
</p>
<VBtn
prepend-icon="tabler-plus"
@click="onAddTimeSlot"
>
Tijdslot aanmaken
</VBtn>
</VCard>
<!-- Empty after filter -->
<VCard
v-else-if="!filteredSlots.length"
class="text-center pa-8"
>
<p class="text-body-1 text-disabled mb-0">
Geen tijdsloten voor dit type.
</p>
</VCard>
<!-- Grouped by date -->
<template v-else>
<div
v-for="[date, slots] in groupedByDate"
:key="date"
class="mb-6"
>
<!-- Day header -->
<div class="d-flex align-center gap-x-2 mb-3">
<h6 class="text-h6">
{{ formatGroupDate(date) }}
</h6>
<VChip
size="x-small"
variant="tonal"
>
{{ slots.length }} {{ slots.length === 1 ? 'slot' : 'slots' }}
</VChip>
</div>
<!-- Time slot rows -->
<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">
<!-- Name + time range -->
<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>
<!-- Person type badge -->
<VChip
size="small"
:color="personTypeColor[ts.person_type]"
>
{{ personTypeLabel[ts.person_type] }}
</VChip>
<!-- Context label (Opbouw / Afbraak / Transitie) -->
<VChip
v-if="getTimeSlotContext(ts)"
size="small"
:color="getTimeSlotContext(ts)!.color"
variant="outlined"
>
{{ getTimeSlotContext(ts)!.label }}
</VChip>
<!-- Fill rate -->
<div
class="d-flex align-center gap-x-2"
style="min-inline-size: 200px; flex: 1;"
>
<template v-if="ts.shifts_count > 0">
<VProgressLinear
:model-value="fillRatePercent(ts)"
:color="fillRateColor(ts)"
height="8"
rounded
style="max-inline-size: 120px;"
/>
<span class="text-body-2 text-no-wrap">
{{ ts.filled_slots }}/{{ ts.total_slots }} plekken
</span>
</template>
<span
v-else
class="text-body-2 text-disabled font-italic"
>
Geen shifts aangemaakt
</span>
</div>
<!-- Sections count -->
<span
v-if="ts.sections_count > 0"
class="text-body-2 text-no-wrap"
>
{{ ts.sections_count }} {{ ts.sections_count === 1 ? 'sectie' : 'secties' }}
</span>
<VSpacer />
<!-- Actions -->
<div class="d-flex gap-x-1">
<VBtn
icon="tabler-copy"
variant="text"
size="small"
title="Dupliceren"
@click="onDuplicateTimeSlot(ts)"
/>
<VBtn
icon="tabler-edit"
variant="text"
size="small"
title="Bewerken"
@click="onEditTimeSlot(ts)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="small"
color="error"
title="Verwijderen"
@click="onDeleteConfirm(ts)"
/>
</div>
</div>
</VListItem>
</VList>
</VCard>
</div>
</template>
<!-- Create/Edit dialog -->
<CreateTimeSlotDialog
v-model="isCreateDialogOpen"
:event-id="eventId"
:time-slot="editingTimeSlot"
:duplicate-from="duplicatingTimeSlot"
/>
<!-- Delete confirmation dialog -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="450"
>
<VCard title="Tijdslot verwijderen">
<VCardText>
<p>
Weet je zeker dat je dit tijdslot wilt verwijderen?
Alle shifts die aan dit tijdslot gekoppeld zijn worden ook verwijderd.
</p>
<p
v-if="deletingTimeSlot"
class="font-weight-medium mb-0"
>
{{ deletingTimeSlot.name }} {{ formatGroupDate(deletingTimeSlot.date) }}
</p>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeletingTimeSlot"
@click="onDeleteExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
</EventTabsNav>
</template>

View File

@@ -1,3 +1,8 @@
import type { TimeSlot, PersonType, TimeSlotSource } from '@/types/timeSlot'
export type { TimeSlot, PersonType, TimeSlotSource }
export type { CreateTimeSlotPayload, UpdateTimeSlotPayload } from '@/types/timeSlot'
export type SectionType = 'standard' | 'cross_event' export type SectionType = 'standard' | 'cross_event'
export type ShiftStatus = 'draft' | 'open' | 'full' | 'in_progress' | 'completed' | 'cancelled' export type ShiftStatus = 'draft' | 'open' | 'full' | 'in_progress' | 'completed' | 'cancelled'
@@ -16,24 +21,6 @@ export interface FestivalSection {
created_at: string created_at: string
} }
export type PersonType = 'CREW' | 'VOLUNTEER' | 'PRESS' | 'PHOTO' | 'PARTNER'
export type TimeSlotSource = 'sub_event' | 'festival'
export interface TimeSlot {
id: string
event_id: string
name: string
person_type: PersonType
date: string
start_time: string
end_time: string
duration_hours: number | null
source?: TimeSlotSource | null
event_name?: string | null
shifts_count?: number
}
export interface Shift { export interface Shift {
id: string id: string
festival_section_id: string festival_section_id: string
@@ -78,16 +65,6 @@ export interface CreateSectionPayload {
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {} export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}
export interface CreateTimeSlotPayload {
name: string
person_type: string
date: string
start_time: string
end_time: string
duration_hours?: number
}
export interface UpdateTimeSlotPayload extends Partial<CreateTimeSlotPayload> {}
export interface CreateShiftPayload { export interface CreateShiftPayload {
time_slot_id: string time_slot_id: string

View File

@@ -0,0 +1,40 @@
export const PersonType = {
CREW: 'CREW',
VOLUNTEER: 'VOLUNTEER',
PRESS: 'PRESS',
PHOTO: 'PHOTO',
PARTNER: 'PARTNER',
} as const
export type PersonType = (typeof PersonType)[keyof typeof PersonType]
export type TimeSlotSource = 'sub_event' | 'festival'
export interface TimeSlot {
id: string
event_id: string
name: string
person_type: PersonType
date: string
start_time: string
end_time: string
duration_hours: number | null
source?: TimeSlotSource | null
event_name?: string | null
shifts_count: number
total_slots: number
filled_slots: number
sections_count: number
created_at: string
}
export interface CreateTimeSlotPayload {
name: string
person_type: PersonType
date: string
start_time: string
end_time: string
duration_hours?: number
}
export interface UpdateTimeSlotPayload extends Partial<CreateTimeSlotPayload> {}

View File

@@ -25,10 +25,12 @@ declare module 'vue-router/auto-routes' {
'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-artists': RouteRecordInfo<'events-id-artists', '/events/:id/artists', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-artists': RouteRecordInfo<'events-id-artists', '/events/:id/artists', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-briefings': RouteRecordInfo<'events-id-briefings', '/events/:id/briefings', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-briefings': RouteRecordInfo<'events-id-briefings', '/events/:id/briefings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-crowd-lists': RouteRecordInfo<'events-id-crowd-lists', '/events/:id/crowd-lists', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-persons': RouteRecordInfo<'events-id-persons', '/events/:id/persons', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-persons': RouteRecordInfo<'events-id-persons', '/events/:id/persons', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-programmaonderdelen': RouteRecordInfo<'events-id-programmaonderdelen', '/events/:id/programmaonderdelen', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-programmaonderdelen': RouteRecordInfo<'events-id-programmaonderdelen', '/events/:id/programmaonderdelen', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-sections': RouteRecordInfo<'events-id-sections', '/events/:id/sections', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-sections': RouteRecordInfo<'events-id-sections', '/events/:id/sections', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>, 'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>, 'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>, 'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>, 'organisation': RouteRecordInfo<'organisation', '/organisation', Record<never, never>, Record<never, never>>,