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:
@@ -21,12 +21,19 @@ final class Event extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'organisation_id',
|
||||
'parent_event_id',
|
||||
'name',
|
||||
'slug',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'timezone',
|
||||
'status',
|
||||
'event_type',
|
||||
'event_type_label',
|
||||
'sub_event_label',
|
||||
'is_recurring',
|
||||
'recurrence_rule',
|
||||
'recurrence_exceptions',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -34,6 +41,9 @@ final class Event extends Model
|
||||
return [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_recurring' => 'boolean',
|
||||
'recurrence_exceptions' => 'array',
|
||||
'event_type' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +89,68 @@ final class Event extends Model
|
||||
return $this->hasMany(CrowdList::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class, 'parent_event_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class, 'parent_event_id')
|
||||
->orderBy('start_date')
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
// ----- Scopes -----
|
||||
|
||||
public function scopeTopLevel(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('parent_event_id');
|
||||
}
|
||||
|
||||
public function scopeChildren(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('parent_event_id');
|
||||
}
|
||||
|
||||
public function scopeFestivals(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('event_type', ['festival', 'series']);
|
||||
}
|
||||
|
||||
public function scopeWithChildren(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) {
|
||||
$q->whereIn('id', function ($sub) {
|
||||
$sub->select('id')->from('events')->whereNull('parent_event_id');
|
||||
})->orWhereIn('parent_event_id', function ($sub) {
|
||||
$sub->select('id')->from('events')->whereNull('parent_event_id');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
public function isFestival(): bool
|
||||
{
|
||||
return $this->event_type !== 'event' && $this->parent_event_id === null;
|
||||
}
|
||||
|
||||
public function isSubEvent(): bool
|
||||
{
|
||||
return $this->parent_event_id !== null;
|
||||
}
|
||||
|
||||
public function isFlatEvent(): bool
|
||||
{
|
||||
return $this->parent_event_id === null && $this->children()->count() === 0;
|
||||
}
|
||||
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->exists();
|
||||
}
|
||||
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'draft');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->foreignUlid('parent_event_id')
|
||||
->nullable()
|
||||
->after('organisation_id')
|
||||
->constrained('events')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->enum('event_type', ['event', 'festival', 'series'])
|
||||
->default('event')
|
||||
->after('status');
|
||||
|
||||
$table->string('event_type_label')
|
||||
->nullable()
|
||||
->after('event_type');
|
||||
|
||||
$table->string('sub_event_label')
|
||||
->nullable()
|
||||
->after('event_type_label');
|
||||
|
||||
$table->boolean('is_recurring')
|
||||
->default(false)
|
||||
->after('sub_event_label');
|
||||
|
||||
$table->string('recurrence_rule')
|
||||
->nullable()
|
||||
->after('is_recurring');
|
||||
|
||||
$table->json('recurrence_exceptions')
|
||||
->nullable()
|
||||
->after('recurrence_rule');
|
||||
|
||||
$table->index('parent_event_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropForeign(['parent_event_id']);
|
||||
$table->dropIndex(['parent_event_id']);
|
||||
$table->dropColumn([
|
||||
'parent_event_id',
|
||||
'event_type',
|
||||
'event_type_label',
|
||||
'sub_event_label',
|
||||
'is_recurring',
|
||||
'recurrence_rule',
|
||||
'recurrence_exceptions',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_person_activations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignUlid('event_id')->constrained('events')->cascadeOnDelete();
|
||||
$table->foreignUlid('person_id')->constrained('persons')->cascadeOnDelete();
|
||||
|
||||
$table->unique(['event_id', 'person_id']);
|
||||
$table->index('person_id');
|
||||
$table->index('event_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_person_activations');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -99,5 +100,59 @@ class DevSeeder extends Seeder
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Flat event (backward compatible single event)
|
||||
Event::firstOrCreate(
|
||||
['organisation_id' => $org->id, 'slug' => 'test-event-01'],
|
||||
[
|
||||
'name' => 'Test Event 01',
|
||||
'start_date' => '2026-08-15',
|
||||
'end_date' => '2026-08-15',
|
||||
'status' => 'draft',
|
||||
'event_type' => 'event',
|
||||
'parent_event_id' => null,
|
||||
],
|
||||
);
|
||||
|
||||
// 6. Festival with sub-events
|
||||
$festival = Event::firstOrCreate(
|
||||
['organisation_id' => $org->id, 'slug' => 'echt-zomer-feesten-2026'],
|
||||
[
|
||||
'name' => 'Echt Zomer Feesten 2026',
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-11',
|
||||
'status' => 'draft',
|
||||
'event_type' => 'festival',
|
||||
'event_type_label' => 'Festival',
|
||||
'sub_event_label' => 'Programmaonderdeel',
|
||||
'parent_event_id' => null,
|
||||
],
|
||||
);
|
||||
|
||||
// Sub-event 1: Dance Festival
|
||||
Event::firstOrCreate(
|
||||
['organisation_id' => $org->id, 'slug' => 'dance-festival-2026'],
|
||||
[
|
||||
'name' => 'Dance Festival',
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-10',
|
||||
'status' => 'draft',
|
||||
'event_type' => 'event',
|
||||
'parent_event_id' => $festival->id,
|
||||
],
|
||||
);
|
||||
|
||||
// Sub-event 2: Zomerfestival
|
||||
Event::firstOrCreate(
|
||||
['organisation_id' => $org->id, 'slug' => 'zomerfestival-2026'],
|
||||
[
|
||||
'name' => 'Zomerfestival',
|
||||
'start_date' => '2026-07-11',
|
||||
'end_date' => '2026-07-11',
|
||||
'status' => 'draft',
|
||||
'event_type' => 'event',
|
||||
'parent_event_id' => $festival->id,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/app/components.d.ts
vendored
4
apps/app/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
apps/app/pnpm-lock.yaml
generated
12
apps/app/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
165
apps/app/src/components/sections/AssignShiftDialog.vue
Normal file
165
apps/app/src/components/sections/AssignShiftDialog.vue
Normal 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>
|
||||
142
apps/app/src/components/sections/CreateSectionDialog.vue
Normal file
142
apps/app/src/components/sections/CreateSectionDialog.vue
Normal 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>
|
||||
287
apps/app/src/components/sections/CreateShiftDialog.vue
Normal file
287
apps/app/src/components/sections/CreateShiftDialog.vue
Normal 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>
|
||||
193
apps/app/src/components/sections/CreateTimeSlotDialog.vue
Normal file
193
apps/app/src/components/sections/CreateTimeSlotDialog.vue
Normal 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>
|
||||
92
apps/app/src/composables/api/useSections.ts
Normal file
92
apps/app/src/composables/api/useSections.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
98
apps/app/src/composables/api/useShifts.ts
Normal file
98
apps/app/src/composables/api/useShifts.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
77
apps/app/src/composables/api/useTimeSlots.ts
Normal file
77
apps/app/src/composables/api/useTimeSlots.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
96
apps/app/src/types/section.ts
Normal file
96
apps/app/src/types/section.ts
Normal 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> {}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
384
docs/BACKLOG.md
Normal file
384
docs/BACKLOG.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Crewli — Product Backlog
|
||||
|
||||
> Gedocumenteerde wensen en features die bewust zijn uitgesteld.
|
||||
> Bijgewerkt: April 2026
|
||||
>
|
||||
> **Gebruik:** Voeg nieuwe items toe als ze tijdens development ontstaan.
|
||||
> Geef elk item een prioriteit en fase zodra je het gaat oppakken.
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Geplande features
|
||||
|
||||
### ARCH-01 — Recurrence / Terugkerende events
|
||||
|
||||
**Aanleiding:** Schaatsbaan use case — 8 weken, elke za+zo openingsdagen.
|
||||
**Wat:** Organisator definieert één template sub-event met RRULE.
|
||||
Platform genereert automatisch alle instanties.
|
||||
**Details:**
|
||||
|
||||
- RRULE formaat (RFC 5545): `FREQ=WEEKLY;BYDAY=SA,SU;UNTIL=20270126`
|
||||
- `events.recurrence_rule` (string nullable) — al gereserveerd in schema
|
||||
- `events.recurrence_exceptions` (JSON) — cancelled + modified dates
|
||||
- UI: "Genereer openingsdagen" wizard
|
||||
- Aanpassen van één instantie raakt template niet
|
||||
- "Alleen deze dag" / "Alle volgende dagen" / "Alle dagen" (Google Calendar patroon)
|
||||
**Schema:** Kolommen al aanwezig in v1.7. Alleen generator-logica ontbreekt.
|
||||
|
||||
---
|
||||
|
||||
### ARCH-02 — Min/max shifts per vrijwilliger per serie
|
||||
|
||||
**Aanleiding:** Schaatsbaan — eerlijke verdeling, minimum commitment.
|
||||
**Wat:** Per festival/serie instelbaar minimum en maximum aantal shifts
|
||||
dat een vrijwilliger kan claimen.
|
||||
**Details:**
|
||||
|
||||
- `festivals.min_shifts_per_volunteer` (int nullable)
|
||||
- `festivals.max_shifts_per_volunteer` (int nullable)
|
||||
- Portal toont voortgang: "Jij hebt 2 van minimaal 4 shifts geclaimd"
|
||||
- Bij bereiken maximum: verdere claims geblokkeerd
|
||||
**Afhankelijk van:** ARCH-01 (recurrence), Portal self-service
|
||||
|
||||
---
|
||||
|
||||
### ARCH-03 — Sectie templates / kopiëren van vorig event
|
||||
|
||||
**Aanleiding:** Organisatoren die elk jaar dezelfde secties en shifts opzetten.
|
||||
**Wat:** "Kopieer secties van vorig festival" functie in de UI.
|
||||
Kopieert festival_sections + shifts structuur (zonder toewijzingen).
|
||||
**Details:**
|
||||
|
||||
- UI: dropdown "Kopieer structuur van..." bij aanmaken festival
|
||||
- Optie: kopieer alleen secties / secties + shifts / alles
|
||||
- Tijden worden proportioneel aangepast aan nieuwe datums
|
||||
**Prioriteit:** Hoog — bespaart veel handmatig werk bij terugkerende festivals
|
||||
|
||||
---
|
||||
|
||||
### ARCH-04 — Cross-festival conflictdetectie
|
||||
|
||||
**Aanleiding:** Vrijwilliger die bij twee festivals van dezelfde organisatie
|
||||
op dezelfde dag ingepland staat.
|
||||
**Wat:** Waarschuwing (geen blokkade) als iemand al actief is op een
|
||||
ander festival van dezelfde organisatie op dezelfde datum.
|
||||
**Details:**
|
||||
|
||||
- Soft check — waarschuwing tonen, niet blokkeren
|
||||
- Relevant bij organisaties met meerdere festivals tegelijk
|
||||
- Query: `shift_assignments` cross-festival op person_id + datum
|
||||
|
||||
---
|
||||
|
||||
### ARCH-05 — Shift fairness / prioriteitswachtrij
|
||||
|
||||
**Aanleiding:** Populaire shifts worden direct volgeboekt door snelle vrijwilligers.
|
||||
**Wat:** Optionele wachtrij-modus waarbij het systeem eerlijk verdeelt
|
||||
op basis van: reliability score, aantal uren al ingepland, aanmeldvolgorde.
|
||||
**Details:**
|
||||
|
||||
- `shifts.assignment_mode` (enum: first_come | fair_queue | manual)
|
||||
- Fair queue: systeem wijst toe op basis van algoritme
|
||||
- Organisator keurt resultaat goed voor publicatie
|
||||
**Prioriteit:** Middel — nice-to-have voor grote festivals
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Communicatie & Notificaties
|
||||
|
||||
### COMM-01 — Real-time WebSocket notificaties
|
||||
|
||||
**Aanleiding:** Differentiator — geen van de concurrenten heeft dit.
|
||||
**Wat:** Push notificaties via Laravel Echo + Soketi voor:
|
||||
|
||||
- Nieuwe vrijwilliger aanmelding
|
||||
- Shift geclaimd
|
||||
- Uitnodiging geaccepteerd
|
||||
- Shift niet gevuld (waarschuwing)
|
||||
- No-show alert op show-dag
|
||||
**Tech:** Laravel Echo + Soketi (zelf-gehoste WebSocket server)
|
||||
**Frontend:** Notificatie bell in topbar activeren
|
||||
|
||||
---
|
||||
|
||||
### COMM-02 — Topbar volledig activeren
|
||||
|
||||
**Aanleiding:** Vuexy topbar staat er maar is niet aangesloten op Crewli.
|
||||
**Wat:**
|
||||
|
||||
- Zoekbalk (CTRL+K) aansluiten op Crewli-entiteiten
|
||||
(personen, events, secties zoeken)
|
||||
- Notificatie bell koppelen aan COMM-01
|
||||
- App switcher: Organizer / Admin / Portal wisselen
|
||||
- User avatar: gekoppeld aan ingelogde gebruiker (deels al gedaan)
|
||||
**Prioriteit:** Middel — werkt zonder maar verbetert UX significant
|
||||
|
||||
---
|
||||
|
||||
### COMM-03 — Globale zoekfunctie (cmd+K)
|
||||
|
||||
**Aanleiding:** Differentiator — cross-entiteit zoeken.
|
||||
**Wat:** Modal zoekbalk die zoekt over:
|
||||
personen, events, artiesten, secties, shifts
|
||||
**Tech:** Meilisearch of database full-text search
|
||||
**Prioriteit:** Laag — Fase 4
|
||||
|
||||
---
|
||||
|
||||
### COMM-04 — SMS + WhatsApp campagnes via Zender
|
||||
|
||||
**Aanleiding:** WeezCrew heeft dit als sterk punt.
|
||||
**Wat:** Bulk communicatie via Zender (zelf-gehoste SMS/WhatsApp gateway)
|
||||
|
||||
- Normal urgency → email
|
||||
- Urgent → WhatsApp
|
||||
- Emergency → SMS + WhatsApp parallel
|
||||
**Tech:** ZenderService (al gedocumenteerd in dev guide)
|
||||
**Afhankelijk van:** Communicatie module backend
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Show Day & Operationeel
|
||||
|
||||
### OPS-01 — Mission Control
|
||||
|
||||
**Aanleiding:** In2Event's sterkste feature.
|
||||
**Wat:** Real-time operationele hub op show-dag:
|
||||
|
||||
- Live check-in overzicht per sectie
|
||||
- Artiest handling (aankomst, soundcheck, performance status)
|
||||
- No-show alerts met automatische opvolging
|
||||
- Inventaris uitgifte (portofoons, hesjes)
|
||||
**Prioriteit:** Hoog voor show-dag gebruik
|
||||
|
||||
---
|
||||
|
||||
### OPS-02 — No-show automatisering
|
||||
|
||||
**Aanleiding:** 30-minuten alert voor niet-ingecheckte vrijwilligers.
|
||||
**Wat:** Automatische WhatsApp/SMS via Zender als vrijwilliger
|
||||
niet is ingecheckt 30 min na shift-starttijd.
|
||||
**Schema:** `show_day_absence_alerts` al aanwezig ✅
|
||||
**Afhankelijk van:** COMM-04 (Zender), OPS-01 (Mission Control)
|
||||
|
||||
---
|
||||
|
||||
### OPS-03 — Allocatiesheet PDF generator
|
||||
|
||||
**Aanleiding:** WeezCrew heeft branded PDF per crew.
|
||||
**Wat:** Gepersonaliseerde PDF per vrijwilliger/crew:
|
||||
taakbeschrijving, tijden, locatie, QR-code voor check-in.
|
||||
**Tech:** DomPDF (al geïnstalleerd)
|
||||
**Prioriteit:** Middel
|
||||
|
||||
---
|
||||
|
||||
### OPS-04 — Scanner infrastructuur
|
||||
|
||||
**Aanleiding:** QR check-in op locatie.
|
||||
**Wat:** Scanstations configureren, koppelen aan hardware.
|
||||
`scanners` tabel al aanwezig in schema ✅
|
||||
**Prioriteit:** Laag — Fase 4
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Vrijwilligers & Portal
|
||||
|
||||
### VOL-01 — apps/portal/ vrijwilliger self-service
|
||||
|
||||
**Aanleiding:** Vrijwilligers moeten zichzelf kunnen aanmelden en
|
||||
shifts claimen zonder toegang tot de Organizer app.
|
||||
**Wat:**
|
||||
|
||||
- Publiek registratieformulier (multi-step)
|
||||
- Login portal voor vrijwilligers
|
||||
- Beschikbaarheid opgeven (time slots kiezen)
|
||||
- My Shifts overzicht
|
||||
- Shift claimen met conflictdetectie
|
||||
- "Ik kan toch niet komen" workflow
|
||||
**Afhankelijk van:** Sections + Shifts backend (al klaar ✅)
|
||||
|
||||
---
|
||||
|
||||
### VOL-02 — Vrijwilliger paspoort + reliability score
|
||||
|
||||
**Aanleiding:** Platform-breed profiel dat accumuleert over jaren.
|
||||
**Wat:**
|
||||
|
||||
- Festival-paspoort: visuele tijdlijn van deelgenomen festivals
|
||||
- Reliability score (0.0-5.0): berekend via scheduled job
|
||||
- Coordinator-beoordeling per festival (intern, nooit zichtbaar)
|
||||
- "Would reinvite" indicator bij heruitnodiging
|
||||
**Schema:** `volunteer_profiles`, `volunteer_festival_history` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
### VOL-03 — Post-festival evaluatie + retrospectief
|
||||
|
||||
**Aanleiding:** Automatische feedback na het festival.
|
||||
**Wat:**
|
||||
|
||||
- 24u na laatste shift: evaluatiemail naar vrijwilligers
|
||||
- Max 5 vragen (beleving, shift kwaliteit, terugkomen?)
|
||||
- Gegenereerd retrospectief rapport per festival
|
||||
- Coordinator-beoordeling parallel (intern)
|
||||
**Schema:** `post_festival_evaluations`, `festival_retrospectives` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
### VOL-04 — Shift swap workflow (portal)
|
||||
|
||||
**Aanleiding:** Vrijwilliger wil shift ruilen met collega.
|
||||
**Wat:**
|
||||
|
||||
- Open swap: iedereen mag reageren
|
||||
- Persoonlijke swap: specifieke collega vragen
|
||||
- Na akkoord beide: coordinator bevestigt (of auto-approve)
|
||||
- Wachtlijst: bij uitval automatisch aanschrijven
|
||||
**Schema:** `shift_swap_requests`, `shift_absences`, `shift_waitlist` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Artiesten & Advancing
|
||||
|
||||
### ART-01 — Artist advancing portal (apps/portal/)
|
||||
|
||||
**Aanleiding:** Crescat's sterkste feature.
|
||||
**Wat:**
|
||||
|
||||
- Sectie-gebaseerd advance portal via gesignde URL
|
||||
- Per sectie onafhankelijk submitbaar (Guest List, Contacts, Production)
|
||||
- Milestone pipeline: Offer In → Advance Received
|
||||
- Per-artiest zichtbaarheidscontrole van advance secties
|
||||
- Submission diff tracking (created/updated/untouched/deleted)
|
||||
**Schema:** `advance_sections`, `advance_submissions` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
### ART-02 — Timetable (stage + drag-drop)
|
||||
|
||||
**Aanleiding:** FullCalendar timeline view voor podia-planning.
|
||||
**Wat:**
|
||||
|
||||
- Timeline view per podium
|
||||
- Drag-and-drop performances
|
||||
- B2B detectie (twee artiesten op zelfde podium zelfde tijd)
|
||||
**Tech:** FullCalendar (al in stack ✅)
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Formulieren & Leveranciers
|
||||
|
||||
### FORM-01 — Formulierbouwer
|
||||
|
||||
**Aanleiding:** WeezCrew heeft een krachtige drag-sorteerbare builder.
|
||||
**Wat:**
|
||||
|
||||
- Drag-sorteerbaar, conditionele logica
|
||||
- Live preview
|
||||
- Iframe embed voor externe websites
|
||||
- Configureerbare velden per crowd type
|
||||
**Schema:** `public_forms` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
### SUP-01 — Leveranciersportal + productieverzoeken
|
||||
|
||||
**Aanleiding:** Leveranciers moeten productie-informatie kunnen indienen.
|
||||
**Wat:**
|
||||
|
||||
- Token-gebaseerde portal toegang (geen account nodig)
|
||||
- Productieverzoek indienen (mensen, tech, stroom, voertuigen)
|
||||
- Crowd list indienen voor hun crew
|
||||
**Schema:** `production_requests`, `material_requests` al aanwezig ✅
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Differentiators
|
||||
|
||||
### DIFF-01 — Cross-event crew pool + reliability score
|
||||
|
||||
**Aanleiding:** Vrijwilligers hergebruiken over events van dezelfde organisatie.
|
||||
**Wat:** Eén klik heruitnodiging op basis van vorig jaar.
|
||||
Reliability score zichtbaar naast naam in de lijst.
|
||||
|
||||
---
|
||||
|
||||
### DIFF-02 — Crew PWA (mobiel)
|
||||
|
||||
**Aanleiding:** On-site zelfservice voor crew op hun telefoon.
|
||||
**Wat:** Progressive Web App voor:
|
||||
shifts bekijken, briefing lezen, clock-in, push notificaties.
|
||||
|
||||
---
|
||||
|
||||
### DIFF-03 — Publieke REST API + webhooks
|
||||
|
||||
**Aanleiding:** Enterprise integraties.
|
||||
**Wat:** Gedocumenteerde publieke API + webhook systeem
|
||||
voor third-party integraties (ticketing, HR, etc.)
|
||||
|
||||
---
|
||||
|
||||
### DIFF-04 — CO2 / Duurzaamheidsrapportage
|
||||
|
||||
**Aanleiding:** Toenemende focus op duurzame events.
|
||||
**Wat:** Emissieberekeningen op basis van transport en energieverbruik.
|
||||
**Status:** Expliciet out of scope voor v1.x
|
||||
|
||||
---
|
||||
|
||||
## Apps & Platforms
|
||||
|
||||
### APPS-01 — apps/admin/ volledig bouwen
|
||||
|
||||
**Aanleiding:** Super Admin panel voor platform-beheer.
|
||||
**Wat:**
|
||||
|
||||
- Alle organisaties beheren
|
||||
- Billing status wijzigen
|
||||
- Platform-gebruikers beheren
|
||||
- Usage statistieken
|
||||
|
||||
---
|
||||
|
||||
### APPS-02 — OrganisationSwitcher ingeklapte staat fix
|
||||
|
||||
**Aanleiding:** Flikkering/hover-bug bij ingeklapte sidebar.
|
||||
**Wat:** Correcte weergave en animatie in ingeklapte staat.
|
||||
**Prioriteit:** Low — cosmetisch, werkt functioneel wel
|
||||
|
||||
---
|
||||
|
||||
## Technische schuld
|
||||
|
||||
### TECH-01 — Bestaande tests bijwerken na festival/event refactor
|
||||
|
||||
**Aanleiding:** Na toevoegen parent_event_id worden bestaande tests
|
||||
mogelijk fragiel door gewijzigde factory-setup.
|
||||
**Wat:** Alle Feature tests reviewen en bijwerken waar nodig.
|
||||
|
||||
---
|
||||
|
||||
### TECH-02 — scopeForFestival helper op Event model
|
||||
|
||||
**Aanleiding:** Queries die door parent/child heen moeten werken.
|
||||
**Wat:** `Event::scopeWithChildren()` en `Event::scopeForFestival()`
|
||||
helper scopes zodat queries automatisch parent + children bevatten.
|
||||
|
||||
---
|
||||
|
||||
### TECH-03 — DevSeeder uitbreiden met festival-structuur
|
||||
|
||||
**Aanleiding:** Na festival/event refactor heeft de DevSeeder
|
||||
realistische testdata nodig met parent/child events.
|
||||
**Wat:** DevSeeder aanpassen met:
|
||||
|
||||
- Test festival (parent)
|
||||
- 2-3 sub-events (children)
|
||||
- Personen op festival-niveau
|
||||
|
||||
---
|
||||
|
||||
_Laatste update: April 2026_
|
||||
_Voeg nieuwe items toe met prefix: ARCH-, COMM-, OPS-, VOL-, ART-, FORM-, SUP-, DIFF-, APPS-, TECH-_
|
||||
178
docs/SCHEMA.md
178
docs/SCHEMA.md
@@ -1,14 +1,21 @@
|
||||
# Crewli — Core Database Schema
|
||||
|
||||
> Source: Design Document v1.3 — Section 3.5
|
||||
> **Version: 1.6** — Updated April 2026
|
||||
> **Version: 1.7** — Updated April 2026
|
||||
>
|
||||
> **Changelog:**
|
||||
>
|
||||
> - v1.3: Original — 12 database review findings incorporated
|
||||
> - v1.4: Competitor analysis amendments (Crescat, WeezCrew, In2Event)
|
||||
> - v1.5: Concept Event Structure review + final decisions
|
||||
> - v1.6: Removed `festival_sections.shift_follows_events` — feature does not fit Crewli's vision (staff planning is independent of artist/timetable planning)
|
||||
> - v1.6: Removed `festival_sections.shift_follows_events`
|
||||
> - v1.7: Festival/Event architecture — universal event model supporting
|
||||
> single events, multi-day festivals, multi-location events, event series
|
||||
> and periodic operations (recurrence). Added `parent_event_id`,
|
||||
> `event_type`, `sub_event_label`, `is_recurring`, `recurrence_rule`,
|
||||
> `recurrence_exceptions` to `events`. Added `event_person_activations`
|
||||
> pivot. Changed `persons.event_id` to reference festival-level event.
|
||||
> Added `event_type_label` for UI terminology customisation.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,23 +117,72 @@
|
||||
|
||||
### `events`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------------- | ------------------ | ------------------------------------------------------------------------- |
|
||||
| `id` | ULID | PK |
|
||||
| `organisation_id` | ULID FK | → organisations |
|
||||
| `name` | string | |
|
||||
| `slug` | string | |
|
||||
| `start_date` | date | |
|
||||
| `end_date` | date | |
|
||||
| `timezone` | string | default: Europe/Amsterdam |
|
||||
| `status` | enum | `draft\|published\|registration_open\|buildup\|showday\|teardown\|closed` |
|
||||
| `deleted_at` | timestamp nullable | Soft delete |
|
||||
> **v1.7:** Universal event model supporting all event types:
|
||||
> single events, multi-day festivals, multi-location events,
|
||||
> event series, and periodic operations (schaatsbaan use case).
|
||||
>
|
||||
> **Architecture:**
|
||||
>
|
||||
> - A **flat event** has no parent and no children → behaves as a normal single event
|
||||
> - A **festival/series** has no parent but has children → container level
|
||||
> - A **sub-event** has a `parent_event_id` → operational unit within a festival
|
||||
> - A **single event** = flat event where festival and operational unit are the same
|
||||
>
|
||||
> **UI behaviour:** If an event has no children, all tabs are shown at the
|
||||
> event level (flat mode). Once children are added, the event becomes a
|
||||
> festival container and children get the operational tabs.
|
||||
|
||||
**Relations:** `belongsTo` organisation, `hasMany` festival_sections, time_slots, persons, artists, briefings
|
||||
**Indexes:** `(organisation_id, status)`, `UNIQUE(organisation_id, slug)`
|
||||
| Column | Type | Notes |
|
||||
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| `id` | ULID | PK |
|
||||
| `organisation_id` | ULID FK | → organisations |
|
||||
| `parent_event_id` | ULID FK nullable | **v1.7** → events (nullOnDelete). NULL = top-level event or festival |
|
||||
| `name` | string | |
|
||||
| `slug` | string | |
|
||||
| `start_date` | date | |
|
||||
| `end_date` | date | |
|
||||
| `timezone` | string | default: Europe/Amsterdam |
|
||||
| `status` | enum | `draft\|published\|registration_open\|buildup\|showday\|teardown\|closed` |
|
||||
| `event_type` | enum | **v1.7** `event\|festival\|series` — default: event |
|
||||
| `event_type_label` | string nullable | **v1.7** UI label chosen by organiser: "Festival", "Evenement", "Serie" |
|
||||
| `sub_event_label` | string nullable | **v1.7** How to call children: "Dag", "Programmaonderdeel", "Editie" |
|
||||
| `is_recurring` | bool | **v1.7** default: false. True = generated from recurrence rule |
|
||||
| `recurrence_rule` | string nullable | **v1.7** RRULE (RFC 5545): "FREQ=WEEKLY;BYDAY=SA,SU;UNTIL=20270126" |
|
||||
| `recurrence_exceptions` | JSON nullable | **v1.7** Array of {date, type: cancelled\|modified, overrides: {}}. JSON OK: opaque config |
|
||||
| `deleted_at` | timestamp nullable | Soft delete |
|
||||
|
||||
**Relations:**
|
||||
|
||||
- `belongsTo` Organisation
|
||||
- `belongsTo` Event as parent (`parent_event_id`)
|
||||
- `hasMany` Event as children (`parent_event_id`)
|
||||
- `hasMany` FestivalSection, TimeSlot, Artist, Briefing (on sub-event or flat event)
|
||||
- `hasMany` Person (on festival/top-level event)
|
||||
|
||||
**Indexes:** `(organisation_id, status)`, `(parent_event_id)`, `UNIQUE(organisation_id, slug)`
|
||||
**Soft delete:** yes
|
||||
|
||||
> **v1.5 note:** `volunteer_min_hours_for_pass` removed — not applicable for Crewli use cases.
|
||||
**Helper scopes (Laravel):**
|
||||
|
||||
```php
|
||||
scopeTopLevel() // WHERE parent_event_id IS NULL
|
||||
scopeChildren() // WHERE parent_event_id IS NOT NULL
|
||||
scopeWithChildren() // includes self + all children
|
||||
scopeFestivals() // WHERE event_type IN ('festival', 'series')
|
||||
```
|
||||
|
||||
**Event type behaviour:**
|
||||
| event_type | Has parent? | Description |
|
||||
|---|---|---|
|
||||
| `event` | No | Flat single event — all modules at this level |
|
||||
| `event` | Yes | Sub-event (operational unit within festival) |
|
||||
| `festival` | No | Multi-day festival — children are the days |
|
||||
| `series` | No | Recurring series — children are the editions |
|
||||
|
||||
> **Recurrence note (BACKLOG ARCH-01):** `recurrence_rule` and
|
||||
> `recurrence_exceptions` are reserved for the future recurrence generator.
|
||||
> For now, sub-events are created manually. The generator will auto-create
|
||||
> sub-events from the RRULE when built.
|
||||
|
||||
---
|
||||
|
||||
@@ -537,21 +593,26 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
### `persons`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ------------------ | -------------------------------------------------------------------- |
|
||||
| `id` | ULID | PK |
|
||||
| `user_id` | ULID FK nullable | → users — nullable: external guests/artists have no platform account |
|
||||
| `event_id` | ULID FK | → events |
|
||||
| `crowd_type_id` | ULID FK | → crowd_types |
|
||||
| `company_id` | ULID FK nullable | → companies |
|
||||
| `name` | string | |
|
||||
| `email` | string | Indexed deduplication key |
|
||||
| `phone` | string nullable | |
|
||||
| `status` | enum | `invited\|applied\|pending\|approved\|rejected\|no_show` |
|
||||
| `is_blacklisted` | bool | |
|
||||
| `admin_notes` | text nullable | |
|
||||
| `custom_fields` | JSON | Event-specific fields — not queryable |
|
||||
| `deleted_at` | timestamp nullable | Soft delete |
|
||||
> **v1.7:** `event_id` now always references the top-level event (festival or
|
||||
> flat event). For sub-events, persons register at the festival level.
|
||||
> Activation per sub-event is tracked via `event_person_activations` pivot
|
||||
> and/or derived from shift assignments.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | ------------------ | ------------------------------------------------------------------------------ |
|
||||
| `id` | ULID | PK |
|
||||
| `user_id` | ULID FK nullable | → users — nullable: external guests/artists have no platform account |
|
||||
| `event_id` | ULID FK | → events — **v1.7** always references top-level event (festival or flat event) |
|
||||
| `crowd_type_id` | ULID FK | → crowd_types |
|
||||
| `company_id` | ULID FK nullable | → companies |
|
||||
| `name` | string | |
|
||||
| `email` | string | Indexed deduplication key |
|
||||
| `phone` | string nullable | |
|
||||
| `status` | enum | `invited\|applied\|pending\|approved\|rejected\|no_show` |
|
||||
| `is_blacklisted` | bool | |
|
||||
| `admin_notes` | text nullable | |
|
||||
| `custom_fields` | JSON | Event-specific fields — not queryable |
|
||||
| `deleted_at` | timestamp nullable | Soft delete |
|
||||
|
||||
**Unique constraint:** `UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL`
|
||||
**Indexes:** `(event_id, crowd_type_id, status)`, `(email, event_id)`, `(user_id, event_id)`
|
||||
@@ -610,6 +671,29 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
---
|
||||
|
||||
### `event_person_activations`
|
||||
|
||||
> **v1.7 New table.** Tracks which sub-events a person is active on,
|
||||
> independent of shift assignments. Used for:
|
||||
>
|
||||
> - Suppliers/crew present at all sub-events without shifts
|
||||
> - Festival-wide crew who need accreditation per day
|
||||
> - Persons manually activated on specific sub-events by coordinator
|
||||
>
|
||||
> For volunteers: activation is derived from shift assignments (no manual entry needed).
|
||||
> For fixed crew and suppliers: use this pivot for explicit activation.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ----------- | ------- | --------------------------------- |
|
||||
| `id` | int AI | PK — integer for join performance |
|
||||
| `event_id` | ULID FK | → events (the sub-event) |
|
||||
| `person_id` | ULID FK | → persons |
|
||||
|
||||
**Unique constraint:** `UNIQUE(event_id, person_id)`
|
||||
**Indexes:** `(person_id)`, `(event_id)`
|
||||
|
||||
---
|
||||
|
||||
## 3.5.6 Accreditation Engine
|
||||
|
||||
### `accreditation_categories`
|
||||
@@ -1236,10 +1320,42 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
|
||||
|
||||
- Every query on event data **MUST** scope on `organisation_id` via `OrganisationScope` Eloquent Global Scope
|
||||
- Use Laravel policies — never direct id-checks in controllers
|
||||
- **v1.7:** For festival queries, use `scopeWithChildren()` to include parent + all sub-events
|
||||
- **Audit log:** Spatie `laravel-activitylog` on: `persons`, `accreditation_assignments`, `shift_assignments`, `check_ins`, `production_requests`
|
||||
|
||||
---
|
||||
|
||||
### Rule 8 — Festival/Event Model (v1.7)
|
||||
|
||||
```
|
||||
Registration level → top-level event (festival or flat event)
|
||||
Operational level → sub-event (child event)
|
||||
Planning level → festival_section + shift
|
||||
|
||||
A person:
|
||||
- Registers once at festival/top-level event
|
||||
- Is active on 1 or more sub-events
|
||||
- Has shifts within those sub-events
|
||||
|
||||
Determined by:
|
||||
- Volunteer: via shift assignments (automatic)
|
||||
- Fixed crew: via event_person_activations (manual)
|
||||
- Supplier crew: via event_person_activations (manual)
|
||||
- Artist: always linked to one sub-event
|
||||
|
||||
Flat event (no children):
|
||||
- All modules at event level
|
||||
- persons.event_id = the event itself
|
||||
- No sub-event navigation shown in UI
|
||||
|
||||
Festival/series (has children):
|
||||
- persons.event_id = the parent (festival) event
|
||||
- festival_sections, shifts, artists = on child events
|
||||
- UI shows festival overview + child event tabs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 6 — Shift Time Resolution
|
||||
|
||||
```php
|
||||
|
||||
Reference in New Issue
Block a user