diff --git a/api/app/Http/Controllers/Api/V1/TimeSlotController.php b/api/app/Http/Controllers/Api/V1/TimeSlotController.php index 3582672..7c4c735 100644 --- a/api/app/Http/Controllers/Api/V1/TimeSlotController.php +++ b/api/app/Http/Controllers/Api/V1/TimeSlotController.php @@ -22,6 +22,22 @@ final class TimeSlotController extends Controller $timeSlots = $event->timeSlots()->orderBy('date')->orderBy('start_time')->get(); + if ($event->isSubEvent() && request()->boolean('include_parent') && $event->parent_event_id) { + $parentTimeSlots = TimeSlot::where('event_id', $event->parent_event_id) + ->with('event') + ->orderBy('date') + ->orderBy('start_time') + ->get(); + + $timeSlots->load('event'); + $timeSlots->each(fn (TimeSlot $ts) => $ts->setAttribute('source', 'sub_event')); + $parentTimeSlots->each(fn (TimeSlot $ts) => $ts->setAttribute('source', 'festival')); + + $timeSlots = $timeSlots->merge($parentTimeSlots) + ->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 0fc9309..1379bc6 100644 --- a/api/app/Http/Requests/Api/V1/StoreShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreShiftRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class StoreShiftRequest extends FormRequest { @@ -17,7 +18,16 @@ final class StoreShiftRequest extends FormRequest public function rules(): array { return [ - 'time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], + 'time_slot_id' => ['required', 'ulid', Rule::exists('time_slots', 'id')->where(function ($query) { + $event = $this->route('event'); + $eventIds = [$event->id]; + + if ($event->isSubEvent() && $event->parent_event_id) { + $eventIds[] = $event->parent_event_id; + } + + $query->whereIn('event_id', $eventIds); + })], 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], 'title' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], @@ -28,8 +38,10 @@ final class StoreShiftRequest extends FormRequest 'report_time' => ['nullable', 'date_format:H:i'], 'actual_start_time' => ['nullable', 'date_format:H:i'], 'actual_end_time' => ['nullable', 'date_format:H:i'], + 'end_date' => ['nullable', 'date'], 'is_lead_role' => ['nullable', 'boolean'], 'allow_overlap' => ['nullable', 'boolean'], + 'events_during_shift' => ['nullable', 'array'], 'status' => ['nullable', 'in:draft,open,full,in_progress,completed,cancelled'], ]; } diff --git a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php index 1c1ffbe..dcf9691 100644 --- a/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateShiftRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdateShiftRequest extends FormRequest { @@ -17,7 +18,16 @@ final class UpdateShiftRequest extends FormRequest public function rules(): array { return [ - 'time_slot_id' => ['sometimes', 'ulid', 'exists:time_slots,id'], + 'time_slot_id' => ['sometimes', 'ulid', Rule::exists('time_slots', 'id')->where(function ($query) { + $event = $this->route('event'); + $eventIds = [$event->id]; + + if ($event->isSubEvent() && $event->parent_event_id) { + $eventIds[] = $event->parent_event_id; + } + + $query->whereIn('event_id', $eventIds); + })], 'location_id' => ['nullable', 'ulid', 'exists:locations,id'], 'title' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], @@ -28,8 +38,10 @@ final class UpdateShiftRequest extends FormRequest 'report_time' => ['nullable', 'date_format:H:i'], 'actual_start_time' => ['nullable', 'date_format:H:i'], 'actual_end_time' => ['nullable', 'date_format:H:i'], + 'end_date' => ['nullable', 'date'], 'is_lead_role' => ['nullable', 'boolean'], 'allow_overlap' => ['nullable', 'boolean'], + 'events_during_shift' => ['nullable', 'array'], 'status' => ['sometimes', 'in:draft,open,full,in_progress,completed,cancelled'], ]; } diff --git a/api/app/Http/Resources/Api/V1/TimeSlotResource.php b/api/app/Http/Resources/Api/V1/TimeSlotResource.php index f832b02..3612860 100644 --- a/api/app/Http/Resources/Api/V1/TimeSlotResource.php +++ b/api/app/Http/Resources/Api/V1/TimeSlotResource.php @@ -6,6 +6,7 @@ namespace App\Http\Resources\Api\V1; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; final class TimeSlotResource extends JsonResource { @@ -17,9 +18,11 @@ final class TimeSlotResource extends JsonResource 'name' => $this->name, 'person_type' => $this->person_type, 'date' => $this->date->toDateString(), - 'start_time' => $this->start_time, - 'end_time' => $this->end_time, + 'start_time' => Carbon::parse($this->start_time)->format('H:i'), + 'end_time' => Carbon::parse($this->end_time)->format('H:i'), 'duration_hours' => $this->duration_hours, + 'source' => $this->resource->getAttribute('source'), + 'event_name' => $this->whenLoaded('event', fn () => $this->event->name), 'created_at' => $this->created_at->toIso8601String(), ]; } diff --git a/api/tests/Feature/TimeSlot/TimeSlotTest.php b/api/tests/Feature/TimeSlot/TimeSlotTest.php index b6cf4d2..a5c584b 100644 --- a/api/tests/Feature/TimeSlot/TimeSlotTest.php +++ b/api/tests/Feature/TimeSlot/TimeSlotTest.php @@ -126,6 +126,30 @@ class TimeSlotTest extends TestCase ->assertJson(['data' => ['name' => 'Vrijdag Avond Updated']]); } + public function test_update_cross_org_returns_403(): void + { + $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->outsider); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/time-slots/{$timeSlot->id}", [ + 'name' => 'Hacked', + ]); + + $response->assertForbidden(); + } + + public function test_destroy_cross_org_returns_403(): void + { + $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->outsider); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/time-slots/{$timeSlot->id}"); + + $response->assertForbidden(); + } + public function test_destroy_deletes_time_slot(): void { $timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); diff --git a/apps/app/src/components/sections/CreateShiftDialog.vue b/apps/app/src/components/sections/CreateShiftDialog.vue index 665841c..e154e58 100644 --- a/apps/app/src/components/sections/CreateShiftDialog.vue +++ b/apps/app/src/components/sections/CreateShiftDialog.vue @@ -5,10 +5,17 @@ import { useTimeSlotList } from '@/composables/api/useTimeSlots' import { requiredValidator } from '@core/utils/validators' import type { Shift, ShiftStatus } from '@/types/section' -const props = defineProps<{ +const props = withDefaults(defineProps<{ eventId: string sectionId: string shift?: Shift | null + isSubEvent?: boolean +}>(), { + isSubEvent: false, +}) + +const emit = defineEmits<{ + openTimeSlots: [] }>() const modelValue = defineModel({ required: true }) @@ -17,8 +24,9 @@ const eventIdRef = computed(() => props.eventId) const sectionIdRef = computed(() => props.sectionId) const isEditing = computed(() => !!props.shift) +const isSubEventRef = computed(() => props.isSubEvent) -const { data: timeSlots } = useTimeSlotList(eventIdRef) +const { data: timeSlots } = useTimeSlotList(eventIdRef, { includeParent: isSubEventRef }) const { mutate: createShift, isPending: isCreating } = useCreateShift(eventIdRef, sectionIdRef) const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(eventIdRef, sectionIdRef) @@ -64,12 +72,39 @@ watch( { immediate: true }, ) -const timeSlotItems = computed(() => - timeSlots.value?.map(ts => ({ - title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`, - value: ts.id, - })) ?? [], -) +const timeSlotItems = computed(() => { + if (!timeSlots.value?.length) return [] + + const hasFestival = timeSlots.value.some(ts => ts.source === 'festival') + if (!hasFestival) { + return timeSlots.value.map(ts => ({ + title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`, + value: ts.id, + })) + } + + 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(ts => ({ + title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`, + value: ts.id, + }))) + } + + if (festivalSlots.length) { + items.push({ title: festivalSlots[0]?.event_name ?? 'Festival', type: 'subheader' }) + items.push(...festivalSlots.map(ts => ({ + title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`, + value: ts.id, + }))) + } + + return items +}) const statusOptions = [ { title: 'Concept', value: 'draft' }, @@ -148,20 +183,39 @@ function onSubmit() { @after-leave="!isEditing && resetForm()" > - - + + + +
+ Maak eerst een time slot aan + + Time Slots beheren + +
+
@@ -264,24 +320,24 @@ function onSubmit() { />
-
-
- - - - Annuleren - - - {{ isEditing ? 'Opslaan' : 'Toevoegen' }} - - + + + + + Annuleren + + + {{ isEditing ? 'Opslaan' : 'Toevoegen' }} + + +
diff --git a/apps/app/src/composables/api/useTimeSlots.ts b/apps/app/src/composables/api/useTimeSlots.ts index 869b438..5a2ad07 100644 --- a/apps/app/src/composables/api/useTimeSlots.ts +++ b/apps/app/src/composables/api/useTimeSlots.ts @@ -13,12 +13,16 @@ interface PaginatedResponse { data: T[] } -export function useTimeSlotList(eventId: Ref) { +export function useTimeSlotList(eventId: Ref, options?: { includeParent?: Ref }) { + const includeParent = options?.includeParent + return useQuery({ - queryKey: ['time-slots', eventId], + queryKey: ['time-slots', eventId, includeParent], queryFn: async () => { + const params = includeParent?.value ? { include_parent: 'true' } : {} const { data } = await apiClient.get>( `/events/${eventId.value}/time-slots`, + { params }, ) return data.data