feat: fix time slot hierarchy — seeder, API include_children, frontend dropdown, navigation
Restructure the festival hierarchy end-to-end: Seeder: Remove duplicate festival-level VOLUNTEER time slots, keep only CREW operational slots. Rename sub-events to "Dag 1/2/3 — ..." pattern. Change Nachtsecurity to Security (cross_event). EHBO/Security shifts now use sub-event time slots via cross_event exception. Add flat event "Braderie Dorpstown 2026". API: Add ?include_children=true to TimeSlotController for festivals, returning all sub-event time slots with source and event_name fields. Update StoreShiftRequest and UpdateShiftRequest to accept child time slots for cross_event sections. Frontend: Create useTimeSlotDropdown composable with 4-scenario dropdown logic. Replace AppSelect with VAutocomplete in CreateShiftDialog with grouped items, dimmed festival slots, and info tooltips. Add InfoTooltip reusable component. Show festival context labels on cross_event sections in sub-event section lists. Add read-only festival time slots on sub-event time-slots page. Add cross_event context banner with "Bekijk alle diensten" link. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -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']
|
||||
|
||||
20
apps/app/src/components/common/InfoTooltip.vue
Normal file
20
apps/app/src/components/common/InfoTooltip.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<VTooltip
|
||||
location="bottom"
|
||||
max-width="320"
|
||||
open-on-click
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VIcon
|
||||
v-bind="tooltipProps"
|
||||
icon="tabler-info-circle"
|
||||
size="16"
|
||||
color="medium-emphasis"
|
||||
class="cursor-help"
|
||||
/>
|
||||
</template>
|
||||
<div class="text-body-2">
|
||||
<slot />
|
||||
</div>
|
||||
</VTooltip>
|
||||
</template>
|
||||
@@ -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"
|
||||
>
|
||||
<VCardText>
|
||||
<!-- Cross_event section context indicator -->
|
||||
<div
|
||||
v-if="section?.type === 'cross_event'"
|
||||
class="mb-4"
|
||||
>
|
||||
<span class="text-body-2 text-medium-emphasis">
|
||||
{{ section.name }}
|
||||
</span>
|
||||
<VChip
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
>
|
||||
festival-breed
|
||||
</VChip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isSubEvent && section"
|
||||
class="mb-4"
|
||||
>
|
||||
<span class="text-body-2 text-medium-emphasis">
|
||||
{{ section.name }} · {{ eventDetail?.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppSelect
|
||||
v-if="timeSlotsLoading || timeSlotItems.length"
|
||||
<VAutocomplete
|
||||
v-if="timeSlotsLoading || hasTimeSlots"
|
||||
v-model="form.time_slot_id"
|
||||
label="Time Slot"
|
||||
:items="timeSlotItems"
|
||||
:items="flattenedTimeSlots.filter(i => !i._isGroupHeader)"
|
||||
item-title="displayLabel"
|
||||
item-value="id"
|
||||
label="Tijdslot"
|
||||
:loading="timeSlotsLoading"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.time_slot_id"
|
||||
/>
|
||||
>
|
||||
<template
|
||||
v-if="showInfoTooltip"
|
||||
#prepend-inner
|
||||
>
|
||||
<InfoTooltip>
|
||||
<p>{{ tooltipText?.main }}</p>
|
||||
<div class="mt-2 pa-2 bg-surface rounded text-caption">
|
||||
<strong>Tip:</strong> {{ tooltipText?.tip }}
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
</template>
|
||||
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListItem
|
||||
v-if="!item.raw._isGroupHeader"
|
||||
v-bind="itemProps"
|
||||
:class="{ 'opacity-65': item.raw._isDimmed }"
|
||||
>
|
||||
<template #append>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ item.raw.timeRange }}
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
<template #selection="{ item }">
|
||||
{{ item.raw.name }} · {{ item.raw.timeRange }}
|
||||
</template>
|
||||
</VAutocomplete>
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import draggable from 'vuedraggable'
|
||||
import { useSectionList, useDeleteSection, useReorderSections } from '@/composables/api/useSections'
|
||||
import { useShiftList, useDeleteShift } from '@/composables/api/useShifts'
|
||||
import { useEventDetail } from '@/composables/api/useEvents'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue'
|
||||
import EditSectionDialog from '@/components/sections/EditSectionDialog.vue'
|
||||
@@ -9,7 +10,9 @@ import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue'
|
||||
import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue'
|
||||
import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue'
|
||||
import { useShiftDetailStore } from '@/stores/useShiftDetailStore'
|
||||
import InfoTooltip from '@/components/common/InfoTooltip.vue'
|
||||
import type { FestivalSection, Shift, ShiftStatus } from '@/types/section'
|
||||
import type { EventItem } from '@/types/event'
|
||||
|
||||
const shiftDetailStore = useShiftDetailStore()
|
||||
const authStore = useAuthStore()
|
||||
@@ -17,11 +20,17 @@ const authStore = useAuthStore()
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
isSubEvent?: boolean
|
||||
parentEvent?: EventItem | null
|
||||
}>()
|
||||
|
||||
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;
|
||||
<span>Secties</span>
|
||||
|
||||
<!-- Contextual help tooltip -->
|
||||
<InfoTooltip v-if="isSubEvent && parentEvent">
|
||||
<p>
|
||||
Sommige secties zijn <strong>festival-breed</strong>: ze zijn bij elk
|
||||
programmaonderdeel actief en worden centraal beheerd. Je herkent ze aan
|
||||
de festivalnaam achter de sectienaam.
|
||||
</p>
|
||||
<div class="mt-2 pa-2 bg-surface rounded text-caption">
|
||||
<strong>Voorbeeld:</strong> EHBO en Security zijn festival-breed — ze
|
||||
staan bij elk programmaonderdeel.
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
<VTooltip
|
||||
v-else
|
||||
location="bottom"
|
||||
max-width="300"
|
||||
>
|
||||
@@ -319,18 +340,15 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
||||
class="me-1"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>{{ element.name }}</VListItemTitle>
|
||||
<template #append>
|
||||
<VChip
|
||||
v-if="element.type === 'cross_event'"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
class="me-1"
|
||||
<VListItemTitle>
|
||||
<span>{{ element.name }}</span>
|
||||
<span
|
||||
v-if="element.type === 'cross_event' && parentEvent"
|
||||
class="text-caption text-medium-emphasis ml-1"
|
||||
>
|
||||
Overkoepelend
|
||||
</VChip>
|
||||
</template>
|
||||
· {{ parentEvent.name }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -356,6 +374,28 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
||||
|
||||
<!-- Section selected -->
|
||||
<template v-else>
|
||||
<!-- Context banner for cross_event sections viewed from sub-event -->
|
||||
<VAlert
|
||||
v-if="activeSection.type === 'cross_event' && isSubEvent && parentEvent"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="d-flex justify-space-between align-center flex-wrap gap-2">
|
||||
<span>
|
||||
Je bekijkt {{ activeSection.name }} vanuit
|
||||
<strong>{{ eventDetail?.name }}</strong>
|
||||
</span>
|
||||
<RouterLink
|
||||
:to="{ name: 'events-id-sections', params: { id: activeSection.event_id } }"
|
||||
class="text-caption"
|
||||
>
|
||||
Bekijk alle diensten
|
||||
</RouterLink>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- Header -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||||
@@ -370,8 +410,9 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
||||
v-if="activeSection.type === 'cross_event'"
|
||||
size="small"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
>
|
||||
Overkoepelend
|
||||
festival-breed
|
||||
</VChip>
|
||||
<span
|
||||
v-if="activeSection.crew_need"
|
||||
@@ -600,6 +641,7 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
|
||||
v-model="isCreateShiftOpen"
|
||||
:event-id="activeSectionEventId"
|
||||
:section-id="activeSection.id"
|
||||
:section="activeSection"
|
||||
:shift="editingShift"
|
||||
:is-sub-event="isSubEvent"
|
||||
/>
|
||||
|
||||
@@ -13,13 +13,17 @@ interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
}
|
||||
|
||||
export function useTimeSlotList(orgId: Ref<string>, eventId: Ref<string>, options?: { includeParent?: Ref<boolean> }) {
|
||||
export function useTimeSlotList(orgId: Ref<string>, eventId: Ref<string>, options?: { includeParent?: Ref<boolean>; includeChildren?: Ref<boolean> }) {
|
||||
const includeParent = options?.includeParent
|
||||
const includeChildren = options?.includeChildren
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['time-slots', eventId, includeParent],
|
||||
queryKey: ['time-slots', eventId, includeParent, includeChildren],
|
||||
queryFn: async () => {
|
||||
const params = includeParent?.value ? { include_parent: 'true' } : {}
|
||||
const params: Record<string, string> = {}
|
||||
if (includeParent?.value) params.include_parent = 'true'
|
||||
if (includeChildren?.value) params.include_children = 'true'
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<TimeSlot>>(
|
||||
`/organisations/${orgId.value}/events/${eventId.value}/time-slots`,
|
||||
{ params },
|
||||
|
||||
161
apps/app/src/composables/useTimeSlotDropdown.ts
Normal file
161
apps/app/src/composables/useTimeSlotDropdown.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { EventItem } from '@/types/event'
|
||||
import type { FestivalSection } from '@/types/section'
|
||||
import type { TimeSlot } from '@/types/timeSlot'
|
||||
|
||||
export type DropdownScenario = 'flat' | 'sub_event_standard' | 'cross_event' | 'festival_standard'
|
||||
|
||||
interface TimeSlotDropdownItem {
|
||||
id: string
|
||||
name: string
|
||||
timeRange: string
|
||||
displayLabel: string
|
||||
_isGroupHeader: boolean
|
||||
_isDimmed: boolean
|
||||
groupName: string
|
||||
}
|
||||
|
||||
interface TooltipContent {
|
||||
main: string
|
||||
tip: string
|
||||
}
|
||||
|
||||
export function useTimeSlotDropdown(
|
||||
event: Ref<EventItem | null | undefined>,
|
||||
section: Ref<FestivalSection | null | undefined>,
|
||||
) {
|
||||
const scenario = computed<DropdownScenario>(() => {
|
||||
if (!event.value) return 'flat'
|
||||
|
||||
const isSubEvent = !!event.value.parent_event_id
|
||||
const hasChildren = event.value.has_children
|
||||
const isCrossEvent = section.value?.type === 'cross_event'
|
||||
|
||||
// Flat event — no hierarchy
|
||||
if (!isSubEvent && !hasChildren) return 'flat'
|
||||
|
||||
// Cross_event section — needs all time slots
|
||||
if (isCrossEvent) return 'cross_event'
|
||||
|
||||
// Standard section on sub-event — own + parent
|
||||
if (isSubEvent) return 'sub_event_standard'
|
||||
|
||||
// Standard section on festival level — own only
|
||||
return 'festival_standard'
|
||||
})
|
||||
|
||||
const showInfoTooltip = computed(() => scenario.value !== 'flat')
|
||||
|
||||
const tooltipText = computed<TooltipContent | null>(() => {
|
||||
const eventName = event.value?.name ?? ''
|
||||
const sectionName = section.value?.name ?? ''
|
||||
const festivalName = event.value?.parent?.name ?? eventName
|
||||
|
||||
switch (scenario.value) {
|
||||
case 'sub_event_standard':
|
||||
return {
|
||||
main: `Kies het tijdvenster voor deze dienst. Je ziet de tijdsloten van ${eventName} en de algemene tijdsloten van ${festivalName}.`,
|
||||
tip: `Voor een reguliere dienst kies je een tijdslot van ${eventName}. Voor een opbouw- of afbraakdienst kies je een festivaltijdslot.`,
|
||||
}
|
||||
case 'cross_event':
|
||||
return {
|
||||
main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`,
|
||||
tip: `Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).`,
|
||||
}
|
||||
case 'festival_standard':
|
||||
return {
|
||||
main: `Kies een tijdslot van ${eventName} voor deze dienst.`,
|
||||
tip: `Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.`,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const fetchParams = computed(() => {
|
||||
switch (scenario.value) {
|
||||
case 'flat':
|
||||
case 'festival_standard':
|
||||
return { includeParent: false, includeChildren: false }
|
||||
|
||||
case 'sub_event_standard':
|
||||
return { includeParent: true, includeChildren: false }
|
||||
|
||||
case 'cross_event':
|
||||
return { includeParent: false, includeChildren: true }
|
||||
}
|
||||
})
|
||||
|
||||
function groupTimeSlots(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
|
||||
if (scenario.value === 'flat') {
|
||||
return timeSlots.map(ts => toDropdownItem(ts, false))
|
||||
}
|
||||
|
||||
// Group by event_name
|
||||
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
|
||||
for (const ts of timeSlots) {
|
||||
const key = ts.event_name ?? 'Onbekend'
|
||||
if (!groups.has(key)) {
|
||||
const isOwn = ts.source === 'own' || ts.source === 'sub_event' || ts.source === 'festival'
|
||||
? (scenario.value === 'sub_event_standard'
|
||||
? ts.source === 'sub_event'
|
||||
: ts.source === 'own')
|
||||
: false
|
||||
groups.set(key, { slots: [], isOwn })
|
||||
}
|
||||
groups.get(key)!.slots.push(ts)
|
||||
}
|
||||
|
||||
const items: TimeSlotDropdownItem[] = []
|
||||
|
||||
// Own group first, then others
|
||||
const sortedGroups = [...groups.entries()].sort(([, a], [, b]) => {
|
||||
if (a.isOwn && !b.isOwn) return -1
|
||||
if (!a.isOwn && b.isOwn) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const [groupName, { slots, isOwn }] of sortedGroups) {
|
||||
// Add group header
|
||||
items.push({
|
||||
id: `header-${groupName}`,
|
||||
name: groupName,
|
||||
timeRange: '',
|
||||
displayLabel: groupName,
|
||||
_isGroupHeader: true,
|
||||
_isDimmed: false,
|
||||
groupName,
|
||||
})
|
||||
|
||||
// Determine if slots should be dimmed
|
||||
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
|
||||
|
||||
for (const ts of slots) {
|
||||
items.push(toDropdownItem(ts, isDimmed))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
return {
|
||||
scenario,
|
||||
showInfoTooltip,
|
||||
tooltipText,
|
||||
fetchParams,
|
||||
groupTimeSlots,
|
||||
}
|
||||
}
|
||||
|
||||
function toDropdownItem(ts: TimeSlot, isDimmed: boolean): TimeSlotDropdownItem {
|
||||
const timeRange = `${ts.start_time} – ${ts.end_time}`
|
||||
return {
|
||||
id: ts.id,
|
||||
name: ts.name,
|
||||
timeRange,
|
||||
displayLabel: ts.name,
|
||||
_isGroupHeader: false,
|
||||
_isDimmed: isDimmed,
|
||||
groupName: ts.event_name ?? '',
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const eventId = computed(() => String((route.params as { id: string }).id))
|
||||
<SectionsShiftsPanel
|
||||
:event-id="eventId"
|
||||
:is-sub-event="event?.is_sub_event ?? false"
|
||||
:parent-event="event?.parent ?? null"
|
||||
/>
|
||||
</template>
|
||||
</EventTabsNav>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeSlotList, useDeleteTimeSlot } from '@/composables/api/useTimeSlots'
|
||||
import { useEventChildren } from '@/composables/api/useEvents'
|
||||
import { useEventChildren, useEventDetail } from '@/composables/api/useEvents'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import CreateTimeSlotDialog from '@/components/sections/CreateTimeSlotDialog.vue'
|
||||
import InfoTooltip from '@/components/common/InfoTooltip.vue'
|
||||
import { PersonType } from '@/types/timeSlot'
|
||||
import type { TimeSlot, PersonType as PersonTypeValue } from '@/types/timeSlot'
|
||||
import type { EventItem } from '@/types/event'
|
||||
@@ -117,6 +118,29 @@ function getTimeSlotContext(ts: TimeSlot): { label: string; color: string } | nu
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Festival time slots (read-only, for sub-events) ---
|
||||
const { data: eventDetail } = useEventDetail(orgId, eventId)
|
||||
|
||||
const parentEventId = computed(() => eventDetail.value?.parent_event_id ?? '')
|
||||
const isSubEvent = computed(() => eventDetail.value?.is_sub_event ?? false)
|
||||
const parentEvent = computed(() => eventDetail.value?.parent ?? null)
|
||||
|
||||
const { data: festivalTimeSlots } = useTimeSlotList(orgId, parentEventId, {
|
||||
includeParent: computed(() => false),
|
||||
includeChildren: computed(() => false),
|
||||
})
|
||||
|
||||
const festivalTimeSlotsGrouped = computed(() => {
|
||||
if (!festivalTimeSlots.value?.length) return new Map<string, TimeSlot[]>()
|
||||
const groups = new Map<string, TimeSlot[]>()
|
||||
for (const slot of festivalTimeSlots.value) {
|
||||
const existing = groups.get(slot.date) || []
|
||||
existing.push(slot)
|
||||
groups.set(slot.date, existing)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
// --- Fill rate ---
|
||||
function fillRatePercent(ts: TimeSlot): number {
|
||||
if (ts.total_slots === 0) return 0
|
||||
@@ -411,6 +435,79 @@ function onDeleteExecute() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Festival time slots (read-only, sub-events only) -->
|
||||
<template v-if="isSubEvent && parentEvent && festivalTimeSlots?.length">
|
||||
<VDivider
|
||||
class="my-6"
|
||||
style="border-style: dashed;"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center gap-2 mb-3">
|
||||
<VIcon
|
||||
icon="tabler-lock"
|
||||
size="14"
|
||||
color="medium-emphasis"
|
||||
/>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ parentEvent.name }} — alleen-lezen
|
||||
</span>
|
||||
<InfoTooltip>
|
||||
<p>
|
||||
Tijdsloten van <strong>{{ parentEvent.name }}</strong> gelden
|
||||
festival-breed en worden daar beheerd.
|
||||
</p>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="[date, slots] in festivalTimeSlotsGrouped"
|
||||
:key="`festival-${date}`"
|
||||
class="mb-4 opacity-55"
|
||||
>
|
||||
<div class="d-flex align-center gap-x-2 mb-2">
|
||||
<h6 class="text-subtitle-1">
|
||||
{{ formatGroupDate(date) }}
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<VCard variant="outlined">
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="ts in slots"
|
||||
:key="ts.id"
|
||||
>
|
||||
<div class="d-flex align-center gap-x-4 py-2 flex-wrap">
|
||||
<div style="min-inline-size: 200px;">
|
||||
<span class="text-body-1 font-weight-medium">
|
||||
{{ ts.name }}
|
||||
</span>
|
||||
<div class="text-body-2 text-disabled">
|
||||
{{ ts.start_time }} — {{ ts.end_time }}
|
||||
<template v-if="ts.duration_hours">
|
||||
· {{ formatDuration(ts) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<VChip
|
||||
size="small"
|
||||
:color="personTypeColor[ts.person_type]"
|
||||
>
|
||||
{{ personTypeLabel[ts.person_type] }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'events-id-time-slots', params: { id: parentEvent.id } }"
|
||||
class="text-caption text-info mt-2 d-inline-block"
|
||||
>
|
||||
Beheer tijdsloten van {{ parentEvent.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<!-- Create/Edit dialog -->
|
||||
<CreateTimeSlotDialog
|
||||
v-model="isCreateDialogOpen"
|
||||
|
||||
@@ -8,7 +8,7 @@ export const PersonType = {
|
||||
|
||||
export type PersonType = (typeof PersonType)[keyof typeof PersonType]
|
||||
|
||||
export type TimeSlotSource = 'sub_event' | 'festival'
|
||||
export type TimeSlotSource = 'sub_event' | 'festival' | 'own' | string
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string
|
||||
|
||||
@@ -166,9 +166,15 @@ Auth: org_admin or event_manager on the event's organisation.
|
||||
> and includes `event_name` for display grouping. This parameter has no effect on festivals
|
||||
> or flat events.
|
||||
>
|
||||
> For festivals (events with sub-events), pass `?include_children=true` to include all
|
||||
> sub-event time slots. Each time slot is marked with `source` (`own` or the sub-event's
|
||||
> `event_id`) and `event_name`. Used by cross_event section shift dialogs that need access
|
||||
> to all time slots across the festival. This parameter has no effect on sub-events or
|
||||
> flat events.
|
||||
>
|
||||
> Shifts on sub-event sections may reference parent festival time slots (e.g. for build-up
|
||||
> shifts). The `time_slot_id` validation accepts time slots from the sub-event itself or
|
||||
> its parent festival.
|
||||
> shifts). Shifts on cross_event sections may reference any time slot from the festival or
|
||||
> its sub-events. The `time_slot_id` validation accepts time slots accordingly.
|
||||
|
||||
## Shifts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user