feat: festival/series model with sub-events, cross-event sections, tab navigation, SectionsShiftsPanel extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:15:19 +02:00
parent 11b9f1d399
commit 10bd55b8ae
40 changed files with 3087 additions and 1080 deletions

View File

@@ -41,6 +41,7 @@ const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [
const subEventLabelOptions = [
'Dag',
'Evenement',
'Programmaonderdeel',
'Editie',
'Locatie',
@@ -134,11 +135,11 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard title="Nieuw evenement">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -147,6 +148,7 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
@@ -158,6 +160,7 @@ function onSubmit() {
:error-messages="errors.slug"
hint="Wordt gebruikt in de URL"
persistent-hint
autocomplete="one-time-code"
/>
</VCol>
@@ -203,6 +206,7 @@ function onSubmit() {
:error-messages="errors.sub_event_label"
hint="Kies uit de lijst of typ een eigen naam"
persistent-hint
autocomplete="one-time-code"
/>
</VCol>
@@ -214,6 +218,7 @@ function onSubmit() {
:error-messages="errors.event_type_label"
maxlength="50"
counter
autocomplete="one-time-code"
/>
</VCol>
@@ -250,24 +255,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Aanmaken
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -12,6 +12,7 @@ const props = defineProps<{
const modelValue = defineModel<boolean>({ required: true })
const orgIdRef = computed(() => props.orgId)
const parentEventIdRef = computed(() => props.parentEvent.id)
const subEventLabel = computed(() =>
props.parentEvent.sub_event_label ?? 'Programmaonderdeel',
@@ -29,7 +30,7 @@ const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef)
const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef, parentEventIdRef)
const statusOptions: { title: string; value: EventStatus }[] = [
{ title: 'Draft', value: 'draft' },
@@ -72,7 +73,6 @@ function onSubmit() {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
@@ -97,15 +97,15 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard :title="`${subEventLabel} toevoegen`">
<VCardText>
<div class="text-body-2 text-medium-emphasis mb-4">
Onderdeel van: <strong>{{ parentEvent.name }}</strong>
</div>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<div class="text-body-2 text-medium-emphasis mb-4">
Onderdeel van: <strong>{{ parentEvent.name }}</strong>
</div>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
@@ -115,6 +115,7 @@ function onSubmit() {
:error-messages="errors.name"
:placeholder="`Dag 1, ${subEventLabel} 1...`"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
@@ -159,24 +160,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Toevoegen
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Toevoegen
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useDeleteEvent } from '@/composables/api/useEvents'
import type { EventItem } from '@/types/event'
const props = defineProps<{
event: EventItem | null
orgId: string
parentEventId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const emit = defineEmits<{
deleted: [name: string]
}>()
const orgIdRef = computed(() => props.orgId)
const parentEventIdRef = computed(() => props.parentEventId)
const { mutate: deleteEvent, isPending } = useDeleteEvent(orgIdRef, parentEventIdRef)
function onConfirm() {
if (!props.event) return
const name = props.event.name
deleteEvent(props.event.id, {
onSuccess: () => {
modelValue.value = false
emit('deleted', name)
},
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="450"
>
<VCard title="Programmaonderdeel verwijderen">
<VCardText>
<p class="text-body-1 mb-0">
Weet je zeker dat je <strong>'{{ event?.name }}'</strong> wilt verwijderen?
Dit programmaonderdeel en alle bijbehorende secties, shifts en toewijzingen worden gearchiveerd.
</p>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="error"
:loading="isPending"
@click="onConfirm"
>
Verwijderen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -21,6 +21,7 @@ const form = ref({
end_date: '',
timezone: '',
status: '' as EventStatus,
sub_event_label: '' as string | null,
})
const errors = ref<Record<string, string>>({})
@@ -29,6 +30,19 @@ const showSuccess = ref(false)
const { mutate: updateEvent, isPending } = useUpdateEvent(orgIdRef, eventIdRef)
const isFestivalOrSeries = computed(() =>
props.event.event_type === 'festival' || props.event.event_type === 'series',
)
const subEventLabelOptions = [
'Dag',
'Evenement',
'Programmaonderdeel',
'Editie',
'Locatie',
'Ronde',
]
const timezoneOptions = [
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ title: 'Europe/London', value: 'Europe/London' },
@@ -62,6 +76,7 @@ watch(() => props.event, (ev) => {
end_date: ev.end_date,
timezone: ev.timezone,
status: ev.status,
sub_event_label: ev.sub_event_label ?? '',
}
}, { immediate: true })
@@ -71,7 +86,14 @@ function onSubmit() {
errors.value = {}
updateEvent(form.value, {
const payload = {
...form.value,
sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label
? form.value.sub_event_label
: null,
}
updateEvent(payload, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
@@ -98,11 +120,11 @@ function onSubmit() {
max-width="550"
>
<VCard title="Evenement bewerken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -111,6 +133,7 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
@@ -119,6 +142,21 @@ function onSubmit() {
label="Slug"
:rules="[requiredValidator]"
:error-messages="errors.slug"
autocomplete="one-time-code"
/>
</VCol>
<VCol
v-if="isFestivalOrSeries"
cols="12"
>
<AppCombobox
v-model="form.sub_event_label"
label="Hoe noem jij de onderdelen?"
:items="subEventLabelOptions"
:error-messages="errors.sub_event_label"
hint="Kies uit de lijst of typ een eigen naam"
persistent-hint
autocomplete="one-time-code"
/>
</VCol>
<VCol
@@ -168,24 +206,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>

View File

@@ -1,16 +1,11 @@
<script setup lang="ts">
import { useEventDetail } from '@/composables/api/useEvents'
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import EditEventDialog from '@/components/events/EditEventDialog.vue'
import type { EventStatus } from '@/types/event'
const props = withDefaults(defineProps<{
hideTabs?: boolean
}>(), {
hideTabs: false,
})
const route = useRoute()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
@@ -20,6 +15,9 @@ const eventId = computed(() => String((route.params as { id: string }).id))
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
// Children count for programmaonderdelen badge — only for festivals
const { data: children } = useEventChildren(orgId, eventId)
// Set active event in store
watch(eventId, (id) => {
if (id) orgStore.setActiveEvent(id)
@@ -52,7 +50,7 @@ function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const tabs = [
const baseTabs = [
{ label: 'Overzicht', icon: 'tabler-layout-dashboard', route: 'events-id' },
{ label: 'Personen', icon: 'tabler-users', route: 'events-id-persons' },
{ label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' },
@@ -61,9 +59,38 @@ const tabs = [
{ label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' },
]
const programmaonderdelenLabel = computed(() => {
const label = event.value?.sub_event_label
? dutchPlural(event.value.sub_event_label)
: 'Programmaonderdelen'
const count = children.value?.length ?? event.value?.children_count ?? 0
return `${label} (${count})`
})
const tabs = computed(() => {
if (!event.value?.is_festival) return baseTabs
// Festival tab order: Overzicht | Programmaonderdelen | Secties & Shifts | Personen | Artiesten | Briefings | Instellingen
const festivalTab = {
label: programmaonderdelenLabel.value,
icon: 'tabler-calendar-event',
route: 'events-id-programmaonderdelen',
}
return [
baseTabs[0], // Overzicht
festivalTab,
baseTabs[2], // Secties & Shifts
baseTabs[1], // Personen
baseTabs[3], // Artiesten
baseTabs[4], // Briefings
baseTabs[5], // Instellingen
]
})
const activeTab = computed(() => {
const name = route.name as string
return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
return tabs.value.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id'
})
const backRoute = computed(() => {
@@ -100,22 +127,6 @@ const backRoute = computed(() => {
</VAlert>
<template v-else-if="event">
<!-- Sub-event breadcrumb -->
<div
v-if="event.is_sub_event && event.parent && event.parent_event_id"
class="text-caption text-medium-emphasis mb-1"
>
<VIcon size="12">
tabler-arrow-left
</VIcon>
<RouterLink
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
class="text-medium-emphasis"
>
{{ event.parent.name }}
</RouterLink>
</div>
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="d-flex align-center gap-x-3">
@@ -125,6 +136,15 @@ const backRoute = computed(() => {
:to="backRoute"
/>
<h4 class="text-h4">
<template v-if="event.is_sub_event && event.parent && event.parent_event_id">
<RouterLink
:to="{ name: 'events-id', params: { id: event.parent_event_id } }"
class="text-medium-emphasis text-decoration-none"
>
{{ event.parent.name }}
</RouterLink>
<span class="text-medium-emphasis mx-1">&raquo;</span>
</template>
{{ event.name }}
</h4>
<VChip
@@ -153,9 +173,8 @@ const backRoute = computed(() => {
</VBtn>
</div>
<!-- Horizontal tabs (hidden for festival containers) -->
<!-- Horizontal tabs -->
<VTabs
v-if="!hideTabs"
:model-value="activeTab"
class="mb-6"
>

View File

@@ -1,19 +1,34 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateSection } from '@/composables/api/useSections'
import { useCreateSection, useSectionCategories } from '@/composables/api/useSections'
import { useAuthStore } from '@/stores/useAuthStore'
import { requiredValidator } from '@core/utils/validators'
import type { SectionType } from '@/types/section'
const props = defineProps<{
const props = withDefaults(defineProps<{
eventId: string
isSubEvent?: boolean
nextSortOrder?: number
}>(), {
isSubEvent: false,
nextSortOrder: 0,
})
const emit = defineEmits<{
created: [payload: { name: string; redirectedToParent: boolean; parentEventName?: string }]
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const { data: categorySuggestions } = useSectionCategories(orgId)
const form = ref({
name: '',
category: null as string | null,
icon: null as string | null,
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
@@ -29,9 +44,15 @@ const typeOptions = [
{ title: 'Overkoepelend', value: 'cross_event' },
]
const showCrossEventHint = computed(() =>
props.isSubEvent && form.value.type === 'cross_event',
)
function resetForm() {
form.value = {
name: '',
category: null,
icon: null,
type: 'standard',
crew_auto_accepts: false,
responder_self_checkin: true,
@@ -49,13 +70,21 @@ function onSubmit() {
createSection(
{
name: form.value.name,
category: form.value.category || null,
icon: form.value.icon || null,
type: form.value.type,
sort_order: props.nextSortOrder,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
},
{
onSuccess: () => {
onSuccess: (result) => {
modelValue.value = false
emit('created', {
name: result.section.name,
redirectedToParent: result.redirectedToParent,
parentEventName: result.parentEventName,
})
resetForm()
},
onError: (err: any) => {
@@ -65,6 +94,9 @@ function onSubmit() {
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { type: data.message }
}
},
},
)
@@ -79,11 +111,11 @@ function onSubmit() {
@after-leave="resetForm"
>
<VCard title="Sectie aanmaken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
@@ -92,8 +124,38 @@ function onSubmit() {
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<VCombobox
v-model="form.category"
label="Categorie"
:items="categorySuggestions ?? []"
placeholder="Bijv. Bar, Podium, Operationeel..."
clearable
:error-messages="errors.category"
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<div class="d-flex align-center gap-x-2">
<AppTextField
v-model="form.icon"
label="Icoon"
placeholder="tabler-beer"
clearable
:error-messages="errors.icon"
class="flex-grow-1"
autocomplete="one-time-code"
/>
<VIcon
v-if="form.icon"
:icon="form.icon"
size="24"
/>
</div>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.type"
@@ -101,6 +163,15 @@ function onSubmit() {
:items="typeOptions"
:error-messages="errors.type"
/>
<VAlert
v-if="showCrossEventHint"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
Deze sectie wordt automatisch aangemaakt op festival-niveau en is zichtbaar in alle programmaonderdelen.
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
@@ -119,24 +190,24 @@ function onSubmit() {
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Aanmaken
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -81,7 +81,7 @@ export function useCreateEvent(orgId: Ref<string>) {
})
}
export function useCreateSubEvent(orgId: Ref<string>) {
export function useCreateSubEvent(orgId: Ref<string>, parentEventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
@@ -94,6 +94,21 @@ export function useCreateSubEvent(orgId: Ref<string>) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] })
},
})
}
export function useDeleteEvent(orgId: Ref<string>, parentEventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (eventId: string) => {
await apiClient.delete(`/organisations/${orgId.value}/events/${eventId}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events', orgId.value] })
queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] })
},
})
}

View File

@@ -13,6 +13,20 @@ interface PaginatedResponse<T> {
data: T[]
}
export function useSectionCategories(orgId: Ref<string>) {
return useQuery({
queryKey: ['section-categories', orgId],
queryFn: async () => {
const { data } = await apiClient.get<{ data: string[] }>(
`/organisations/${orgId.value}/section-categories`,
)
return data.data
},
enabled: () => !!orgId.value,
})
}
export function useSectionList(eventId: Ref<string>) {
return useQuery({
queryKey: ['sections', eventId],
@@ -27,17 +41,27 @@ export function useSectionList(eventId: Ref<string>) {
})
}
export interface CreateSectionResult {
section: FestivalSection
redirectedToParent: boolean
parentEventName?: string
}
export function useCreateSection(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateSectionPayload) => {
const { data } = await apiClient.post<ApiResponse<FestivalSection>>(
mutationFn: async (payload: CreateSectionPayload): Promise<CreateSectionResult> => {
const { data } = await apiClient.post<ApiResponse<FestivalSection> & { meta?: { redirected_to_parent?: boolean; parent_event_name?: string } }>(
`/events/${eventId.value}/sections`,
payload,
)
return data.data
return {
section: data.data,
redirectedToParent: data.meta?.redirected_to_parent ?? false,
parentEventName: data.meta?.parent_event_name,
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
@@ -58,7 +82,8 @@ export function useUpdateSection(eventId: Ref<string>) {
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
// Invalidate all section lists — a cross_event section update affects multiple events
queryClient.invalidateQueries({ queryKey: ['sections'] })
},
})
}
@@ -78,15 +103,32 @@ export function useDeleteSection(eventId: Ref<string>) {
export function useReorderSections(eventId: Ref<string>) {
const queryClient = useQueryClient()
let previousSections: FestivalSection[] | undefined
return useMutation({
mutationFn: async (orderedIds: string[]) => {
await apiClient.post(`/events/${eventId.value}/sections/reorder`, {
ordered_ids: orderedIds,
sections: orderedIds,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
onMutate: async (orderedIds) => {
await queryClient.cancelQueries({ queryKey: ['sections', eventId.value] })
previousSections = queryClient.getQueryData<FestivalSection[]>(['sections', eventId.value])
// Optimistically update query cache so watch doesn't snap back
if (previousSections) {
const byId = new Map(previousSections.map(s => [s.id, s]))
const reordered = orderedIds
.map(id => byId.get(id))
.filter((s): s is FestivalSection => !!s)
queryClient.setQueryData(['sections', eventId.value], reordered)
}
},
onError: () => {
if (previousSections) {
queryClient.setQueryData(['sections', eventId.value], previousSections)
}
},
// No onSuccess invalidation — query cache and v-model are already in sync
})
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
import { useEventDetail, useEventChildren } from '@/composables/api/useEvents'
import { useEventChildren } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { useAuthStore } from '@/stores/useAuthStore'
import type { EventStatus, EventItem } from '@/types/event'
@@ -18,16 +18,9 @@ const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { data: event } = useEventDetail(orgId, eventId)
const isFestival = computed(() => event.value?.is_festival ?? false)
const isFlatEvent = computed(() => event.value?.is_flat_event ?? false)
// Children query — only enabled for festivals
// Children query — only enabled for festivals (composable handles this)
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
const isCreateSubEventOpen = ref(false)
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
@@ -48,20 +41,17 @@ function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const subEventLabel = computed(() =>
event.value?.sub_event_label ?? 'Programmaonderdeel',
)
// Compact preview: max 4 children
const previewChildren = computed(() => (children.value ?? []).slice(0, 4))
const subEventLabelPlural = computed(() =>
event.value?.sub_event_label
? `${event.value.sub_event_label}en`
: 'Programmaonderdelen',
)
function navigateToChild(child: EventItem) {
router.push({ name: 'events-id', params: { id: child.id } })
}
// --- Flat event tiles (existing behaviour) ---
// --- Flat event / sub-event tiles ---
const tiles = [
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success', route: 'events-id-persons', enabled: true },
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary', route: null, enabled: false },
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary', route: 'events-id-sections', enabled: true },
{ title: 'Artiesten', value: 0, icon: 'tabler-music', color: 'warning', route: null, enabled: false },
{ title: 'Briefings', value: 0, icon: 'tabler-mail', color: 'info', route: null, enabled: false },
]
@@ -70,199 +60,195 @@ function onTileClick(tile: typeof tiles[number]) {
if (tile.enabled && tile.route)
router.push({ name: tile.route as 'events-id-persons', params: { id: eventId.value } })
}
function navigateToChild(child: EventItem) {
router.push({ name: 'events-id', params: { id: child.id } })
}
</script>
<template>
<!-- Festival / Series view -->
<template v-if="isFestival && event">
<EventTabsNav :hide-tabs="true">
<!-- Sub-events subtitle bar -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="text-body-1 text-medium-emphasis">
{{ children?.length ?? event.children_count ?? 0 }} {{ subEventLabelPlural.toLowerCase() }}
</div>
<VBtn
prepend-icon="tabler-plus"
size="small"
@click="isCreateSubEventOpen = true"
>
{{ subEventLabel }} toevoegen
</VBtn>
</div>
<EventTabsNav>
<template #default="{ event }">
<!-- -->
<!-- Festival Overzicht (dashboard) -->
<!-- -->
<template v-if="event?.is_festival">
<!-- Programmaonderdelen compact preview -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center justify-space-between">
<span>
{{ event.sub_event_label ? dutchPlural(event.sub_event_label) : 'Programmaonderdelen' }}
({{ children?.length ?? event.children_count ?? 0 }})
</span>
<VBtn
variant="text"
size="small"
append-icon="tabler-arrow-right"
:to="{ name: 'events-id-programmaonderdelen', params: { id: event.id } }"
>
Bekijk alle
</VBtn>
</VCardTitle>
<!-- Children loading -->
<VSkeletonLoader
v-if="childrenLoading"
type="card@3"
/>
<VCardText>
<VSkeletonLoader
v-if="childrenLoading"
type="card@3"
/>
<!-- Children grid -->
<VRow v-else-if="children?.length">
<VCol
v-for="child in children"
:key="child.id"
cols="12"
md="6"
>
<VCard
class="cursor-pointer"
hover
@click="navigateToChild(child)"
>
<VCardText>
<div class="d-flex justify-space-between align-start mb-1">
<h6 class="text-h6">
{{ child.name }}
</h6>
<VChip
:color="statusColor[child.status]"
size="small"
<VRow v-else-if="previewChildren.length">
<VCol
v-for="child in previewChildren"
:key="child.id"
cols="6"
md="3"
>
<VCard
variant="outlined"
class="cursor-pointer"
hover
@click="navigateToChild(child)"
>
{{ child.status }}
</VChip>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ formatDate(child.start_date) }}
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<VCardText class="pa-3">
<h6 class="text-subtitle-1 font-weight-medium text-truncate mb-1">
{{ child.name }}
</h6>
<p class="text-body-2 text-medium-emphasis mb-1">
{{ formatDate(child.start_date) }}
</p>
<VChip
:color="statusColor[child.status]"
size="x-small"
>
{{ child.status }}
</VChip>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Children empty -->
<VCard
v-else
class="text-center pa-6"
>
<p class="text-body-1 text-disabled mb-0">
Nog geen {{ subEventLabelPlural.toLowerCase() }}. Voeg je eerste {{ subEventLabel.toLowerCase() }} toe.
</p>
</VCard>
<p
v-else
class="text-body-2 text-disabled mb-0"
>
Nog geen {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}.
</p>
</VCardText>
</VCard>
<!-- Bottom info cards -->
<VRow class="mt-6">
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between align-center">
<!-- Vrijwilligers + Capaciteit cards -->
<VRow>
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center gap-x-3">
<VAvatar
color="success"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-users"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Vrijwilligers
</p>
<h4 class="text-h4">
0
</h4>
</div>
</div>
<VBtn
variant="tonal"
size="small"
:to="{ name: 'events-id-persons', params: { id: event.id } }"
>
Beheren
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="6"
>
<VCard>
<VCardText>
<div class="d-flex align-center gap-x-3">
<VAvatar
color="success"
color="info"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-users"
icon="tabler-chart-bar"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Vrijwilligers
Capaciteitsoverzicht
</p>
<p class="text-body-2 text-disabled mb-0">
Volledige capaciteitsplanning zichtbaar zodra {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }} zijn aangemaakt
</p>
<h4 class="text-h4">
0
</h4>
</div>
</div>
<VBtn
variant="tonal"
size="small"
:to="{ name: 'events-id-persons', params: { id: eventId } }"
>
Beheren
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
<!-- -->
<!-- Flat event / Sub-event Overzicht (tiles) -->
<!-- -->
<VRow
v-else
class="mb-6"
>
<VCol
v-for="tile in tiles"
:key="tile.title"
cols="12"
md="6"
sm="6"
md="3"
>
<VCard>
<VCardText>
<div class="d-flex align-center gap-x-3">
<VAvatar
color="info"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-chart-bar"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
Capaciteitsoverzicht
</p>
<p class="text-body-2 text-disabled mb-0">
Volledige capaciteitsplanning zichtbaar zodra {{ subEventLabelPlural.toLowerCase() }} zijn aangemaakt
</p>
</div>
<VCard
:class="{ 'cursor-pointer': tile.enabled }"
:style="!tile.enabled ? 'opacity: 0.5' : ''"
@click="onTileClick(tile)"
>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
:color="tile.color"
variant="tonal"
size="44"
rounded
>
<VIcon
:icon="tile.icon"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
{{ tile.title }}
</p>
<h4 class="text-h4">
{{ tile.value }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<CreateSubEventDialog
v-if="event"
v-model="isCreateSubEventOpen"
:parent-event="event"
:org-id="orgId"
/>
</EventTabsNav>
</template>
<!-- Flat event / Sub-event view (existing behaviour) -->
<EventTabsNav v-else>
<VRow class="mb-6">
<VCol
v-for="tile in tiles"
:key="tile.title"
cols="12"
sm="6"
md="3"
>
<VCard
:class="{ 'cursor-pointer': tile.enabled }"
:style="!tile.enabled ? 'opacity: 0.5' : ''"
@click="onTileClick(tile)"
>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
:color="tile.color"
variant="tonal"
size="44"
rounded
>
<VIcon
:icon="tile.icon"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
{{ tile.title }}
</p>
<h4 class="text-h4">
{{ tile.value }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
</EventTabsNav>
</template>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import CreateSubEventDialog from '@/components/events/CreateSubEventDialog.vue'
import DeleteSubEventDialog from '@/components/events/DeleteSubEventDialog.vue'
import { useEventChildren } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { useAuthStore } from '@/stores/useAuthStore'
import type { EventStatus, EventItem } from '@/types/event'
definePage({
meta: {
navActiveLink: 'events',
},
})
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { data: children, isLoading: childrenLoading } = useEventChildren(orgId, eventId)
const isCreateSubEventOpen = ref(false)
const isDeleteSubEventOpen = ref(false)
const subEventToDelete = ref<EventItem | null>(null)
const showDeleteSuccess = ref(false)
const deletedSubEventName = ref('')
function openDeleteDialog(child: EventItem) {
subEventToDelete.value = child
isDeleteSubEventOpen.value = true
}
function onSubEventDeleted(name: string) {
deletedSubEventName.value = name
showDeleteSuccess.value = true
subEventToDelete.value = null
}
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
registration_open: 'cyan',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'error',
}
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
function navigateToChild(child: EventItem) {
router.push({ name: 'events-id', params: { id: child.id } })
}
</script>
<template>
<EventTabsNav>
<template #default="{ event }">
<!-- Sub-events subtitle bar -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="text-body-1 text-medium-emphasis">
{{ children?.length ?? event?.children_count ?? 0 }}
{{ event?.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}
</div>
<VBtn
prepend-icon="tabler-plus"
size="small"
@click="isCreateSubEventOpen = true"
>
{{ event?.sub_event_label ?? 'Programmaonderdeel' }} toevoegen
</VBtn>
</div>
<!-- Children loading -->
<VSkeletonLoader
v-if="childrenLoading"
type="card@3"
/>
<!-- Children grid -->
<VRow v-else-if="children?.length">
<VCol
v-for="child in children"
:key="child.id"
cols="12"
md="6"
>
<VCard
class="cursor-pointer"
hover
@click="navigateToChild(child)"
>
<VCardText>
<div class="d-flex justify-space-between align-start mb-1">
<h6 class="text-h6">
{{ child.name }}
</h6>
<div class="d-flex align-center gap-x-2">
<VChip
:color="statusColor[child.status]"
size="small"
>
{{ child.status }}
</VChip>
<VMenu>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
icon="tabler-dots-vertical"
variant="text"
size="x-small"
@click.stop
/>
</template>
<VList density="compact">
<VListItem
prepend-icon="tabler-trash"
title="Verwijderen"
class="text-error"
@click="openDeleteDialog(child)"
/>
</VList>
</VMenu>
</div>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ formatDate(child.start_date) }} {{ formatDate(child.end_date) }}
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Children empty -->
<VCard
v-else
class="text-center pa-6"
>
<p class="text-body-1 text-disabled mb-0">
Nog geen {{ event?.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}.
Voeg je eerste {{ (event?.sub_event_label ?? 'programmaonderdeel').toLowerCase() }} toe.
</p>
</VCard>
<CreateSubEventDialog
v-if="event"
v-model="isCreateSubEventOpen"
:parent-event="event"
:org-id="orgId"
/>
<DeleteSubEventDialog
v-model="isDeleteSubEventOpen"
:event="subEventToDelete"
:org-id="orgId"
:parent-event-id="eventId"
@deleted="onSubEventDeleted"
/>
<VSnackbar
v-model="showDeleteSuccess"
color="success"
:timeout="3000"
>
'{{ deletedSubEventName }}' is verwijderd
</VSnackbar>
</template>
</EventTabsNav>
</template>

View File

@@ -1,13 +1,8 @@
<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'
import SectionsShiftsPanel from '@/components/sections/SectionsShiftsPanel.vue'
import { useEventChildren } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
meta: {
@@ -21,567 +16,18 @@ 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('')
// Load children for festivals — needed for time slot context chips (Opbouw/Afbraak/Transitie)
const { data: children } = useEventChildren(orgId, eventId)
</script>
<template>
<EventTabsNav>
<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>
<template #default="{ event }">
<SectionsShiftsPanel
:event-id="eventId"
:is-sub-event="event?.is_sub_event ?? false"
:children="children ?? []"
/>
</template>
</EventTabsNav>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useEventList } from '@/composables/api/useEvents'
import { dutchPlural } from '@/lib/dutch-plural'
import { useAuthStore } from '@/stores/useAuthStore'
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
import type { EventStatus, EventItem } from '@/types/event'
@@ -174,7 +175,7 @@ function navigateToEvent(event: EventItem) {
icon="tabler-layout-grid"
size="16"
/>
{{ event.children_count }} {{ event.sub_event_label?.toLowerCase() ?? 'programmaonderdelen' }}
{{ event.children_count }} {{ event.sub_event_label ? dutchPlural(event.sub_event_label).toLowerCase() : 'programmaonderdelen' }}
</p>
</VCardText>
</VCard>

View File

@@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSectionsUiStore = defineStore('sectionsUi', () => {
const timeSlotsExpanded = ref(false)
function toggleTimeSlots() {
timeSlotsExpanded.value = !timeSlotsExpanded.value
}
function expandTimeSlots() {
timeSlotsExpanded.value = true
}
return {
timeSlotsExpanded,
toggleTimeSlots,
expandTimeSlots,
}
})

View File

@@ -6,6 +6,8 @@ export interface FestivalSection {
id: string
event_id: string
name: string
category: string | null
icon: string | null
type: SectionType
sort_order: number
crew_need: number | null
@@ -14,15 +16,22 @@ export interface FestivalSection {
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: 'CREW' | 'VOLUNTEER' | 'PRESS' | 'PHOTO' | 'PARTNER'
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 {
@@ -59,6 +68,8 @@ export interface ShiftAssignment {
export interface CreateSectionPayload {
name: string
category?: string | null
icon?: string | null
type?: SectionType
sort_order?: number
crew_auto_accepts?: boolean