Extract time slots from Secties & Shifts into a dedicated Tijdsloten tab. New tab groups time slots by date with Dutch date headers, person type filter pills, fill rate progress bars, and sections count. Includes duplicate, edit, and delete actions with confirmation dialog. - Create types/timeSlot.ts with enriched TimeSlot interface - Add Tijdsloten tab to EventTabsNav between Publiekslijsten and Secties - Create time-slots page with loading, error, and empty states - Remove time slots panel from SectionsShiftsPanel - Update CreateShiftDialog to navigate to time slots tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
342 lines
10 KiB
Vue
342 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { VForm } from 'vuetify/components/VForm'
|
||
import { useCreateShift, useUpdateShift } from '@/composables/api/useShifts'
|
||
import { useTimeSlotList } from '@/composables/api/useTimeSlots'
|
||
import { requiredValidator } from '@core/utils/validators'
|
||
import type { Shift, ShiftStatus } from '@/types/section'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
eventId: string
|
||
sectionId: string
|
||
shift?: Shift | null
|
||
isSubEvent?: boolean
|
||
}>(), {
|
||
isSubEvent: false,
|
||
})
|
||
|
||
const modelValue = defineModel<boolean>({ required: true })
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const eventIdRef = computed(() => props.eventId)
|
||
const sectionIdRef = computed(() => props.sectionId)
|
||
|
||
const isEditing = computed(() => !!props.shift)
|
||
const isSubEventRef = computed(() => props.isSubEvent)
|
||
|
||
const { data: timeSlots } = useTimeSlotList(eventIdRef, { includeParent: isSubEventRef })
|
||
const { mutate: createShift, isPending: isCreating } = useCreateShift(eventIdRef, sectionIdRef)
|
||
const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(eventIdRef, sectionIdRef)
|
||
|
||
const isPending = computed(() => isCreating.value || isUpdating.value)
|
||
|
||
const form = ref({
|
||
time_slot_id: '',
|
||
title: '',
|
||
report_time: '',
|
||
actual_start_time: '',
|
||
actual_end_time: '',
|
||
slots_total: 1,
|
||
slots_open_for_claiming: 0,
|
||
is_lead_role: false,
|
||
allow_overlap: false,
|
||
instructions: '',
|
||
status: 'draft' as ShiftStatus,
|
||
})
|
||
|
||
const errors = ref<Record<string, string>>({})
|
||
const refVForm = ref<VForm>()
|
||
|
||
// Populate form when editing
|
||
watch(
|
||
() => props.shift,
|
||
(shift) => {
|
||
if (shift) {
|
||
form.value = {
|
||
time_slot_id: shift.time_slot_id,
|
||
title: shift.title ?? '',
|
||
report_time: shift.report_time ?? '',
|
||
actual_start_time: shift.actual_start_time ?? '',
|
||
actual_end_time: shift.actual_end_time ?? '',
|
||
slots_total: shift.slots_total,
|
||
slots_open_for_claiming: shift.slots_open_for_claiming,
|
||
is_lead_role: shift.is_lead_role,
|
||
allow_overlap: shift.allow_overlap,
|
||
instructions: shift.instructions ?? '',
|
||
status: shift.status,
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const timeSlotItems = computed(() => {
|
||
if (!timeSlots.value?.length) return []
|
||
|
||
const hasFestival = timeSlots.value.some(ts => ts.source === 'festival')
|
||
if (!hasFestival) {
|
||
return timeSlots.value.map(ts => ({
|
||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||
value: ts.id,
|
||
}))
|
||
}
|
||
|
||
const subEventSlots = timeSlots.value.filter(ts => ts.source !== 'festival')
|
||
const festivalSlots = timeSlots.value.filter(ts => ts.source === 'festival')
|
||
const items: Array<{ title: string; value?: string; type?: string }> = []
|
||
|
||
if (subEventSlots.length) {
|
||
items.push({ title: subEventSlots[0]?.event_name ?? 'Programma', type: 'subheader' })
|
||
items.push(...subEventSlots.map(ts => ({
|
||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||
value: ts.id,
|
||
})))
|
||
}
|
||
|
||
if (festivalSlots.length) {
|
||
items.push({ title: festivalSlots[0]?.event_name ?? 'Festival', type: 'subheader' })
|
||
items.push(...festivalSlots.map(ts => ({
|
||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||
value: ts.id,
|
||
})))
|
||
}
|
||
|
||
return items
|
||
})
|
||
|
||
const statusOptions = [
|
||
{ title: 'Concept', value: 'draft' },
|
||
{ title: 'Open', value: 'open' },
|
||
]
|
||
|
||
function resetForm() {
|
||
form.value = {
|
||
time_slot_id: '',
|
||
title: '',
|
||
report_time: '',
|
||
actual_start_time: '',
|
||
actual_end_time: '',
|
||
slots_total: 1,
|
||
slots_open_for_claiming: 0,
|
||
is_lead_role: false,
|
||
allow_overlap: false,
|
||
instructions: '',
|
||
status: 'draft',
|
||
}
|
||
errors.value = {}
|
||
refVForm.value?.resetValidation()
|
||
}
|
||
|
||
function buildPayload() {
|
||
return {
|
||
time_slot_id: form.value.time_slot_id,
|
||
slots_total: form.value.slots_total,
|
||
slots_open_for_claiming: form.value.slots_open_for_claiming,
|
||
is_lead_role: form.value.is_lead_role,
|
||
allow_overlap: form.value.allow_overlap,
|
||
status: form.value.status,
|
||
...(form.value.title ? { title: form.value.title } : {}),
|
||
...(form.value.report_time ? { report_time: form.value.report_time } : {}),
|
||
...(form.value.actual_start_time ? { actual_start_time: form.value.actual_start_time } : {}),
|
||
...(form.value.actual_end_time ? { actual_end_time: form.value.actual_end_time } : {}),
|
||
...(form.value.instructions ? { instructions: form.value.instructions } : {}),
|
||
}
|
||
}
|
||
|
||
function onSubmit() {
|
||
refVForm.value?.validate().then(({ valid }) => {
|
||
if (!valid) return
|
||
|
||
errors.value = {}
|
||
|
||
const callbacks = {
|
||
onSuccess: () => {
|
||
modelValue.value = false
|
||
},
|
||
onError: (err: any) => {
|
||
const data = err.response?.data
|
||
if (data?.errors) {
|
||
errors.value = Object.fromEntries(
|
||
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
|
||
)
|
||
}
|
||
},
|
||
}
|
||
|
||
if (isEditing.value && props.shift) {
|
||
updateShift({ id: props.shift.id, ...buildPayload() }, callbacks)
|
||
}
|
||
else {
|
||
createShift(buildPayload(), callbacks)
|
||
}
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<VDialog
|
||
v-model="modelValue"
|
||
max-width="600"
|
||
@after-leave="!isEditing && resetForm()"
|
||
>
|
||
<VCard :title="isEditing ? 'Shift bewerken' : 'Shift toevoegen'">
|
||
<VForm
|
||
ref="refVForm"
|
||
@submit.prevent="onSubmit"
|
||
>
|
||
<VCardText>
|
||
<VRow>
|
||
<VCol cols="12">
|
||
<AppSelect
|
||
v-if="timeSlotItems.length"
|
||
v-model="form.time_slot_id"
|
||
label="Time Slot"
|
||
:items="timeSlotItems"
|
||
:rules="[requiredValidator]"
|
||
:error-messages="errors.time_slot_id"
|
||
/>
|
||
<VAlert
|
||
v-else
|
||
type="info"
|
||
variant="tonal"
|
||
>
|
||
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||
<span>Maak eerst een time slot aan</span>
|
||
<VBtn
|
||
size="small"
|
||
variant="text"
|
||
color="primary"
|
||
prepend-icon="tabler-clock"
|
||
@click="modelValue = false; router.push({ name: 'events-id-time-slots', params: { id: (route.params as { id: string }).id } })"
|
||
>
|
||
Time Slots beheren
|
||
</VBtn>
|
||
</div>
|
||
</VAlert>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<AppTextField
|
||
v-model="form.title"
|
||
label="Titel / Rol"
|
||
placeholder="Tapper, Barhoofd, Stage Manager..."
|
||
:error-messages="errors.title"
|
||
autocomplete="one-time-code"
|
||
/>
|
||
</VCol>
|
||
<VCol
|
||
cols="12"
|
||
sm="4"
|
||
>
|
||
<AppTextField
|
||
v-model="form.report_time"
|
||
label="Aanwezig om (rapport tijd)"
|
||
type="time"
|
||
:error-messages="errors.report_time"
|
||
/>
|
||
</VCol>
|
||
<VCol
|
||
cols="12"
|
||
sm="4"
|
||
>
|
||
<AppTextField
|
||
v-model="form.actual_start_time"
|
||
label="Afwijkende starttijd"
|
||
type="time"
|
||
hint="Leeg = time slot tijd"
|
||
persistent-hint
|
||
:error-messages="errors.actual_start_time"
|
||
/>
|
||
</VCol>
|
||
<VCol
|
||
cols="12"
|
||
sm="4"
|
||
>
|
||
<AppTextField
|
||
v-model="form.actual_end_time"
|
||
label="Afwijkende eindtijd"
|
||
type="time"
|
||
:error-messages="errors.actual_end_time"
|
||
/>
|
||
</VCol>
|
||
<VCol
|
||
cols="12"
|
||
sm="6"
|
||
>
|
||
<AppTextField
|
||
v-model.number="form.slots_total"
|
||
label="Totaal slots"
|
||
type="number"
|
||
min="1"
|
||
:rules="[requiredValidator]"
|
||
:error-messages="errors.slots_total"
|
||
/>
|
||
</VCol>
|
||
<VCol
|
||
cols="12"
|
||
sm="6"
|
||
>
|
||
<AppTextField
|
||
v-model.number="form.slots_open_for_claiming"
|
||
label="Open voor claimen"
|
||
type="number"
|
||
min="0"
|
||
:max="form.slots_total"
|
||
:rules="[requiredValidator]"
|
||
:error-messages="errors.slots_open_for_claiming"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<VSwitch
|
||
v-model="form.is_lead_role"
|
||
label="Dit is een leidinggevende rol"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<VSwitch
|
||
v-model="form.allow_overlap"
|
||
label="Overlap toegestaan"
|
||
hint="Persoon mag meerdere shifts in hetzelfde tijdvak hebben"
|
||
persistent-hint
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<AppTextarea
|
||
v-model="form.instructions"
|
||
label="Instructies"
|
||
rows="3"
|
||
:error-messages="errors.instructions"
|
||
autocomplete="one-time-code"
|
||
/>
|
||
</VCol>
|
||
<VCol cols="12">
|
||
<AppSelect
|
||
v-model="form.status"
|
||
label="Status"
|
||
:items="statusOptions"
|
||
:error-messages="errors.status"
|
||
/>
|
||
</VCol>
|
||
</VRow>
|
||
</VCardText>
|
||
<VCardActions>
|
||
<VSpacer />
|
||
<VBtn
|
||
variant="text"
|
||
@click="modelValue = false"
|
||
>
|
||
Annuleren
|
||
</VBtn>
|
||
<VBtn
|
||
type="submit"
|
||
color="primary"
|
||
:loading="isPending"
|
||
>
|
||
{{ isEditing ? 'Opslaan' : 'Toevoegen' }}
|
||
</VBtn>
|
||
</VCardActions>
|
||
</VForm>
|
||
</VCard>
|
||
</VDialog>
|
||
</template>
|