diff --git a/api/app/Http/Controllers/Api/V1/TimeSlotController.php b/api/app/Http/Controllers/Api/V1/TimeSlotController.php index da9759a9..63515db3 100644 --- a/api/app/Http/Controllers/Api/V1/TimeSlotController.php +++ b/api/app/Http/Controllers/Api/V1/TimeSlotController.php @@ -34,6 +34,7 @@ final class TimeSlotController extends Controller ->orderBy('start_time') ->get(); + // For sub-events: include parent festival time slots if ($event->isSubEvent() && request()->boolean('include_parent') && $event->parent_event_id) { $parentTimeSlots = TimeSlot::where('event_id', $event->parent_event_id) ->withCount('shifts') @@ -56,6 +57,35 @@ final class TimeSlotController extends Controller ->values(); } + // For festivals: include all sub-event time slots (for cross_event section shift dialogs) + if (!$event->isSubEvent() && request()->boolean('include_children')) { + $childEventIds = Event::where('parent_event_id', $event->id)->pluck('id'); + + if ($childEventIds->isNotEmpty()) { + $childTimeSlots = TimeSlot::whereIn('event_id', $childEventIds) + ->withCount('shifts') + ->with([ + 'event', + 'shifts' => fn ($q) => $q->withCount([ + 'shiftAssignments as assignments_count' => fn ($q) => $q->where('status', 'approved'), + ]), + ]) + ->orderBy('date') + ->orderBy('start_time') + ->get(); + + $timeSlots->load('event'); + $timeSlots->each(fn (TimeSlot $ts) => $ts->setAttribute('source', 'own')); + $childTimeSlots->each(function (TimeSlot $ts) use ($event) { + $ts->setAttribute('source', $ts->event_id); + }); + + $timeSlots = $timeSlots->merge($childTimeSlots) + ->sortBy([['date', 'asc'], ['start_time', 'asc']]) + ->values(); + } + } + return TimeSlotResource::collection($timeSlots); } diff --git a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php index 495d4770..3ee90a6a 100644 --- a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Models\Event; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -20,12 +21,21 @@ final class StoreShiftRequest extends FormRequest return [ 'time_slot_id' => ['required', 'ulid', Rule::exists('time_slots', 'id')->where(function ($query) { $event = $this->route('event'); + $section = $this->route('section'); $eventIds = [$event->id]; + // Sub-event: also accept parent festival time slots if ($event->isSubEvent() && $event->parent_event_id) { $eventIds[] = $event->parent_event_id; } + // Cross_event section: also accept all sub-event time slots + if ($section && $section->type === 'cross_event') { + $childIds = Event::where('parent_event_id', $event->id) + ->pluck('id')->toArray(); + $eventIds = array_merge($eventIds, $childIds); + } + $query->whereIn('event_id', $eventIds); })], 'location_id' => ['nullable', 'ulid', Rule::exists('locations', 'id')->where('event_id', $this->route('event')->id)], diff --git a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php index 9f8d34e5..17788a0d 100644 --- a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Models\Event; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -20,12 +21,21 @@ final class UpdateShiftRequest extends FormRequest return [ 'time_slot_id' => ['sometimes', 'ulid', Rule::exists('time_slots', 'id')->where(function ($query) { $event = $this->route('event'); + $section = $this->route('section'); $eventIds = [$event->id]; + // Sub-event: also accept parent festival time slots if ($event->isSubEvent() && $event->parent_event_id) { $eventIds[] = $event->parent_event_id; } + // Cross_event section: also accept all sub-event time slots + if ($section && $section->type === 'cross_event') { + $childIds = Event::where('parent_event_id', $event->id) + ->pluck('id')->toArray(); + $eventIds = array_merge($eventIds, $childIds); + } + $query->whereIn('event_id', $eventIds); })], 'location_id' => ['nullable', 'ulid', Rule::exists('locations', 'id')->where('event_id', $this->route('event')->id)], diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 06e8c95a..1edc59ac 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -47,6 +47,7 @@ class DevSeeder extends Seeder $this->seedOrganisation(); $this->seedEchtFeesten(); + $this->seedBraderie(); $this->seedIJsbaan(); $this->seedKoningsdag(); $this->seedNachtVanDeKaap(); @@ -187,7 +188,7 @@ class DevSeeder extends Seeder $vrijdag = Event::create([ 'organisation_id' => $this->org->id, 'parent_event_id' => $festival->id, - 'name' => 'Vrijdag', + 'name' => 'Dag 1 — Vrijdag', 'slug' => 'echt-feesten-2026-vrijdag', 'start_date' => '2026-07-10', 'end_date' => '2026-07-10', @@ -199,7 +200,7 @@ class DevSeeder extends Seeder $zaterdag = Event::create([ 'organisation_id' => $this->org->id, 'parent_event_id' => $festival->id, - 'name' => 'Zaterdag', + 'name' => 'Dag 2 — Zaterdag', 'slug' => 'echt-feesten-2026-zaterdag', 'start_date' => '2026-07-11', 'end_date' => '2026-07-11', @@ -211,7 +212,7 @@ class DevSeeder extends Seeder $zondag = Event::create([ 'organisation_id' => $this->org->id, 'parent_event_id' => $festival->id, - 'name' => 'Zondag', + 'name' => 'Dag 3 — Zondag', 'slug' => 'echt-feesten-2026-zondag', 'start_date' => '2026-07-12', 'end_date' => '2026-07-12', @@ -257,8 +258,8 @@ class DevSeeder extends Seeder 'show_in_registration' => false, ]); - $nachtsecurity = FestivalSection::create([ - 'event_id' => $festival->id, 'name' => 'Nachtsecurity', 'type' => 'standard', + $security = FestivalSection::create([ + 'event_id' => $festival->id, 'name' => 'Security', 'type' => 'cross_event', 'category' => 'Veiligheid', 'icon' => 'tabler-shield', 'sort_order' => 2, 'responder_self_checkin' => true, 'crew_auto_accepts' => false, 'show_in_registration' => false, @@ -307,70 +308,85 @@ class DevSeeder extends Seeder } } - // ── Festival-level time slots ── - // CREW slots for operational planning (opbouw, nachtbewaking, afbraak) - // VOLUNTEER slots for cross_event sections (EHBO, Accreditatie) that span all days + // ── Festival-level time slots (CREW only — operational planning) ── + // No VOLUNTEER slots at festival level — cross_event sections use sub-event time slots $fSlots = []; - $fSlots['opbouw1'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw Dag 1', 'person_type' => 'CREW', 'date' => '2026-07-09', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]); - $fSlots['opbouw2'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw Dag 2', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '08:00', 'end_time' => '14:00', 'duration_hours' => 6.00]); - $fSlots['nacht_vr'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtbewaking Vr→Za', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '02:00', 'end_time' => '08:00', 'duration_hours' => 6.00]); - $fSlots['nacht_za'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtbewaking Za→Zo', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '02:00', 'end_time' => '08:00', 'duration_hours' => 6.00]); + $fSlots['opbouw1'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw vrijdag', 'person_type' => 'CREW', 'date' => '2026-07-09', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]); + $fSlots['opbouw2'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Opbouw zaterdag', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '08:00', 'end_time' => '14:00', 'duration_hours' => 6.00]); + $fSlots['nacht_vr'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtsecurity vr→za', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '23:00', 'end_time' => '07:00', 'duration_hours' => 8.00]); + $fSlots['nacht_za'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Nachtsecurity za→zo', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '23:00', 'end_time' => '07:00', 'duration_hours' => 8.00]); $fSlots['afbraak'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Afbraak', 'person_type' => 'CREW', 'date' => '2026-07-13', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10.00]); - // Festival-level VOLUNTEER slots for cross_event sections (EHBO, Accreditatie) - $fSlots['vr_early'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijdag Early (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '14:00', 'end_time' => '18:00', 'duration_hours' => 4.00]); - $fSlots['vr_avond'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Vrijdag Avond (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); - $fSlots['za_dag'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Zaterdag Dag (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]); - $fSlots['za_avond'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Zaterdag Avond (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); - $fSlots['zo_dag'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Zondag Dag (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]); - $fSlots['zo_avond'] = TimeSlot::create(['event_id' => $festival->id, 'name' => 'Zondag Avond (Festival)', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '18:00', 'end_time' => '22:00', 'duration_hours' => 4.00]); - // ── Sub-event time slots ── + // ── Sub-event time slots (program-specific) ── $ts = []; - $ts['vr_early'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag Early', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '14:00', 'end_time' => '18:00', 'duration_hours' => 4.00]); - $ts['vr_avond'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag Avond', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); - $ts['vr_pers'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag Pers', 'person_type' => 'PRESS', 'date' => '2026-07-10', 'start_time' => '16:00', 'end_time' => '22:00', 'duration_hours' => 6.00]); - $ts['za_dag'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag Dag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]); - $ts['za_avond'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag Avond', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); - $ts['za_crew'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag Crew', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '08:00', 'end_time' => '20:00', 'duration_hours' => 12.00]); - $ts['zo_dag'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag Dag', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]); - $ts['zo_avond'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag Avond', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '18:00', 'end_time' => '22:00', 'duration_hours' => 4.00]); + // Dag 1 — Vrijdag + $ts['vr_middag'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '12:00', 'end_time' => '18:00', 'duration_hours' => 6.00]); + $ts['vr_avond'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-10', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); + $ts['vr_crew'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag avond — crew', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '17:00', 'end_time' => '03:00', 'duration_hours' => 10.00]); + $ts['vr_pers'] = TimeSlot::create(['event_id' => $vrijdag->id, 'name' => 'Vrijdag pers', 'person_type' => 'PRESS', 'date' => '2026-07-10', 'start_time' => '16:00', 'end_time' => '22:00', 'duration_hours' => 6.00]); + // Dag 2 — Zaterdag + $ts['za_middag'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '12:00', 'end_time' => '18:00', 'duration_hours' => 6.00]); + $ts['za_avond'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-11', 'start_time' => '18:00', 'end_time' => '02:00', 'duration_hours' => 8.00]); + $ts['za_crew'] = TimeSlot::create(['event_id' => $zaterdag->id, 'name' => 'Zaterdag avond — crew', 'person_type' => 'CREW', 'date' => '2026-07-11', 'start_time' => '17:00', 'end_time' => '03:00', 'duration_hours' => 10.00]); + // Dag 3 — Zondag + $ts['zo_middag'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '10:00', 'end_time' => '18:00', 'duration_hours' => 8.00]); + $ts['zo_avond'] = TimeSlot::create(['event_id' => $zondag->id, 'name' => 'Zondag avond — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-07-12', 'start_time' => '18:00', 'end_time' => '22:00', 'duration_hours' => 4.00]); // ── Shifts (~55) ── $allShifts = []; $s = []; // named shifts for explicit assignments - // EHBO: 6 shifts using festival-level volunteer time slots - foreach (['vr_early', 'vr_avond', 'za_dag', 'za_avond', 'zo_dag', 'zo_avond'] as $key) { + // EHBO (cross_event): shifts use sub-event time slots (cross_event exception) + // + 1 festival-level shift for opbouw duties + $s['ehbo_opbouw'] = Shift::create([ + 'festival_section_id' => $ehbo->id, 'time_slot_id' => $fSlots['opbouw1']->id, + 'title' => 'EHBO Post', 'slots_total' => 2, 'slots_open_for_claiming' => 0, 'status' => 'open', + ]); + $allShifts[] = $s['ehbo_opbouw']; + foreach (['vr_middag', 'vr_avond', 'za_middag', 'za_avond', 'zo_middag'] as $key) { $shift = Shift::create([ 'festival_section_id' => $ehbo->id, - 'time_slot_id' => $fSlots[$key]->id, + 'time_slot_id' => $ts[$key]->id, 'title' => 'EHBO Post', - 'slots_total' => $key === 'za_dag' ? 4 : 3, - 'slots_open_for_claiming' => $key === 'za_dag' ? 0 : 2, + 'slots_total' => $key === 'za_middag' ? 4 : 3, + 'slots_open_for_claiming' => $key === 'za_middag' ? 0 : 2, 'status' => 'open', ]); $allShifts[] = $shift; $s["ehbo_{$key}"] = $shift; } - // Nachtsecurity: 2 shifts + // Security (cross_event): festival night shifts + sub-event crew shifts foreach (['nacht_vr', 'nacht_za'] as $key) { $shift = Shift::create([ - 'festival_section_id' => $nachtsecurity->id, + 'festival_section_id' => $security->id, 'time_slot_id' => $fSlots[$key]->id, - 'title' => 'Nachtbewaker', + 'title' => 'Beveiliger', 'slots_total' => 6, 'slots_open_for_claiming' => 0, 'status' => 'open', ]); $allShifts[] = $shift; - $s["nacht_{$key}"] = $shift; + $s["security_{$key}"] = $shift; + } + // Security shifts using sub-event CREW time slots (cross_event exception) + foreach (['vr_crew', 'za_crew'] as $key) { + $shift = Shift::create([ + 'festival_section_id' => $security->id, + 'time_slot_id' => $ts[$key]->id, + 'title' => 'Beveiliger', + 'slots_total' => 4, + 'slots_open_for_claiming' => 0, + 'status' => 'open', + ]); + $allShifts[] = $shift; + $s["security_{$key}"] = $shift; } - // Terreinploeg: 3 shifts + // Terreinploeg (standard on festival): shifts use festival time slots only foreach (['opbouw1', 'opbouw2', 'afbraak'] as $key) { $shift = Shift::create([ 'festival_section_id' => $terreinploeg->id, @@ -384,11 +400,11 @@ class DevSeeder extends Seeder $s["terrein_{$key}"] = $shift; } - // Accreditatiebalie: 4 shifts using festival-level volunteer time slots - foreach (['vr_early', 'za_dag', 'zo_dag', 'za_avond'] as $key) { + // Accreditatiebalie (cross_event): shifts use sub-event time slots + foreach (['vr_middag', 'za_middag', 'zo_middag', 'za_avond'] as $key) { $shift = Shift::create([ 'festival_section_id' => $accreditatiebalie->id, - 'time_slot_id' => $fSlots[$key]->id, + 'time_slot_id' => $ts[$key]->id, 'title' => 'Accreditatiemedewerker', 'slots_total' => 3, 'slots_open_for_claiming' => 2, @@ -400,9 +416,9 @@ class DevSeeder extends Seeder // Sub-event shifts $slotMap = [ - 'vrijdag' => ['vr_early', 'vr_avond'], - 'zaterdag' => ['za_dag', 'za_avond'], - 'zondag' => ['zo_dag', 'zo_avond'], + 'vrijdag' => ['vr_middag', 'vr_avond'], + 'zaterdag' => ['za_middag', 'za_avond'], + 'zondag' => ['zo_middag', 'zo_avond'], ]; $locationMap = [ @@ -579,17 +595,17 @@ class DevSeeder extends Seeder $namedAssignments = [ // Jan de Vries: 3 bar shifts - ['person' => $jan, 'shift' => 'hoofdbar_vr_early', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], + ['person' => $jan, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], ['person' => $jan, 'shift' => 'hoofdbar_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => false], - ['person' => $jan, 'shift' => 'hoofdbar_za_dag', 'status' => ShiftAssignmentStatus::APPROVED], + ['person' => $jan, 'shift' => 'hoofdbar_za_middag', 'status' => ShiftAssignmentStatus::APPROVED], // Lisa Bakker - ['person' => $lisaB, 'shift' => 'hoofdbar_vr_early', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], - ['person' => $lisaB, 'shift' => 'hoofdbar_za_dag', 'status' => ShiftAssignmentStatus::PENDING_APPROVAL], - ['person' => $lisaB, 'shift' => 'theaterbar_zo_dag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], - // Ahmed Hassan: 4 EHBO shifts - ['person' => $ahmedP, 'shift' => 'ehbo_vr_early', 'status' => ShiftAssignmentStatus::APPROVED], + ['person' => $lisaB, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], + ['person' => $lisaB, 'shift' => 'hoofdbar_za_middag', 'status' => ShiftAssignmentStatus::PENDING_APPROVAL], + ['person' => $lisaB, 'shift' => 'theaterbar_zo_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], + // Ahmed Hassan: 4 EHBO shifts (cross_event section using sub-event time slots) + ['person' => $ahmedP, 'shift' => 'ehbo_vr_middag', 'status' => ShiftAssignmentStatus::APPROVED], ['person' => $ahmedP, 'shift' => 'ehbo_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED], - ['person' => $ahmedP, 'shift' => 'ehbo_za_dag', 'status' => ShiftAssignmentStatus::APPROVED], + ['person' => $ahmedP, 'shift' => 'ehbo_za_middag', 'status' => ShiftAssignmentStatus::APPROVED], ['person' => $ahmedP, 'shift' => 'ehbo_za_avond', 'status' => ShiftAssignmentStatus::APPROVED], // Sara Jansen ['person' => $saraJ, 'shift' => 'theaterbar_vr_avond', 'status' => ShiftAssignmentStatus::PENDING_APPROVAL], @@ -601,15 +617,15 @@ class DevSeeder extends Seeder ['person' => $lotte, 'shift' => 'terrein_opbouw1', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], ['person' => $lotte, 'shift' => 'terrein_afbraak', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], // Robin - ['person' => $robin, 'shift' => 'theaterbar_za_dag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], - // Klaas: nachtsecurity, assigned by Tom - ['person' => $klaas, 'shift' => 'nacht_nacht_vr', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id], - // Dennis: nachtsecurity - ['person' => $dennis, 'shift' => 'nacht_nacht_za', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id], + ['person' => $robin, 'shift' => 'theaterbar_za_middag', 'status' => ShiftAssignmentStatus::APPROVED, 'auto' => true], + // Klaas: security, assigned by Tom + ['person' => $klaas, 'shift' => 'security_nacht_vr', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id], + // Dennis: security + ['person' => $dennis, 'shift' => 'security_nacht_za', 'status' => ShiftAssignmentStatus::APPROVED, 'assigned_by' => $tom->id], // Daan: rejected - ['person' => $daan, 'shift' => 'hoofdbar_vr_early', 'status' => ShiftAssignmentStatus::REJECTED, 'reason' => 'Nog niet goedgekeurd'], + ['person' => $daan, 'shift' => 'hoofdbar_vr_middag', 'status' => ShiftAssignmentStatus::REJECTED, 'reason' => 'Nog niet goedgekeurd'], // Emma: cancelled - ['person' => $emma, 'shift' => 'ingang_vr_early', 'status' => ShiftAssignmentStatus::CANCELLED], + ['person' => $emma, 'shift' => 'ingang_vr_middag', 'status' => ShiftAssignmentStatus::CANCELLED], // Pieter: podiumtechniek with overlap ['person' => $pieter, 'shift' => 'podiumtechniek_vr_avond', 'status' => ShiftAssignmentStatus::APPROVED], ['person' => $pieter, 'shift' => 'podiumtechniek_za_avond', 'status' => ShiftAssignmentStatus::APPROVED], @@ -642,10 +658,10 @@ class DevSeeder extends Seeder ->limit(20) ->get(); - // EHBO Vrijdag Early: slots_total=3, target 4 approved (1 over) + // EHBO Vrijdag Middag: 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']; + $ehboShift = $s['ehbo_vr_middag']; foreach ($overbookPersons->splice(0, $ehboOverbookTarget) as $person) { ShiftAssignment::create([ 'shift_id' => $ehboShift->id, @@ -768,7 +784,7 @@ class DevSeeder extends Seeder // ── Volunteer availabilities (~70) ── - $volSlotKeys = ['vr_early', 'vr_avond', 'za_dag', 'za_avond', 'zo_dag', 'zo_avond']; + $volSlotKeys = ['vr_middag', 'vr_avond', 'za_middag', 'za_avond', 'zo_middag', 'zo_avond']; $volSlots = collect($volSlotKeys)->map(fn (string $k) => $ts[$k]); // Named availabilities @@ -777,7 +793,7 @@ class DevSeeder extends Seeder [$lisaB, $volSlots->random(5), fn () => rand(1, 5)], [$ahmedP, $volSlots, fn () => 5], [$saraJ, collect([$ts['vr_avond'], $ts['za_avond']]), fn () => rand(1, 5)], - [$fatima, collect([$ts['za_dag'], $ts['za_avond'], $ts['zo_dag']]), fn () => rand(1, 5)], + [$fatima, collect([$ts['za_middag'], $ts['za_avond'], $ts['zo_middag']]), fn () => rand(1, 5)], ]; $usedAvails = []; @@ -904,7 +920,67 @@ class DevSeeder extends Seeder } // ========================================================================= - // Event 2: IJsbaan Winterpark (series, published, 60 persons) + // Event 2: Braderie Dorpstown 2026 (flat event, registration_open) + // ========================================================================= + + private function seedBraderie(): void + { + DB::transaction(function (): void { + $this->command->info('Seeding Braderie Dorpstown 2026...'); + + $braderie = Event::create([ + 'organisation_id' => $this->org->id, + 'name' => 'Braderie Dorpstown 2026', + 'slug' => 'braderie-dorpstown-2026', + 'start_date' => '2026-06-14', + 'end_date' => '2026-06-14', + 'timezone' => 'Europe/Amsterdam', + 'status' => 'registration_open', + 'event_type' => 'event', + ]); + + // ── Time Slots (3) ── + + $tsOchtend = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag ochtend — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-06-14', 'start_time' => '07:00', 'end_time' => '13:00', 'duration_hours' => 6.00]); + $tsMiddag = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag middag — vrijwilliger', 'person_type' => 'VOLUNTEER', 'date' => '2026-06-14', 'start_time' => '13:00', 'end_time' => '18:00', 'duration_hours' => 5.00]); + $tsCrew = TimeSlot::create(['event_id' => $braderie->id, 'name' => 'Zondag — crew', 'person_type' => 'CREW', 'date' => '2026-06-14', 'start_time' => '06:00', 'end_time' => '20:00', 'duration_hours' => 14.00]); + + // ── Sections (2) ── + + $verkeersregelaars = FestivalSection::create([ + 'event_id' => $braderie->id, 'name' => 'Verkeersregelaars', 'type' => 'standard', + 'category' => 'Veiligheid', 'icon' => 'tabler-traffic-cone', 'sort_order' => 1, + 'responder_self_checkin' => true, 'crew_auto_accepts' => false, + 'show_in_registration' => true, 'registration_description' => 'Verkeersregelaar bij de braderie', + ]); + + $centraleBar = FestivalSection::create([ + 'event_id' => $braderie->id, 'name' => 'Centrale bar', '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 bij de centrale bar', + ]); + + // ── Shifts (4) ── + + Shift::create(['festival_section_id' => $verkeersregelaars->id, 'time_slot_id' => $tsOchtend->id, 'title' => 'Verkeersregelaar', 'slots_total' => 4, 'slots_open_for_claiming' => 4, 'status' => 'open']); + Shift::create(['festival_section_id' => $verkeersregelaars->id, 'time_slot_id' => $tsMiddag->id, 'title' => 'Verkeersregelaar', 'slots_total' => 4, 'slots_open_for_claiming' => 4, 'status' => 'open']); + Shift::create(['festival_section_id' => $centraleBar->id, 'time_slot_id' => $tsOchtend->id, 'title' => 'Tapper', 'slots_total' => 6, 'slots_open_for_claiming' => 6, 'status' => 'open']); + Shift::create(['festival_section_id' => $centraleBar->id, 'time_slot_id' => $tsMiddag->id, 'title' => 'Tapper', 'slots_total' => 6, 'slots_open_for_claiming' => 6, 'status' => 'open']); + + // ── Persons (15) ── + + $vol = $this->crowdTypes['VOLUNTEER']->id; + Person::factory()->count(10)->approved()->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol]); + Person::factory()->count(3)->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol, 'status' => 'pending']); + Person::factory()->count(2)->create(['event_id' => $braderie->id, 'crowd_type_id' => $vol, 'status' => 'applied']); + + $this->command->info(' Braderie Dorpstown 2026 complete'); + }); + } + + // ========================================================================= + // Event 3: IJsbaan Winterpark (series, published, 60 persons) // ========================================================================= private function seedIJsbaan(): void diff --git a/api/tests/Feature/Shift/ShiftTest.php b/api/tests/Feature/Shift/ShiftTest.php index 23c68f38..720e2bb7 100644 --- a/api/tests/Feature/Shift/ShiftTest.php +++ b/api/tests/Feature/Shift/ShiftTest.php @@ -444,4 +444,86 @@ class ShiftTest extends TestCase $response->assertForbidden(); } + + public function test_create_shift_with_parent_festival_time_slot_succeeds(): void + { + $festival = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'event_type' => 'festival', + ]); + $subEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + ]); + $festivalSlot = TimeSlot::factory()->create(['event_id' => $festival->id]); + $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id, 'type' => 'standard']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}/sections/{$subSection->id}/shifts", + [ + 'time_slot_id' => $festivalSlot->id, + 'title' => 'Opbouw shift', + 'slots_total' => 5, + 'slots_open_for_claiming' => 0, + ], + ); + + $response->assertCreated(); + } + + public function test_create_shift_in_cross_event_section_with_child_time_slot_succeeds(): void + { + $festival = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'event_type' => 'festival', + ]); + $subEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + ]); + $subEventSlot = TimeSlot::factory()->create(['event_id' => $subEvent->id]); + $crossSection = FestivalSection::factory()->create([ + 'event_id' => $festival->id, + 'type' => 'cross_event', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/sections/{$crossSection->id}/shifts", + [ + 'time_slot_id' => $subEventSlot->id, + 'title' => 'EHBO Post', + 'slots_total' => 3, + 'slots_open_for_claiming' => 2, + ], + ); + + $response->assertCreated(); + } + + public function test_create_shift_with_unrelated_event_time_slot_fails_422(): void + { + $unrelatedEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $unrelatedSlot = TimeSlot::factory()->create(['event_id' => $unrelatedEvent->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts", + [ + 'time_slot_id' => $unrelatedSlot->id, + 'title' => 'Invalid shift', + 'slots_total' => 1, + 'slots_open_for_claiming' => 0, + ], + ); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('time_slot_id'); + } } diff --git a/api/tests/Feature/TimeSlot/TimeSlotTest.php b/api/tests/Feature/TimeSlot/TimeSlotTest.php index f1a0846d..da02c3e5 100644 --- a/api/tests/Feature/TimeSlot/TimeSlotTest.php +++ b/api/tests/Feature/TimeSlot/TimeSlotTest.php @@ -234,4 +234,108 @@ class TimeSlotTest extends TestCase $response->assertForbidden(); } + + public function test_include_children_returns_sub_event_time_slots(): void + { + $festival = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'event_type' => 'festival', + ]); + + $subEvent1 = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + ]); + $subEvent2 = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + ]); + + // 2 festival time slots + TimeSlot::factory()->count(2)->create(['event_id' => $festival->id]); + // 3 sub-event 1 time slots + TimeSlot::factory()->count(3)->create(['event_id' => $subEvent1->id]); + // 1 sub-event 2 time slot + TimeSlot::factory()->create(['event_id' => $subEvent2->id]); + + Sanctum::actingAs($this->orgAdmin); + + // Without include_children: only festival's own + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/time-slots"); + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + + // With include_children: festival + all sub-events + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/time-slots?include_children=true"); + $response->assertOk(); + $this->assertCount(6, $response->json('data')); + } + + public function test_include_children_marks_source_and_event_name(): void + { + $festival = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'event_type' => 'festival', + 'name' => 'Test Festival', + ]); + + $subEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + 'name' => 'Day 1', + ]); + + TimeSlot::factory()->create(['event_id' => $festival->id, 'name' => 'Festival Slot']); + TimeSlot::factory()->create(['event_id' => $subEvent->id, 'name' => 'Sub Slot']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/time-slots?include_children=true"); + + $response->assertOk(); + $data = $response->json('data'); + + $festivalSlot = collect($data)->firstWhere('name', 'Festival Slot'); + $subSlot = collect($data)->firstWhere('name', 'Sub Slot'); + + $this->assertEquals('own', $festivalSlot['source']); + $this->assertEquals('Test Festival', $festivalSlot['event_name']); + $this->assertEquals($subEvent->id, $subSlot['source']); + $this->assertEquals('Day 1', $subSlot['event_name']); + } + + public function test_include_children_ignored_for_sub_events(): void + { + $festival = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'event_type' => 'festival', + ]); + + $subEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'parent_event_id' => $festival->id, + ]); + + TimeSlot::factory()->count(2)->create(['event_id' => $festival->id]); + TimeSlot::factory()->count(3)->create(['event_id' => $subEvent->id]); + + Sanctum::actingAs($this->orgAdmin); + + // include_children on a sub-event should have no effect + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}/time-slots?include_children=true"); + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_include_children_ignored_for_flat_events(): void + { + TimeSlot::factory()->count(3)->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->orgAdmin); + + // include_children on a flat event should have no effect + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/time-slots?include_children=true"); + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } } diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 0e0e1649..410c4aa8 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -65,6 +65,7 @@ declare module 'vue' { EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default'] + InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] diff --git a/apps/app/src/components/common/InfoTooltip.vue b/apps/app/src/components/common/InfoTooltip.vue new file mode 100644 index 00000000..d765d558 --- /dev/null +++ b/apps/app/src/components/common/InfoTooltip.vue @@ -0,0 +1,20 @@ + diff --git a/apps/app/src/components/sections/CreateShiftDialog.vue b/apps/app/src/components/sections/CreateShiftDialog.vue index b7890e2a..e9099e60 100644 --- a/apps/app/src/components/sections/CreateShiftDialog.vue +++ b/apps/app/src/components/sections/CreateShiftDialog.vue @@ -2,13 +2,17 @@ import { VForm } from 'vuetify/components/VForm' import { useCreateShift, useUpdateShift } from '@/composables/api/useShifts' import { useTimeSlotList } from '@/composables/api/useTimeSlots' +import { useEventDetail } from '@/composables/api/useEvents' import { useAuthStore } from '@/stores/useAuthStore' +import { useTimeSlotDropdown } from '@/composables/useTimeSlotDropdown' +import InfoTooltip from '@/components/common/InfoTooltip.vue' import { requiredValidator } from '@core/utils/validators' -import type { Shift, ShiftStatus } from '@/types/section' +import type { FestivalSection, Shift, ShiftStatus } from '@/types/section' const props = withDefaults(defineProps<{ eventId: string sectionId: string + section?: FestivalSection | null shift?: Shift | null isSubEvent?: boolean }>(), { @@ -26,9 +30,26 @@ const eventIdRef = computed(() => props.eventId) const sectionIdRef = computed(() => props.sectionId) const isEditing = computed(() => !!props.shift) -const isSubEventRef = computed(() => props.isSubEvent) -const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, { includeParent: isSubEventRef }) +// Get full event detail for hierarchy info +const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef) +const sectionRef = computed(() => props.section ?? null) + +// Determine dropdown scenario +const { scenario, showInfoTooltip, tooltipText, fetchParams, groupTimeSlots } = useTimeSlotDropdown( + eventDetail, + sectionRef, +) + +// Fetch time slots based on scenario +const includeParentRef = computed(() => fetchParams.value.includeParent) +const includeChildrenRef = computed(() => fetchParams.value.includeChildren) + +const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, { + includeParent: includeParentRef, + includeChildren: includeChildrenRef, +}) + const { mutate: createShift, isPending: isCreating } = useCreateShift(orgIdRef, eventIdRef, sectionIdRef) const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(orgIdRef, eventIdRef, sectionIdRef) @@ -74,44 +95,30 @@ watch( { immediate: true }, ) -function formatTimeSlotItem(ts: { id: string; name: string; date: string; start_time: string; end_time: string }) { - return { - title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`, - value: ts.id, - } -} - -const timeSlotItems = computed(() => { +// Group time slots for the dropdown +const flattenedTimeSlots = computed(() => { // While loading, show the current shift's time slot so the dropdown doesn't flash a raw ULID if (!timeSlots.value?.length) { if (props.shift?.time_slot) { - return [formatTimeSlotItem(props.shift.time_slot)] + const ts = props.shift.time_slot + return [{ + id: ts.id, + name: ts.name, + timeRange: `${ts.start_time} – ${ts.end_time}`, + displayLabel: ts.name, + _isGroupHeader: false, + _isDimmed: false, + groupName: '', + }] } return [] } - const hasFestival = timeSlots.value.some(ts => ts.source === 'festival') - if (!hasFestival) { - return timeSlots.value.map(formatTimeSlotItem) - } - - const subEventSlots = timeSlots.value.filter(ts => ts.source !== 'festival') - const festivalSlots = timeSlots.value.filter(ts => ts.source === 'festival') - const items: Array<{ title: string; value?: string; type?: string }> = [] - - if (subEventSlots.length) { - items.push({ title: subEventSlots[0]?.event_name ?? 'Programma', type: 'subheader' }) - items.push(...subEventSlots.map(formatTimeSlotItem)) - } - - if (festivalSlots.length) { - items.push({ title: festivalSlots[0]?.event_name ?? 'Festival', type: 'subheader' }) - items.push(...festivalSlots.map(formatTimeSlotItem)) - } - - return items + return groupTimeSlots(timeSlots.value) }) +const hasTimeSlots = computed(() => flattenedTimeSlots.value.some(i => !i._isGroupHeader)) + const statusOptions = [ { title: 'Concept', value: 'draft' }, { title: 'Open', value: 'open' }, @@ -193,17 +200,75 @@ function onSubmit() { @submit.prevent="onSubmit" > + +
+ + {{ section.name }} + + + festival-breed + +
+
+ + {{ section.name }} · {{ eventDetail?.name }} + +
+ - + > + + + + + + () const orgIdRef = computed(() => authStore.currentOrganisation?.id ?? '') const eventIdRef = computed(() => props.eventId) +// Event detail for context banner +const { data: eventDetail } = useEventDetail(orgIdRef, eventIdRef) + +const isSubEvent = computed(() => props.isSubEvent ?? false) + // --- Section list --- const { data: sectionsQuery, isLoading: sectionsLoading } = useSectionList(orgIdRef, eventIdRef) const { mutate: reorderSections } = useReorderSections(orgIdRef, eventIdRef) @@ -252,7 +261,19 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean; Secties + +

+ Sommige secties zijn festival-breed: ze zijn bij elk + programmaonderdeel actief en worden centraal beheerd. Je herkent ze aan + de festivalnaam achter de sectienaam. +

+
+ Voorbeeld: EHBO en Security zijn festival-breed — ze + staan bij elk programmaonderdeel. +
+
@@ -319,18 +340,15 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean; class="me-1" /> - {{ element.name }} - + · {{ parentEvent.name }} + + @@ -356,6 +374,28 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean; diff --git a/apps/app/src/pages/events/[id]/time-slots/index.vue b/apps/app/src/pages/events/[id]/time-slots/index.vue index 374b5d56..e4bbfbb1 100644 --- a/apps/app/src/pages/events/[id]/time-slots/index.vue +++ b/apps/app/src/pages/events/[id]/time-slots/index.vue @@ -1,9 +1,10 @@