feat: registration section preferences with show_in_registration filtering and deduplication

Add show_in_registration and registration_description columns to festival_sections.
Registration form now shows deduplicated sections by name (across sub-events),
filtered by show_in_registration=true, grouped by category with card-based UI.
Section preferences use section_name instead of section_id.
Add GET/PUT registration-settings endpoints for festival-level bulk management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:03:54 +02:00
parent 3400e4cc7e
commit c21bc085e9
22 changed files with 1443 additions and 104 deletions

View File

@@ -247,34 +247,38 @@ class DevSeeder extends Seeder
'event_id' => $festival->id, 'name' => 'EHBO', 'type' => 'cross_event',
'category' => 'Veiligheid', 'icon' => 'tabler-first-aid-kit', 'sort_order' => 1,
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
'show_in_registration' => false,
]);
$nachtsecurity = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Nachtsecurity', 'type' => 'standard',
'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2,
'responder_self_checkin' => true, 'crew_auto_accepts' => false,
'show_in_registration' => false,
]);
$terreinploeg = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Terreinploeg', 'type' => 'standard',
'category' => 'Productie', 'icon' => 'tabler-shovel', 'sort_order' => 3,
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
'show_in_registration' => false,
]);
$accreditatiebalie = FestivalSection::create([
'event_id' => $festival->id, 'name' => 'Accreditatiebalie', 'type' => 'cross_event',
'category' => 'Ontvangst', 'icon' => 'tabler-id-badge', 'sort_order' => 4,
'responder_self_checkin' => true, 'crew_auto_accepts' => true,
'show_in_registration' => false,
]);
// ── Sub-event sections (5 per sub-event) ──
$sectionDefs = [
'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true],
'theaterbar' => ['name' => 'Theatertent Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true],
'hospitality' => ['name' => 'Backstage Hospitality', 'category' => 'Hospitality', 'icon' => 'tabler-armchair', 'crew_auto_accepts' => false],
'podiumtechniek' => ['name' => 'Podiumtechniek', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'crew_auto_accepts' => false],
'ingang' => ['name' => 'Ingang & Tickets', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'crew_auto_accepts' => true],
'hoofdbar' => ['name' => 'Hoofdpodium Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tap bier en drankjes voor festivalgangers bij het hoofdpodium'],
'theaterbar' => ['name' => 'Theatertent Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Bediening in de overdekte theatertent'],
'hospitality' => ['name' => 'Backstage Hospitality', 'category' => 'Hospitality', 'icon' => 'tabler-armchair', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Ontvang en begeleid artiesten en gasten backstage'],
'podiumtechniek' => ['name' => 'Podiumtechniek', 'category' => 'Techniek', 'icon' => 'tabler-speakerphone', 'crew_auto_accepts' => false, 'show_in_registration' => true, 'registration_description' => 'Help met geluid- en lichttechniek bij de podia'],
'ingang' => ['name' => 'Ingang & Tickets', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Verwelkom bezoekers en scan tickets bij de ingang'],
];
$sections = [];
@@ -290,6 +294,8 @@ class DevSeeder extends Seeder
'sort_order' => $order++,
'responder_self_checkin' => true,
'crew_auto_accepts' => $def['crew_auto_accepts'],
'show_in_registration' => $def['show_in_registration'] ?? false,
'registration_description' => $def['registration_description'] ?? null,
]);
}
}
@@ -598,8 +604,67 @@ class DevSeeder extends Seeder
$usedPersonSlots[$a['person']->id][] = $shift->time_slot_id;
}
// ── Intentional overbooking for UI testing ──
$overbookPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->whereNotIn('id', array_keys($usedPersonSlots))
->limit(20)
->get();
// EHBO Vrijdag Early: slots_total=3, target 4 approved (1 over)
// Already has 1 named approved (Ahmed). Add 3 more = 4 total.
$ehboOverbookTarget = 3;
$ehboShift = $s['ehbo_vr_early'];
foreach ($overbookPersons->splice(0, $ehboOverbookTarget) as $person) {
ShiftAssignment::create([
'shift_id' => $ehboShift->id,
'person_id' => $person->id,
'time_slot_id' => $ehboShift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => false,
'assigned_at' => now(),
'approved_at' => now(),
]);
$usedPersonSlots[$person->id][] = $ehboShift->time_slot_id;
}
// Terreinploeg Opbouw Dag 1: slots_total=15, target 17 approved (2 over)
// Already has 1 named approved (Lotte). Add 16 more = 17 total.
$terreinOverbookTarget = 16;
$terreinShift = $s['terrein_opbouw1'];
$terreinOverbookPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->whereNotIn('id', collect($usedPersonSlots)->keys()->filter(
fn ($pid) => in_array($terreinShift->time_slot_id, $usedPersonSlots[$pid] ?? []),
)->values())
->limit($terreinOverbookTarget)
->get();
foreach ($terreinOverbookPersons as $person) {
ShiftAssignment::create([
'shift_id' => $terreinShift->id,
'person_id' => $person->id,
'time_slot_id' => $terreinShift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => true,
'assigned_at' => now(),
'approved_at' => now(),
]);
$usedPersonSlots[$person->id][] = $terreinShift->time_slot_id;
}
// ── Factory shift assignments (~100) ──
// Track filled (approved/completed) counts per shift to respect slots_total
$shiftFilledCounts = [];
$existingAssignments = ShiftAssignment::whereIn('shift_id', collect($allShifts)->pluck('id'))
->whereIn('status', [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED])
->get()
->groupBy('shift_id');
foreach ($allShifts as $shift) {
$shiftFilledCounts[$shift->id] = ($existingAssignments[$shift->id] ?? collect())->count();
}
$approvedPersons = Person::where('event_id', $festival->id)
->where('status', 'approved')
->get();
@@ -631,6 +696,11 @@ class DevSeeder extends Seeder
$statusIdx++;
$isApproved = in_array($status, [ShiftAssignmentStatus::APPROVED, ShiftAssignmentStatus::COMPLETED]);
// Skip approved assignments if shift is already at capacity
if ($isApproved && ($shiftFilledCounts[$shift->id] ?? 0) >= $shift->slots_total) {
continue;
}
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
@@ -641,6 +711,11 @@ class DevSeeder extends Seeder
'approved_at' => $isApproved ? now() : null,
'rejection_reason' => $status === ShiftAssignmentStatus::REJECTED ? 'Geen beschikbare plek' : null,
]);
if ($isApproved) {
$shiftFilledCounts[$shift->id] = ($shiftFilledCounts[$shift->id] ?? 0) + 1;
}
$existing[] = $shift->time_slot_id;
}
$usedPersonSlots[$person->id] = $existing;
@@ -840,9 +915,9 @@ class DevSeeder extends Seeder
// ── Sections, time slots, shifts per sub-event ──
$sectionDefs = [
['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer'],
['name' => 'Schaatsverhuur', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket'],
['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel'],
['name' => 'Schaatsbaan Bar', 'category' => 'Bar', 'icon' => 'tabler-beer', 'show_in_registration' => true, 'registration_description' => 'Warme dranken en snacks serveren aan schaatsers'],
['name' => 'Schaatsverhuur', 'category' => 'Ontvangst', 'icon' => 'tabler-ticket', 'show_in_registration' => true, 'registration_description' => 'Schaatsen uitgeven en innemen bij de verhuurbalie'],
['name' => 'Terrein', 'category' => 'Productie', 'icon' => 'tabler-shovel', 'show_in_registration' => true, 'registration_description' => 'IJsbaan onderhoud en terreinbeheer'],
];
$allShifts = [];
@@ -863,6 +938,8 @@ class DevSeeder extends Seeder
'sort_order' => $order + 1,
'responder_self_checkin' => true,
'crew_auto_accepts' => true,
'show_in_registration' => $sd['show_in_registration'] ?? false,
'registration_description' => $sd['registration_description'] ?? null,
]);
}
@@ -905,9 +982,10 @@ class DevSeeder extends Seeder
$draftShifts = collect($allShifts)->filter(fn (Shift $shift) => $shift->status === 'draft');
$allApproved = $approvedVol->merge($approvedCrew);
// Week 1+2: 5-6 per open shift
// Week 1+2: up to slots_total per open shift
foreach ($openShifts as $shift) {
$assigned = $allApproved->shuffle()->take(rand(5, 6));
$count = min(rand(5, 6), $shift->slots_total);
$assigned = $allApproved->shuffle()->take($count);
foreach ($assigned as $person) {
ShiftAssignment::create([
'shift_id' => $shift->id,
@@ -981,10 +1059,10 @@ class DevSeeder extends Seeder
// ── Sections (4) ──
$secPodium = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Podium Erasmusbrug', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 1, 'responder_self_checkin' => true]);
$secBar = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Bar Willemsplein', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => true]);
$secKids = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Kinderactiviteiten', 'type' => 'standard', 'category' => 'Entertainment', 'icon' => 'tabler-balloon', 'sort_order' => 3, 'responder_self_checkin' => true]);
$secBev = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Beveiliging', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 4, 'responder_self_checkin' => true]);
$secPodium = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Podium Erasmusbrug', 'type' => 'standard', 'category' => 'Podium', 'icon' => 'tabler-microphone-2', 'sort_order' => 1, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Podiummedewerker bij de Erasmusbrug']);
$secBar = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Bar Willemsplein', 'type' => 'standard', 'category' => 'Bar', 'icon' => 'tabler-beer', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => true, 'show_in_registration' => true, 'registration_description' => 'Tappen en serveren op het Willemsplein']);
$secKids = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Kinderactiviteiten', 'type' => 'standard', 'category' => 'Entertainment', 'icon' => 'tabler-balloon', 'sort_order' => 3, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Begeleid kinderactiviteiten en spelletjes']);
$secBev = FestivalSection::create(['event_id' => $koningsdag->id, 'name' => 'Beveiliging', 'type' => 'standard', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 4, 'responder_self_checkin' => true, 'show_in_registration' => true, 'registration_description' => 'Beveiliging en crowd management']);
$kSections = [$secPodium, $secBar, $secKids, $secBev];
$kLocations = [$locErasmus, $locWillems, $locOudeHaven, $locOudeHaven];