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

@@ -1,8 +1,6 @@
<script setup lang="ts">
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue'
import { useEventChildren } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
meta: {
@@ -11,13 +9,8 @@ definePage({
})
const route = useRoute()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.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>
<template>
@@ -26,7 +19,6 @@ const { data: children } = useEventChildren(orgId, eventId)
<SectionsShiftsPanel
:event-id="eventId"
:is-sub-event="event?.is_sub_event ?? false"
:children="children ?? []"
/>
</template>
</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>