diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 71227a7..c18feb9 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -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'); diff --git a/api/database/migrations/2026_04_08_400000_add_festival_columns_to_events_table.php b/api/database/migrations/2026_04_08_400000_add_festival_columns_to_events_table.php new file mode 100644 index 0000000..9b86a0e --- /dev/null +++ b/api/database/migrations/2026_04_08_400000_add_festival_columns_to_events_table.php @@ -0,0 +1,64 @@ +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', + ]); + }); + } +}; diff --git a/api/database/migrations/2026_04_08_410000_create_event_person_activations_table.php b/api/database/migrations/2026_04_08_410000_create_event_person_activations_table.php new file mode 100644 index 0000000..64e5e9d --- /dev/null +++ b/api/database/migrations/2026_04_08_410000_create_event_person_activations_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 89cdfb8..90dffc1 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -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, + ], + ); } } diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 5ee9213..712d72d 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -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'] diff --git a/apps/app/package.json b/apps/app/package.json index cd15da2..a4f5e98 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -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", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 035ea64..3f1dec4 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -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 diff --git a/apps/app/src/components/sections/AssignShiftDialog.vue b/apps/app/src/components/sections/AssignShiftDialog.vue new file mode 100644 index 0000000..cbd5287 --- /dev/null +++ b/apps/app/src/components/sections/AssignShiftDialog.vue @@ -0,0 +1,165 @@ + + + diff --git a/apps/app/src/components/sections/CreateSectionDialog.vue b/apps/app/src/components/sections/CreateSectionDialog.vue new file mode 100644 index 0000000..74ace15 --- /dev/null +++ b/apps/app/src/components/sections/CreateSectionDialog.vue @@ -0,0 +1,142 @@ + + + diff --git a/apps/app/src/components/sections/CreateShiftDialog.vue b/apps/app/src/components/sections/CreateShiftDialog.vue new file mode 100644 index 0000000..665841c --- /dev/null +++ b/apps/app/src/components/sections/CreateShiftDialog.vue @@ -0,0 +1,287 @@ + + + diff --git a/apps/app/src/components/sections/CreateTimeSlotDialog.vue b/apps/app/src/components/sections/CreateTimeSlotDialog.vue new file mode 100644 index 0000000..eb715a6 --- /dev/null +++ b/apps/app/src/components/sections/CreateTimeSlotDialog.vue @@ -0,0 +1,193 @@ + + + diff --git a/apps/app/src/composables/api/useSections.ts b/apps/app/src/composables/api/useSections.ts new file mode 100644 index 0000000..e7942cf --- /dev/null +++ b/apps/app/src/composables/api/useSections.ts @@ -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 { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] +} + +export function useSectionList(eventId: Ref) { + return useQuery({ + queryKey: ['sections', eventId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/events/${eventId.value}/sections`, + ) + + return data.data + }, + enabled: () => !!eventId.value, + }) +} + +export function useCreateSection(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: CreateSectionPayload) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/sections`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] }) + }, + }) +} + +export function useUpdateSection(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: UpdateSectionPayload & { id: string }) => { + const { data } = await apiClient.put>( + `/events/${eventId.value}/sections/${id}`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] }) + }, + }) +} + +export function useDeleteSection(eventId: Ref) { + 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) { + 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] }) + }, + }) +} diff --git a/apps/app/src/composables/api/useShifts.ts b/apps/app/src/composables/api/useShifts.ts new file mode 100644 index 0000000..6190cf1 --- /dev/null +++ b/apps/app/src/composables/api/useShifts.ts @@ -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 { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] +} + +export function useShiftList(eventId: Ref, sectionId: Ref) { + return useQuery({ + queryKey: ['shifts', sectionId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/events/${eventId.value}/sections/${sectionId.value}/shifts`, + ) + + return data.data + }, + enabled: () => !!eventId.value && !!sectionId.value, + }) +} + +export function useCreateShift(eventId: Ref, sectionId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: CreateShiftPayload) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/sections/${sectionId.value}/shifts`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] }) + }, + }) +} + +export function useUpdateShift(eventId: Ref, sectionId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: UpdateShiftPayload & { id: string }) => { + const { data } = await apiClient.put>( + `/events/${eventId.value}/sections/${sectionId.value}/shifts/${id}`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shifts', sectionId.value] }) + }, + }) +} + +export function useDeleteShift(eventId: Ref, sectionId: Ref) { + 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, sectionId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ shiftId, personId }: { shiftId: string; personId: string }) => { + const { data } = await apiClient.post>( + `/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] }) + }, + }) +} diff --git a/apps/app/src/composables/api/useTimeSlots.ts b/apps/app/src/composables/api/useTimeSlots.ts new file mode 100644 index 0000000..869b438 --- /dev/null +++ b/apps/app/src/composables/api/useTimeSlots.ts @@ -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 { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] +} + +export function useTimeSlotList(eventId: Ref) { + return useQuery({ + queryKey: ['time-slots', eventId], + queryFn: async () => { + const { data } = await apiClient.get>( + `/events/${eventId.value}/time-slots`, + ) + + return data.data + }, + enabled: () => !!eventId.value, + }) +} + +export function useCreateTimeSlot(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: CreateTimeSlotPayload) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/time-slots`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['time-slots', eventId.value] }) + }, + }) +} + +export function useUpdateTimeSlot(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, ...payload }: UpdateTimeSlotPayload & { id: string }) => { + const { data } = await apiClient.put>( + `/events/${eventId.value}/time-slots/${id}`, + payload, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['time-slots', eventId.value] }) + }, + }) +} + +export function useDeleteTimeSlot(eventId: Ref) { + 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] }) + }, + }) +} diff --git a/apps/app/src/pages/events/[id]/sections/index.vue b/apps/app/src/pages/events/[id]/sections/index.vue index 2d55255..f31cccb 100644 --- a/apps/app/src/pages/events/[id]/sections/index.vue +++ b/apps/app/src/pages/events/[id]/sections/index.vue @@ -1,19 +1,587 @@ diff --git a/apps/app/src/types/section.ts b/apps/app/src/types/section.ts new file mode 100644 index 0000000..b665e9e --- /dev/null +++ b/apps/app/src/types/section.ts @@ -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 {} + +export interface CreateTimeSlotPayload { + name: string + person_type: string + date: string + start_time: string + end_time: string + duration_hours?: number +} + +export interface UpdateTimeSlotPayload extends Partial {} + +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 {} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index 651798e..446443d 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -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, }, -}) +}); diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..ddc41bb --- /dev/null +++ b/docs/BACKLOG.md @@ -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-_ diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 4aca99b..34fc3fe 100644 --- a/docs/SCHEMA.md +++ b/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