Files
crewli/apps/app/src/components/sections/CreateShiftDialog.vue

555 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateShift, useUpdateShift } from '@/composables/api/useShifts'
import { useTimeSlotList } from '@/composables/api/useTimeSlots'
import { useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import { useTimeSlotDropdown } from '@/composables/useTimeSlotDropdown'
import { requiredValidator } from '@core/utils/validators'
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
const props = withDefaults(defineProps<{
eventId: string
sectionId: string
section?: FestivalSection | null
shift?: Shift | null
isSubEvent?: boolean
}>(), {
isSubEvent: false,
})
const modelValue = defineModel<boolean>({ required: true })
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const orgIdRef = computed(() => authStore.currentOrganisation?.id ?? '')
const eventIdRef = computed(() => props.eventId)
const sectionIdRef = computed(() => props.sectionId)
const isEditing = computed(() => !!props.shift)
// Get full event detail for hierarchy info
const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef)
const sectionRef = computed(() => props.section ?? null)
// Determine dropdown scenario
const { scenario, showInfoTooltip, hasGroups, tooltipText, fetchParams, sortedItems } = useTimeSlotDropdown(
eventDetail,
sectionRef,
)
// Fetch time slots based on scenario
const includeParentRef = computed(() => fetchParams.value.includeParent)
const includeChildrenRef = computed(() => fetchParams.value.includeChildren)
const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, {
includeParent: includeParentRef,
includeChildren: includeChildrenRef,
})
const { mutate: createShift, isPending: isCreating } = useCreateShift(orgIdRef, eventIdRef, sectionIdRef)
const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(orgIdRef, 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>()
/** Time-slot hierarchy uitleg (ingeklapt = minder visuele ruis). */
const timeSlotHelpOpen = ref(false)
watch(modelValue, (open) => {
if (open)
timeSlotHelpOpen.value = false
})
function toggleTimeSlotHelp() {
timeSlotHelpOpen.value = !timeSlotHelpOpen.value
}
// 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 },
)
// Build sorted dropdown items (no fake header items — groups are detected by boundary)
const dropdownItems = computed(() => {
// While loading, show the current shift's time slot so the dropdown doesn't flash a raw ULID
if (!timeSlots.value?.length) {
if (props.shift?.time_slot) {
const ts = props.shift.time_slot
return [{
id: ts.id,
name: ts.name,
timeRange: `${ts.start_time} ${ts.end_time}`,
displayLabel: ts.name,
_isDimmed: false,
groupName: '',
}]
}
return []
}
return sortedItems(timeSlots.value)
})
const hasTimeSlots = computed(() => dropdownItems.value.length > 0)
// Detect group boundaries: true when the current item starts a new group
function isNewGroup(index: number): boolean {
if (index === 0) return true
return dropdownItems.value[index]?.groupName !== dropdownItems.value[index - 1]?.groupName
}
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="640"
@after-leave="!isEditing && resetForm()"
>
<VCard
class="d-flex flex-column overflow-hidden"
max-height="min(90vh, 880px)"
rounded="lg"
>
<VCardItem class="flex-shrink-0 pb-3 pt-5 px-6">
<VCardTitle class="text-h6 ps-0">
{{ isEditing ? 'Shift bewerken' : 'Shift toevoegen' }}
</VCardTitle>
<VCardSubtitle
v-if="section"
class="text-body-2 mt-2 ps-0 d-flex align-center flex-wrap gap-2"
>
<span class="text-high-emphasis">{{ section.name }}</span>
<VChip
v-if="section.type === 'cross_event'"
size="small"
color="info"
variant="tonal"
>
festival-breed
</VChip>
<span
v-else-if="isSubEvent && eventDetail?.name"
class="text-medium-emphasis"
>· {{ eventDetail.name }}</span>
</VCardSubtitle>
</VCardItem>
<VDivider class="flex-shrink-0" />
<VForm
ref="refVForm"
class="d-flex flex-column flex-grow-1"
style="min-height: 0"
@submit.prevent="onSubmit"
>
<VCardText
class="flex-grow-1 overflow-y-auto px-6 py-5"
style="min-height: 0"
>
<VAlert
v-if="showInfoTooltip && tooltipText"
variant="tonal"
type="info"
border="start"
border-color="info"
density="comfortable"
:icon="false"
class="mb-6 pa-0 overflow-hidden"
>
<div
role="button"
tabindex="0"
class="cursor-pointer pa-4 text-start"
:aria-expanded="timeSlotHelpOpen"
aria-controls="shift-time-slot-help"
@click="toggleTimeSlotHelp"
@keydown.enter.prevent="toggleTimeSlotHelp"
@keydown.space.prevent="toggleTimeSlotHelp"
>
<div class="d-flex align-start gap-3">
<VIcon
icon="tabler-info-circle"
color="info"
size="22"
class="flex-shrink-0 align-self-start"
/>
<div class="flex-grow-1 min-width-0">
<div class="d-flex align-center flex-nowrap gap-2">
<span class="text-body-1 font-weight-medium text-high-emphasis flex-grow-1 min-width-0">
Tijdsloten in dit evenement
</span>
<VIcon
:icon="timeSlotHelpOpen ? 'tabler-chevron-up' : 'tabler-chevron-down'"
size="22"
class="text-medium-emphasis flex-shrink-0"
/>
</div>
<VExpandTransition>
<div
v-show="timeSlotHelpOpen"
id="shift-time-slot-help"
class="text-body-2 mt-3 text-medium-emphasis"
>
<p class="mb-2 text-high-emphasis">
{{ tooltipText.main }}
</p>
<p class="text-caption mb-0">
<span class="font-weight-semibold text-high-emphasis">Tip:</span>
{{ tooltipText.tip }}
</p>
</div>
</VExpandTransition>
</div>
</div>
</div>
</VAlert>
<p class="text-caption font-weight-bold text-uppercase text-medium-emphasis tracking-normal mb-3">
Tijd & rol
</p>
<VRow>
<VCol cols="12">
<VAutocomplete
v-if="timeSlotsLoading || hasTimeSlots"
v-model="form.time_slot_id"
:items="dropdownItems"
item-title="displayLabel"
item-value="id"
label="Tijdslot"
:loading="timeSlotsLoading"
:rules="[requiredValidator]"
:error-messages="errors.time_slot_id"
hide-details="auto"
>
<template #item="{ props: itemProps, item, index }">
<!-- Group header: rendered when event_name changes -->
<VListSubheader
v-if="hasGroups && isNewGroup(index)"
:title="item.raw.groupName"
/>
<VListItem
v-bind="itemProps"
:style="item.raw._isDimmed ? 'opacity: 0.55' : ''"
>
<template #title>
{{ item.raw.name }}
</template>
<template #append>
<span class="text-caption text-medium-emphasis">
{{ item.raw.timeRange }}
</span>
</template>
</VListItem>
</template>
<template #selection="{ item }">
{{ item.raw.name }} · {{ item.raw.timeRange }}
</template>
</VAutocomplete>
<VAlert
v-else
type="info"
variant="tonal"
border="start"
border-color="info"
density="comfortable"
>
<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>
</VRow>
<VDivider class="my-4" />
<p class="text-caption font-weight-bold text-uppercase text-medium-emphasis tracking-normal mb-2">
Tijden (optioneel)
</p>
<p class="text-caption text-medium-emphasis mb-4">
Laat leeg om de start- en eindtijd van het gekozen tijdslot te gebruiken.
</p>
<VRow>
<VCol
cols="12"
sm="4"
>
<AppTextField
v-model="form.report_time"
label="Rapporttijd"
type="time"
hide-details="auto"
:error-messages="errors.report_time"
/>
</VCol>
<VCol
cols="12"
sm="4"
>
<AppTextField
v-model="form.actual_start_time"
label="Starttijd"
type="time"
hide-details="auto"
:error-messages="errors.actual_start_time"
/>
</VCol>
<VCol
cols="12"
sm="4"
>
<AppTextField
v-model="form.actual_end_time"
label="Eindtijd"
type="time"
hide-details="auto"
:error-messages="errors.actual_end_time"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<p class="text-caption font-weight-bold text-uppercase text-medium-emphasis tracking-normal mb-3">
Capaciteit
</p>
<VRow>
<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"
hint="Vrijwilligers kunnen zelf boeken tot dit maximum"
persistent-hint
:rules="[requiredValidator]"
:error-messages="errors.slots_open_for_claiming"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<p class="text-caption font-weight-bold text-uppercase text-medium-emphasis tracking-normal mb-3">
Opties
</p>
<VRow>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="form.is_lead_role"
color="primary"
density="comfortable"
label="Leidinggevende rol"
hide-details
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VSwitch
v-model="form.allow_overlap"
color="primary"
density="comfortable"
label="Overlap toegestaan"
hide-details
/>
</VCol>
</VRow>
<p class="text-caption text-medium-emphasis mb-0 mt-2">
Leiding: coördinatie of verantwoordelijke. Overlap: dezelfde persoon mag meerdere shifts in hetzelfde tijdvak.
</p>
<VDivider class="my-4" />
<p class="text-caption font-weight-bold text-uppercase text-medium-emphasis tracking-normal mb-3">
Documentatie & status
</p>
<VRow>
<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>
<VDivider class="flex-shrink-0" />
<VCardActions class="flex-shrink-0 px-6 py-4 bg-surface">
<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>