feat: schema v1.7 + sections/shifts frontend

- Universeel festival/event model (parent_event_id, event_type)
- event_person_activations pivot tabel
- Event model: parent/children relaties + helper scopes
- DevSeeder: festival structuur met sub-events
- Sections & Shifts frontend (twee-kolom layout)
- BACKLOG.md aangemaakt met 22 gedocumenteerde wensen
This commit is contained in:
2026-04-08 07:23:56 +02:00
parent 6f69b30fb6
commit 6848bc2c49
19 changed files with 2560 additions and 87 deletions

View File

@@ -1,19 +1,587 @@
<script setup lang="ts">
import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections'
import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
import { useAuthStore } from '@/stores/useAuthStore'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.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 } from '@/types/section'
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))
// --- Section list ---
const { data: sections, isLoading: sectionsLoading } = useSectionList(eventId)
const { mutate: deleteSection } = useDeleteSection(eventId)
const { mutate: reorderSections } = useReorderSections(eventId)
const activeSectionId = ref<string | null>(null)
const activeSection = computed(() =>
sections.value?.find(s => s.id === activeSectionId.value) ?? null,
)
// Auto-select first section
watch(sections, (list) => {
if (list?.length && !activeSectionId.value) {
activeSectionId.value = list[0].id
}
}, { immediate: true })
// --- Shifts for active section ---
const activeSectionIdRef = computed(() => activeSectionId.value ?? '')
const { data: shifts, isLoading: shiftsLoading } = useShiftList(eventId, activeSectionIdRef)
const { mutate: deleteShiftMutation, isPending: isDeleting } = useDeleteShift(eventId, activeSectionIdRef)
// Group shifts by time_slot_id
const shiftsByTimeSlot = computed(() => {
if (!shifts.value) return []
const groups = new Map<string, { timeSlotName: string; date: string; startTime: string; endTime: string; totalSlots: number; filledSlots: number; shifts: Shift[] }>()
for (const shift of shifts.value) {
const tsId = shift.time_slot_id
if (!groups.has(tsId)) {
groups.set(tsId, {
timeSlotName: shift.time_slot?.name ?? 'Onbekend',
date: shift.time_slot?.date ?? '',
startTime: shift.effective_start_time,
endTime: shift.effective_end_time,
totalSlots: 0,
filledSlots: 0,
shifts: [],
})
}
const group = groups.get(tsId)!
group.shifts.push(shift)
group.totalSlots += shift.slots_total
group.filledSlots += shift.filled_slots
}
return Array.from(groups.values())
})
// --- Dialogs ---
const isCreateSectionOpen = ref(false)
const isEditSectionOpen = ref(false)
const isCreateTimeSlotOpen = ref(false)
const isCreateShiftOpen = ref(false)
const isAssignShiftOpen = ref(false)
const editingShift = ref<Shift | null>(null)
const assigningShift = ref<Shift | null>(null)
// Delete section
const isDeleteSectionOpen = ref(false)
const deletingSectionId = ref<string | null>(null)
function onDeleteSectionConfirm(section: FestivalSection) {
deletingSectionId.value = section.id
isDeleteSectionOpen.value = true
}
function onDeleteSectionExecute() {
if (!deletingSectionId.value) return
deleteSection(deletingSectionId.value, {
onSuccess: () => {
isDeleteSectionOpen.value = false
if (activeSectionId.value === deletingSectionId.value) {
activeSectionId.value = sections.value?.[0]?.id ?? null
}
deletingSectionId.value = null
},
})
}
// Delete shift
const isDeleteShiftOpen = ref(false)
const deletingShiftId = ref<string | null>(null)
function onDeleteShiftConfirm(shift: Shift) {
deletingShiftId.value = shift.id
isDeleteShiftOpen.value = true
}
function onDeleteShiftExecute() {
if (!deletingShiftId.value) return
deleteShiftMutation(deletingShiftId.value, {
onSuccess: () => {
isDeleteShiftOpen.value = false
deletingShiftId.value = null
},
})
}
function onEditShift(shift: Shift) {
editingShift.value = shift
isCreateShiftOpen.value = true
}
function onAssignShift(shift: Shift) {
assigningShift.value = shift
isAssignShiftOpen.value = true
}
function onAddShift() {
editingShift.value = null
isCreateShiftOpen.value = true
}
function onEditSection() {
// Re-use create dialog for editing section (section name in header)
isEditSectionOpen.value = true
}
// Status styling
const statusColor: Record<ShiftStatus, string> = {
draft: 'default',
open: 'info',
full: 'success',
in_progress: 'warning',
completed: 'success',
cancelled: 'error',
}
const statusLabel: Record<ShiftStatus, string> = {
draft: 'Concept',
open: 'Open',
full: 'Vol',
in_progress: 'Bezig',
completed: 'Voltooid',
cancelled: 'Geannuleerd',
}
function fillRateColor(rate: number): string {
if (rate >= 80) return 'success'
if (rate >= 40) return 'warning'
return 'error'
}
// Drag & drop reorder
const dragIndex = ref<number | null>(null)
function onDragStart(index: number) {
dragIndex.value = index
}
function onDragOver(e: DragEvent) {
e.preventDefault()
}
function onDrop(targetIndex: number) {
if (dragIndex.value === null || dragIndex.value === targetIndex || !sections.value) return
const items = [...sections.value]
const [moved] = items.splice(dragIndex.value, 1)
items.splice(targetIndex, 0, moved)
reorderSections(items.map(s => s.id))
dragIndex.value = null
}
function onDragEnd() {
dragIndex.value = null
}
// Date formatting
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
})
function formatDate(iso: string) {
if (!iso) return ''
return dateFormatter.format(new Date(iso))
}
// Success snackbar
const showSuccess = ref(false)
const successMessage = ref('')
</script>
<template>
<EventTabsNav>
<VCard class="ma-4">
<VCardText>
Deze module is binnenkort beschikbaar.
</VCardText>
</VCard>
<VRow>
<!-- LEFT COLUMN Sections list -->
<VCol
cols="12"
md="3"
style="min-inline-size: 280px; max-inline-size: 320px;"
>
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<span>Secties</span>
<VBtn
icon="tabler-plus"
variant="text"
size="small"
@click="isCreateSectionOpen = true"
/>
</VCardTitle>
<!-- Loading -->
<VSkeletonLoader
v-if="sectionsLoading"
type="list-item@4"
/>
<!-- Empty -->
<VCardText
v-else-if="!sections?.length"
class="text-center text-disabled"
>
Geen secties maak er een aan
</VCardText>
<!-- Section list -->
<VList
v-else
density="compact"
nav
>
<VListItem
v-for="(section, index) in sections"
:key="section.id"
:active="section.id === activeSectionId"
color="primary"
draggable="true"
@click="activeSectionId = section.id"
@dragstart="onDragStart(index)"
@dragover="onDragOver"
@drop="onDrop(index)"
@dragend="onDragEnd"
>
<template #prepend>
<VIcon
icon="tabler-grip-vertical"
size="16"
class="cursor-grab me-1"
style="opacity: 0.4;"
/>
</template>
<VListItemTitle>{{ section.name }}</VListItemTitle>
<template #append>
<VChip
v-if="section.type === 'cross_event'"
size="x-small"
color="info"
class="me-1"
>
Overkoepelend
</VChip>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
<!-- RIGHT COLUMN Shifts for active section -->
<VCol>
<!-- No section selected -->
<VCard
v-if="!activeSection"
class="text-center pa-8"
>
<VIcon
icon="tabler-layout-grid"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled">
Selecteer een sectie om shifts te beheren
</p>
</VCard>
<!-- Section selected -->
<template v-else>
<!-- Header -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center justify-space-between flex-wrap gap-2">
<div class="d-flex align-center gap-x-2">
<span>{{ activeSection.name }}</span>
<VChip
v-if="activeSection.type === 'cross_event'"
size="small"
color="info"
>
Overkoepelend
</VChip>
<span
v-if="activeSection.crew_need"
class="text-body-2 text-disabled"
>
Crew nodig: {{ activeSection.crew_need }}
</span>
</div>
<div class="d-flex gap-x-2">
<VBtn
size="small"
variant="tonal"
prepend-icon="tabler-clock"
@click="isCreateTimeSlotOpen = true"
>
Time Slot
</VBtn>
<VBtn
size="small"
variant="tonal"
prepend-icon="tabler-plus"
@click="onAddShift"
>
Shift
</VBtn>
<VBtn
size="small"
variant="tonal"
icon="tabler-edit"
@click="onEditSection"
/>
<VBtn
size="small"
variant="tonal"
icon="tabler-trash"
color="error"
@click="onDeleteSectionConfirm(activeSection)"
/>
</div>
</VCardTitle>
</VCard>
<!-- Loading shifts -->
<VSkeletonLoader
v-if="shiftsLoading"
type="card@3"
/>
<!-- No shifts -->
<VCard
v-else-if="!shifts?.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-calendar-time"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled mb-4">
Nog geen shifts voor deze sectie
</p>
<VBtn
prepend-icon="tabler-plus"
@click="onAddShift"
>
Shift toevoegen
</VBtn>
</VCard>
<!-- Shifts grouped by time slot -->
<template v-else>
<VCard
v-for="(group, gi) in shiftsByTimeSlot"
:key="gi"
class="mb-4"
>
<!-- Group header -->
<VCardTitle class="d-flex align-center justify-space-between">
<div>
<span>{{ group.timeSlotName }}</span>
<span class="text-body-2 text-disabled ms-2">
{{ formatDate(group.date) }} {{ group.startTime }}{{ group.endTime }}
</span>
</div>
<span class="text-body-2">
{{ group.filledSlots }}/{{ group.totalSlots }} ingevuld
</span>
</VCardTitle>
<VDivider />
<!-- Shifts in group -->
<VList density="compact">
<VListItem
v-for="shift in group.shifts"
:key="shift.id"
>
<div class="d-flex align-center gap-x-3 py-1 flex-wrap">
<!-- Title + lead badge -->
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
<span class="text-body-1 font-weight-medium">
{{ shift.title ?? 'Shift' }}
</span>
<VChip
v-if="shift.is_lead_role"
size="x-small"
color="warning"
>
Hoofdrol
</VChip>
</div>
<!-- Fill rate -->
<div class="d-flex align-center gap-x-2" style="min-inline-size: 160px;">
<VProgressLinear
:model-value="shift.fill_rate"
:color="fillRateColor(shift.fill_rate)"
height="8"
rounded
style="inline-size: 80px;"
/>
<span class="text-body-2 text-no-wrap">
{{ shift.filled_slots }}/{{ shift.slots_total }}
</span>
</div>
<!-- Status -->
<VChip
:color="statusColor[shift.status]"
size="small"
>
{{ statusLabel[shift.status] }}
</VChip>
<VSpacer />
<!-- Actions -->
<div class="d-flex gap-x-1">
<VBtn
icon="tabler-user-plus"
variant="text"
size="small"
title="Toewijzen"
@click="onAssignShift(shift)"
/>
<VBtn
icon="tabler-edit"
variant="text"
size="small"
title="Bewerken"
@click="onEditShift(shift)"
/>
<VBtn
icon="tabler-trash"
variant="text"
size="small"
color="error"
title="Verwijderen"
@click="onDeleteShiftConfirm(shift)"
/>
</div>
</div>
</VListItem>
</VList>
</VCard>
</template>
</template>
</VCol>
</VRow>
<!-- Dialogs -->
<CreateSectionDialog
v-model="isCreateSectionOpen"
:event-id="eventId"
/>
<CreateSectionDialog
v-model="isEditSectionOpen"
:event-id="eventId"
/>
<CreateTimeSlotDialog
v-model="isCreateTimeSlotOpen"
:event-id="eventId"
/>
<CreateShiftDialog
v-if="activeSection"
v-model="isCreateShiftOpen"
:event-id="eventId"
:section-id="activeSection.id"
:shift="editingShift"
/>
<AssignShiftDialog
v-if="activeSection"
v-model="isAssignShiftOpen"
:event-id="eventId"
:section-id="activeSection.id"
:shift="assigningShift"
/>
<!-- Delete section confirmation -->
<VDialog
v-model="isDeleteSectionOpen"
max-width="400"
>
<VCard title="Sectie verwijderen">
<VCardText>
Weet je zeker dat je deze sectie en alle bijbehorende shifts wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteSectionOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
@click="onDeleteSectionExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Delete shift confirmation -->
<VDialog
v-model="isDeleteShiftOpen"
max-width="400"
>
<VCard title="Shift verwijderen">
<VCardText>
Weet je zeker dat je deze shift wilt verwijderen?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteShiftOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="onDeleteShiftExecute"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Success snackbar -->
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ successMessage }}
</VSnackbar>
</EventTabsNav>
</template>