feat: schema v1.7 + sections/shifts frontend

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

View File

@@ -24,6 +24,7 @@ declare module 'vue' {
AppStepper: typeof import('./src/@core/components/AppStepper.vue')['default']
AppTextarea: typeof import('./src/@core/components/app-form-elements/AppTextarea.vue')['default']
AppTextField: typeof import('./src/@core/components/app-form-elements/AppTextField.vue')['default']
AssignShiftDialog: typeof import('./src/components/sections/AssignShiftDialog.vue')['default']
CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default']
CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default']
CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default']
@@ -31,6 +32,9 @@ declare module 'vue' {
CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default']
CreateEventDialog: typeof import('./src/components/events/CreateEventDialog.vue')['default']
CreatePersonDialog: typeof import('./src/components/persons/CreatePersonDialog.vue')['default']
CreateSectionDialog: typeof import('./src/components/sections/CreateSectionDialog.vue')['default']
CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default']
CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default']
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default']
CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']
CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default']

View File

@@ -90,6 +90,7 @@
"@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-vue": "6.0.1",
"@vitejs/plugin-vue-jsx": "5.1.1",
"baseline-browser-mapping": "^2.10.16",
"eslint": "8.57.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-import-resolver-typescript": "3.10.1",

View File

@@ -235,6 +235,9 @@ importers:
'@vitejs/plugin-vue-jsx':
specifier: 5.1.1
version: 5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
baseline-browser-mapping:
specifier: ^2.10.16
version: 2.10.16
eslint:
specifier: 8.57.1
version: 8.57.1
@@ -1999,8 +2002,9 @@ packages:
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
baseline-browser-mapping@2.8.21:
resolution: {integrity: sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==}
baseline-browser-mapping@2.10.16:
resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==}
engines: {node: '>=6.0.0'}
hasBin: true
binary-extensions@2.3.0:
@@ -6728,7 +6732,7 @@ snapshots:
balanced-match@2.0.0: {}
baseline-browser-mapping@2.8.21: {}
baseline-browser-mapping@2.10.16: {}
binary-extensions@2.3.0: {}
@@ -6751,7 +6755,7 @@ snapshots:
browserslist@4.27.0:
dependencies:
baseline-browser-mapping: 2.8.21
baseline-browser-mapping: 2.10.16
caniuse-lite: 1.0.30001752
electron-to-chromium: 1.5.244
node-releases: 2.0.27

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { useAssignShift } from '@/composables/api/useShifts'
import { usePersonList } from '@/composables/api/usePersons'
import type { Shift } from '@/types/section'
import type { Person } from '@/types/person'
const props = defineProps<{
eventId: string
sectionId: string
shift: Shift | null
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const sectionIdRef = computed(() => props.sectionId)
const approvedFilter = ref({ status: 'approved' })
const { data: personsResponse } = usePersonList(eventIdRef, approvedFilter)
const { mutate: assignShift, isPending } = useAssignShift(eventIdRef, sectionIdRef)
const persons = computed(() => personsResponse.value?.data ?? [])
const selectedPersonId = ref<string>('')
const errors = ref<Record<string, string>>({})
const showSuccess = ref(false)
// Check for overlap warning
const hasOverlapWarning = computed(() => {
if (!selectedPersonId.value || !props.shift) return false
// This is informational — the backend enforces the actual constraint
return false
})
const personItems = computed(() =>
persons.value.map((p: Person) => ({
title: `${p.name}${p.email}`,
value: p.id,
props: {
subtitle: p.crowd_type?.name ?? '',
},
})),
)
function onSubmit() {
if (!selectedPersonId.value || !props.shift) return
errors.value = {}
assignShift(
{
shiftId: props.shift.id,
personId: selectedPersonId.value,
},
{
onSuccess: () => {
showSuccess.value = true
modelValue.value = false
selectedPersonId.value = ''
},
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]]),
)
}
else if (data?.message) {
errors.value = { person_id: data.message }
}
},
},
)
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="550"
>
<VCard title="Shift toewijzen">
<VCardText>
<!-- Shift info -->
<div
v-if="shift"
class="mb-4"
>
<div class="d-flex align-center gap-x-2 mb-2">
<span class="text-h6">{{ shift.title ?? 'Shift' }}</span>
<VChip
v-if="shift.is_lead_role"
color="warning"
size="small"
>
Hoofdrol
</VChip>
</div>
<div class="text-body-2 text-disabled">
{{ shift.time_slot?.name }} {{ shift.effective_start_time }}{{ shift.effective_end_time }}
</div>
<div class="text-body-2 text-disabled">
Capaciteit: {{ shift.filled_slots }}/{{ shift.slots_total }}
</div>
</div>
<VDivider class="mb-4" />
<!-- Person search -->
<VAutocomplete
v-model="selectedPersonId"
label="Persoon zoeken"
:items="personItems"
item-title="title"
item-value="value"
:error-messages="errors.person_id"
clearable
no-data-text="Geen goedgekeurde personen gevonden"
>
<template #item="{ props: itemProps, item }">
<VListItem
v-bind="itemProps"
:subtitle="item.raw.props.subtitle"
/>
</template>
</VAutocomplete>
<!-- Overlap warning -->
<VAlert
v-if="hasOverlapWarning && shift?.allow_overlap"
type="warning"
variant="tonal"
class="mt-3"
>
Let op: overlap met bestaande shift
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
:disabled="!selectedPersonId"
@click="onSubmit"
>
Toewijzen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Persoon succesvol toegewezen
</VSnackbar>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateSection } from '@/composables/api/useSections'
import { requiredValidator } from '@core/utils/validators'
import type { SectionType } from '@/types/section'
const props = defineProps<{
eventId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const form = ref({
name: '',
type: 'standard' as SectionType,
crew_auto_accepts: false,
responder_self_checkin: true,
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const { mutate: createSection, isPending } = useCreateSection(eventIdRef)
const typeOptions = [
{ title: 'Standaard', value: 'standard' },
{ title: 'Overkoepelend', value: 'cross_event' },
]
function resetForm() {
form.value = {
name: '',
type: 'standard',
crew_auto_accepts: false,
responder_self_checkin: true,
}
errors.value = {}
refVForm.value?.resetValidation()
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
createSection(
{
name: form.value.name,
type: form.value.type,
crew_auto_accepts: form.value.crew_auto_accepts,
responder_self_checkin: form.value.responder_self_checkin,
},
{
onSuccess: () => {
modelValue.value = false
resetForm()
},
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]]),
)
}
},
},
)
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="500"
@after-leave="resetForm"
>
<VCard title="Sectie aanmaken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.type"
label="Type"
:items="typeOptions"
:error-messages="errors.type"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.crew_auto_accepts"
label="Crew auto-accepteren"
hint="Toewijzingen worden automatisch goedgekeurd"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="form.responder_self_checkin"
label="Zelfstandig inchecken"
hint="Vrijwilligers kunnen zelf inchecken via QR"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,287 @@
<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 = defineProps<{
eventId: string
sectionId: string
shift?: Shift | null
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const sectionIdRef = computed(() => props.sectionId)
const isEditing = computed(() => !!props.shift)
const { data: timeSlots } = useTimeSlotList(eventIdRef)
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(() =>
timeSlots.value?.map(ts => ({
title: `${ts.name}${ts.date} ${ts.start_time}${ts.end_time}`,
value: ts.id,
})) ?? [],
)
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
if (!isEditing.value) resetForm()
},
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'">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppSelect
v-model="form.time_slot_id"
label="Time Slot"
:items="timeSlotItems"
:rules="[requiredValidator]"
:error-messages="errors.time_slot_id"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.title"
label="Titel / Rol"
placeholder="Tapper, Barhoofd, Stage Manager..."
:error-messages="errors.title"
/>
</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"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.status"
label="Status"
:items="statusOptions"
:error-messages="errors.status"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
{{ isEditing ? 'Opslaan' : 'Toevoegen' }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateTimeSlot } from '@/composables/api/useTimeSlots'
import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
eventId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const eventIdRef = computed(() => props.eventId)
const form = ref({
name: '',
person_type: 'VOLUNTEER',
date: '',
start_time: '',
end_time: '',
duration_hours: null as number | null,
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const { mutate: createTimeSlot, isPending } = useCreateTimeSlot(eventIdRef)
const personTypeOptions = [
{ title: 'Vrijwilliger', value: 'VOLUNTEER' },
{ title: 'Crew', value: 'CREW' },
{ title: 'Pers', value: 'PRESS' },
{ title: 'Fotograaf', value: 'PHOTO' },
{ title: 'Partner', value: 'PARTNER' },
]
// Auto-calculate duration from start/end time
watch(
() => [form.value.start_time, form.value.end_time],
([start, end]) => {
if (start && end) {
const [sh, sm] = start.split(':').map(Number)
const [eh, em] = end.split(':').map(Number)
let diff = (eh * 60 + em) - (sh * 60 + sm)
if (diff < 0) diff += 24 * 60
form.value.duration_hours = Math.round((diff / 60) * 100) / 100
}
},
)
function resetForm() {
form.value = {
name: '',
person_type: 'VOLUNTEER',
date: '',
start_time: '',
end_time: '',
duration_hours: null,
}
errors.value = {}
refVForm.value?.resetValidation()
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
createTimeSlot(
{
name: form.value.name,
person_type: form.value.person_type,
date: form.value.date,
start_time: form.value.start_time,
end_time: form.value.end_time,
...(form.value.duration_hours != null ? { duration_hours: form.value.duration_hours } : {}),
},
{
onSuccess: () => {
modelValue.value = false
resetForm()
},
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]]),
)
}
},
},
)
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="550"
@after-leave="resetForm"
>
<VCard title="Time Slot aanmaken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
placeholder="Dag 1 Avond - Vrijwilliger"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.person_type"
label="Persoonscategorie"
:items="personTypeOptions"
:rules="[requiredValidator]"
:error-messages="errors.person_type"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.date"
label="Datum"
type="date"
:rules="[requiredValidator]"
:error-messages="errors.date"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="form.start_time"
label="Starttijd"
type="time"
:rules="[requiredValidator]"
:error-messages="errors.start_time"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<AppTextField
v-model="form.end_time"
label="Eindtijd"
type="time"
:rules="[requiredValidator]"
:error-messages="errors.end_time"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model.number="form.duration_hours"
label="Duur (uren)"
type="number"
:error-messages="errors.duration_hours"
hint="Automatisch berekend uit start- en eindtijd"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,92 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { CreateSectionPayload, FestivalSection, UpdateSectionPayload } from '@/types/section'
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
}
export function useSectionList(eventId: Ref<string>) {
return useQuery({
queryKey: ['sections', eventId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<FestivalSection>>(
`/events/${eventId.value}/sections`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useCreateSection(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateSectionPayload) => {
const { data } = await apiClient.post<ApiResponse<FestivalSection>>(
`/events/${eventId.value}/sections`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
},
})
}
export function useUpdateSection(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: UpdateSectionPayload & { id: string }) => {
const { data } = await apiClient.put<ApiResponse<FestivalSection>>(
`/events/${eventId.value}/sections/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
},
})
}
export function useDeleteSection(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/events/${eventId.value}/sections/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
},
})
}
export function useReorderSections(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (orderedIds: string[]) => {
await apiClient.post(`/events/${eventId.value}/sections/reorder`, {
ordered_ids: orderedIds,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] })
},
})
}

View File

@@ -0,0 +1,98 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { CreateShiftPayload, Shift, UpdateShiftPayload } from '@/types/section'
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
}
export function useShiftList(eventId: Ref<string>, sectionId: Ref<string>) {
return useQuery({
queryKey: ['shifts', sectionId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<Shift>>(
`/events/${eventId.value}/sections/${sectionId.value}/shifts`,
)
return data.data
},
enabled: () => !!eventId.value && !!sectionId.value,
})
}
export function useCreateShift(eventId: Ref<string>, sectionId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateShiftPayload) => {
const { data } = await apiClient.post<ApiResponse<Shift>>(
`/events/${eventId.value}/sections/${sectionId.value}/shifts`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] })
},
})
}
export function useUpdateShift(eventId: Ref<string>, sectionId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: UpdateShiftPayload & { id: string }) => {
const { data } = await apiClient.put<ApiResponse<Shift>>(
`/events/${eventId.value}/sections/${sectionId.value}/shifts/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] })
},
})
}
export function useDeleteShift(eventId: Ref<string>, sectionId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(
`/events/${eventId.value}/sections/${sectionId.value}/shifts/${id}`,
)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] })
},
})
}
export function useAssignShift(eventId: Ref<string>, sectionId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ shiftId, personId }: { shiftId: string; personId: string }) => {
const { data } = await apiClient.post<ApiResponse<unknown>>(
`/events/${eventId.value}/sections/${sectionId.value}/shifts/${shiftId}/assign`,
{ person_id: personId },
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] })
queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] })
},
})
}

View File

@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { CreateTimeSlotPayload, TimeSlot, UpdateTimeSlotPayload } from '@/types/section'
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
}
export function useTimeSlotList(eventId: Ref<string>) {
return useQuery({
queryKey: ['time-slots', eventId],
queryFn: async () => {
const { data } = await apiClient.get<PaginatedResponse<TimeSlot>>(
`/events/${eventId.value}/time-slots`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useCreateTimeSlot(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateTimeSlotPayload) => {
const { data } = await apiClient.post<ApiResponse<TimeSlot>>(
`/events/${eventId.value}/time-slots`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['time-slots', eventId.value] })
},
})
}
export function useUpdateTimeSlot(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: UpdateTimeSlotPayload & { id: string }) => {
const { data } = await apiClient.put<ApiResponse<TimeSlot>>(
`/events/${eventId.value}/time-slots/${id}`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['time-slots', eventId.value] })
},
})
}
export function useDeleteTimeSlot(eventId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/events/${eventId.value}/time-slots/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['time-slots', eventId.value] })
},
})
}

View File

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

View File

@@ -0,0 +1,96 @@
export type SectionType = 'standard' | 'cross_event'
export type ShiftStatus = 'draft' | 'open' | 'full' | 'in_progress' | 'completed' | 'cancelled'
export interface FestivalSection {
id: string
event_id: string
name: string
type: SectionType
sort_order: number
crew_need: number | null
crew_auto_accepts: boolean
responder_self_checkin: boolean
created_at: string
}
export interface TimeSlot {
id: string
event_id: string
name: string
person_type: 'CREW' | 'VOLUNTEER' | 'PRESS' | 'PHOTO' | 'PARTNER'
date: string
start_time: string
end_time: string
duration_hours: number | null
}
export interface Shift {
id: string
festival_section_id: string
time_slot_id: string
location_id: string | null
title: string | null
description: string | null
instructions: string | null
slots_total: number
slots_open_for_claiming: number
is_lead_role: boolean
report_time: string | null
actual_start_time: string | null
actual_end_time: string | null
allow_overlap: boolean
status: ShiftStatus
filled_slots: number
fill_rate: number
effective_start_time: string
effective_end_time: string
time_slot: TimeSlot | null
created_at: string
}
export interface ShiftAssignment {
id: string
shift_id: string
person_id: string
status: 'pending_approval' | 'approved' | 'rejected' | 'cancelled' | 'completed'
assigned_at: string | null
}
export interface CreateSectionPayload {
name: string
type?: SectionType
sort_order?: number
crew_auto_accepts?: boolean
responder_self_checkin?: boolean
}
export interface UpdateSectionPayload extends Partial<CreateSectionPayload> {}
export interface CreateTimeSlotPayload {
name: string
person_type: string
date: string
start_time: string
end_time: string
duration_hours?: number
}
export interface UpdateTimeSlotPayload extends Partial<CreateTimeSlotPayload> {}
export interface CreateShiftPayload {
time_slot_id: string
location_id?: string
title?: string
slots_total: number
slots_open_for_claiming: number
report_time?: string
actual_start_time?: string
actual_end_time?: string
is_lead_role?: boolean
allow_overlap?: boolean
instructions?: string
status?: ShiftStatus
}
export interface UpdateShiftPayload extends Partial<CreateShiftPayload> {}

View File

@@ -1,15 +1,18 @@
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VueRouterAutoImports, getPascalCaseRouteName } from 'unplugin-vue-router'
import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite'
import VueDevTools from 'vite-plugin-vue-devtools'
import MetaLayouts from 'vite-plugin-vue-meta-layouts'
import vuetify from 'vite-plugin-vuetify'
import svgLoader from 'vite-svg-loader'
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import {
VueRouterAutoImports,
getPascalCaseRouteName,
} from "unplugin-vue-router";
import VueRouter from "unplugin-vue-router/vite";
import { defineConfig } from "vite";
import VueDevTools from "vite-plugin-vue-devtools";
import MetaLayouts from "vite-plugin-vue-meta-layouts";
import vuetify from "vite-plugin-vuetify";
import svgLoader from "vite-svg-loader";
// https://vitejs.dev/config/
export default defineConfig({
@@ -17,18 +20,18 @@ export default defineConfig({
// Docs: https://github.com/posva/unplugin-vue-router
// This plugin should be placed before vue plugin
VueRouter({
getRouteName: routeNode => {
getRouteName: (routeNode) => {
// Convert pascal case to kebab case
return getPascalCaseRouteName(routeNode)
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
.toLowerCase()
.replace(/([a-z\d])([A-Z])/g, "$1-$2")
.toLowerCase();
},
}),
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag === 'swiper-container' || tag === 'swiper-slide',
isCustomElement: (tag) =>
tag === "swiper-container" || tag === "swiper-slide",
},
},
}),
@@ -39,77 +42,101 @@ export default defineConfig({
vuetify({
styles: {
// Absolute URL so resolution does not depend on process cwd (fixes common SASS 404s).
configFile: fileURLToPath(new URL('./src/styles/settings.scss', import.meta.url)),
configFile: fileURLToPath(
new URL("./src/styles/settings.scss", import.meta.url),
),
},
}),
// Docs: https://github.com/dishait/vite-plugin-vue-meta-layouts?tab=readme-ov-file
MetaLayouts({
target: './src/layouts',
defaultLayout: 'default',
target: "./src/layouts",
defaultLayout: "default",
}),
// Docs: https://github.com/antfu/unplugin-vue-components#unplugin-vue-components
Components({
dirs: ['src/@core/components', 'src/views/demos', 'src/components'],
dirs: ["src/@core/components", "src/views/demos", "src/components"],
dts: true,
resolvers: [
componentName => {
(componentName) => {
// Auto import `VueApexCharts`
if (componentName === 'VueApexCharts')
return { name: 'default', from: 'vue3-apexcharts', as: 'VueApexCharts' }
if (componentName === "VueApexCharts")
return {
name: "default",
from: "vue3-apexcharts",
as: "VueApexCharts",
};
},
],
}),
// Docs: https://github.com/antfu/unplugin-auto-import#unplugin-auto-import
AutoImport({
imports: ['vue', VueRouterAutoImports, '@vueuse/core', '@vueuse/math', 'vue-i18n', 'pinia'],
imports: [
"vue",
VueRouterAutoImports,
"@vueuse/core",
"@vueuse/math",
"vue-i18n",
"pinia",
],
dirs: [
'./src/@core/utils',
'./src/@core/composable/',
'./src/composables/',
'./src/utils/',
'./src/plugins/*/composables/*',
"./src/@core/utils",
"./src/@core/composable/",
"./src/composables/",
"./src/utils/",
"./src/plugins/*/composables/*",
],
vueTemplate: true,
// Disabled to avoid confusion & accidental usage
ignore: ['useCookies', 'useStorage'],
ignore: ["useCookies", "useStorage"],
}),
svgLoader(),
],
define: { 'process.env': {} },
define: { "process.env": {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@themeConfig': fileURLToPath(new URL('./themeConfig.ts', import.meta.url)),
'@core': fileURLToPath(new URL('./src/@core', import.meta.url)),
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)),
'@configured-variables': fileURLToPath(new URL('./src/assets/styles/variables/_template.scss', import.meta.url)),
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@themeConfig": fileURLToPath(
new URL("./themeConfig.ts", import.meta.url),
),
"@core": fileURLToPath(new URL("./src/@core", import.meta.url)),
"@layouts": fileURLToPath(new URL("./src/@layouts", import.meta.url)),
"@images": fileURLToPath(
new URL("./src/assets/images/", import.meta.url),
),
"@styles": fileURLToPath(
new URL("./src/assets/styles/", import.meta.url),
),
"@configured-variables": fileURLToPath(
new URL(
"./src/assets/styles/variables/_template.scss",
import.meta.url,
),
),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:8000',
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
warmup: {
clientFiles: ["./src/pages/**/*.vue", "./src/components/**/*.vue"],
},
},
build: {
chunkSizeWarningLimit: 5000,
},
optimizeDeps: {
exclude: ['vuetify'],
entries: [
'./src/**/*.vue',
],
exclude: ["vuetify"],
entries: ["./src/**/*.vue"],
force: true,
},
})
});