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:
@@ -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>
|
||||
|
||||
180
apps/app/src/pages/events/[id]/programmaonderdelen/index.vue
Normal file
180
apps/app/src/pages/events/[id]/programmaonderdelen/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user