From 968e17c6d64f1950d3bcb1bd2582ec5daf63c4d2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 20:32:31 +0200 Subject: [PATCH] feat: smart assign person dialog with conflict details and assignable-persons endpoint Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that returns approved persons with availability status, conflict details, and already-assigned flags. Improve ShiftAssignmentService conflict errors to include section name, time slot, and time range. Replace both assign dialogs with a new AssignPersonDialog featuring search, crowd type filtering, availability toggle, and inline conflict warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Api/V1/ShiftAssignmentController.php | 71 ++ api/app/Services/ShiftAssignmentService.php | 22 +- api/routes/api.php | 1 + .../Feature/Api/V1/AssignablePersonsTest.php | 296 +++++++ .../sections/SectionsShiftsPanel.vue | 59 +- .../components/shifts/AssignPersonDialog.vue | 351 ++++++++ .../components/shifts/ShiftDetailPanel.vue | 817 ++++++++++++++++++ .../composables/api/useShiftAssignments.ts | 161 ++++ apps/app/src/types/shiftAssignment.ts | 67 ++ dev-docs/API.md | 40 + 10 files changed, 1872 insertions(+), 13 deletions(-) create mode 100644 api/tests/Feature/Api/V1/AssignablePersonsTest.php create mode 100644 apps/app/src/components/shifts/AssignPersonDialog.vue create mode 100644 apps/app/src/components/shifts/ShiftDetailPanel.vue create mode 100644 apps/app/src/composables/api/useShiftAssignments.ts create mode 100644 apps/app/src/types/shiftAssignment.ts diff --git a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php index 7708ffab..66141a68 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\PersonStatus; use App\Enums\ShiftAssignmentStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\BulkApproveShiftAssignmentRequest; use App\Http\Requests\Api\V1\RejectShiftAssignmentRequest; use App\Http\Resources\Api\V1\ShiftAssignmentResource; use App\Models\Event; +use App\Models\Person; +use App\Models\Shift; use App\Models\ShiftAssignment; use App\Services\ShiftAssignmentService; use Illuminate\Http\JsonResponse; @@ -98,4 +101,72 @@ final class ShiftAssignmentController extends Controller return $this->success($results); } + + public function assignablePersons(Event $event, Shift $shift): JsonResponse + { + Gate::authorize('viewAny', [ShiftAssignment::class, $event]); + + $festivalEventId = $event->parent_event_id ?? $event->id; + $timeSlotId = $shift->time_slot_id; + $shiftId = $shift->id; + + // Get all conflict assignments for this time slot in one query + $conflicts = ShiftAssignment::where('time_slot_id', $timeSlotId) + ->whereIn('status', [ + ShiftAssignmentStatus::PENDING_APPROVAL, + ShiftAssignmentStatus::APPROVED, + ]) + ->with(['shift.festivalSection', 'shift.timeSlot']) + ->get() + ->keyBy('person_id'); + + // Get all assignments for THIS shift in one query + $alreadyAssigned = ShiftAssignment::where('shift_id', $shiftId) + ->whereNotIn('status', [ + ShiftAssignmentStatus::REJECTED, + ShiftAssignmentStatus::CANCELLED, + ]) + ->pluck('person_id') + ->flip(); + + $persons = Person::where('event_id', $festivalEventId) + ->where('status', PersonStatus::APPROVED) + ->with('crowdType') + ->orderBy('name') + ->get() + ->map(function (Person $person) use ($conflicts, $alreadyAssigned, $shiftId) { + $isAlreadyAssigned = $alreadyAssigned->has($person->id); + $conflict = $conflicts->get($person->id); + $hasConflict = $conflict && $conflict->shift_id !== $shiftId; + + return [ + 'id' => $person->id, + 'name' => $person->name, + 'email' => $person->email, + 'status' => $person->status, + 'crowd_type' => $person->crowdType ? [ + 'id' => $person->crowdType->id, + 'name' => $person->crowdType->name, + 'system_type' => $person->crowdType->system_type, + ] : null, + 'is_available' => ! $hasConflict && ! $isAlreadyAssigned, + 'already_assigned' => $isAlreadyAssigned, + 'conflict' => $hasConflict ? [ + 'section_name' => $conflict->shift->festivalSection->name, + 'shift_title' => $conflict->shift->title ?? $conflict->shift->festivalSection->name, + 'time_slot_name' => $conflict->shift->timeSlot->name, + 'time' => $conflict->shift->timeSlot->start_time + . '–' . $conflict->shift->timeSlot->end_time, + ] : null, + ]; + }) + ->sortBy([ + ['already_assigned', 'asc'], + ['is_available', 'desc'], + ['name', 'asc'], + ]) + ->values(); + + return response()->json(['data' => $persons]); + } } diff --git a/api/app/Services/ShiftAssignmentService.php b/api/app/Services/ShiftAssignmentService.php index dcadbe68..47281a80 100644 --- a/api/app/Services/ShiftAssignmentService.php +++ b/api/app/Services/ShiftAssignmentService.php @@ -24,7 +24,7 @@ final class ShiftAssignmentService $this->validateShiftIsOpen($shift); $this->validatePersonApproved($person); $this->validateClaimCapacity($shift); - $this->validateNoConflict($shift, $person); + $this->validateNoConflict($shift, $person, isClaim: true); $autoApprove = $shift->festivalSection->crew_auto_accepts; $status = $autoApprove @@ -323,20 +323,30 @@ final class ShiftAssignmentService /** * @throws ValidationException */ - private function validateNoConflict(Shift $shift, Person $person): void + private function validateNoConflict(Shift $shift, Person $person, bool $isClaim = false): void { if ($shift->allow_overlap) { return; } - $conflict = ShiftAssignment::where('person_id', $person->id) + $existingAssignment = ShiftAssignment::where('person_id', $person->id) ->where('time_slot_id', $shift->time_slot_id) ->active() - ->exists(); + ->with(['shift.festivalSection', 'shift.timeSlot']) + ->first(); + + if ($existingAssignment) { + $section = $existingAssignment->shift->festivalSection->name; + $timeSlot = $existingAssignment->shift->timeSlot->name; + $time = $existingAssignment->shift->timeSlot->start_time + . '–' . $existingAssignment->shift->timeSlot->end_time; + + $message = $isClaim + ? "Je bent al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})." + : "Deze persoon is al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})."; - if ($conflict) { throw ValidationException::withMessages([ - 'person_id' => ['Deze persoon is al ingepland voor dit tijdslot.'], + 'person_id' => [$message], ]); } } diff --git a/api/routes/api.php b/api/routes/api.php index ba920e05..7ebedfc7 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -147,6 +147,7 @@ Route::middleware('auth:sanctum')->group(function () { // Shift assignments (event-level) Route::get('shift-assignments', [ShiftAssignmentController::class, 'index']); + Route::get('shifts/{shift}/assignable-persons', [ShiftAssignmentController::class, 'assignablePersons']); Route::post('shift-assignments/{shiftAssignment}/approve', [ShiftAssignmentController::class, 'approve']); Route::post('shift-assignments/{shiftAssignment}/reject', [ShiftAssignmentController::class, 'reject']); Route::post('shift-assignments/{shiftAssignment}/cancel', [ShiftAssignmentController::class, 'cancel']); diff --git a/api/tests/Feature/Api/V1/AssignablePersonsTest.php b/api/tests/Feature/Api/V1/AssignablePersonsTest.php new file mode 100644 index 00000000..5b91874d --- /dev/null +++ b/api/tests/Feature/Api/V1/AssignablePersonsTest.php @@ -0,0 +1,296 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + + $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $this->section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + $this->otherSection = FestivalSection::factory()->create(['event_id' => $this->event->id]); + $this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + private function createOpenShift(array $overrides = []): Shift + { + return Shift::factory()->open()->create(array_merge([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + 'slots_total' => 4, + 'slots_open_for_claiming' => 3, + ], $overrides)); + } + + private function createPerson(array $overrides = []): Person + { + return Person::factory()->approved()->create(array_merge([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + ], $overrides)); + } + + // ========================================================================= + // Assignable persons endpoint + // ========================================================================= + + public function test_assignable_persons_returns_available_persons(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $person->id) + ->assertJsonPath('data.0.is_available', true) + ->assertJsonPath('data.0.already_assigned', false) + ->assertJsonPath('data.0.conflict', null); + } + + public function test_assignable_persons_shows_conflict_details(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]); + $person = $this->createPerson(); + + // Assign person to shift1 (same time slot) + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift2->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.is_available', false) + ->assertJsonPath('data.0.already_assigned', false) + ->assertJsonPath('data.0.conflict.section_name', $this->section->name) + ->assertJsonPath('data.0.conflict.time_slot_name', $this->timeSlot->name); + } + + public function test_assignable_persons_shows_already_assigned(): void + { + $shift = $this->createOpenShift(); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.already_assigned', true) + ->assertJsonPath('data.0.is_available', false); + } + + public function test_assignable_persons_excludes_non_approved_persons(): void + { + $shift = $this->createOpenShift(); + Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonCount(0, 'data'); + } + + public function test_assignable_persons_unauthenticated_returns_401(): void + { + $shift = $this->createOpenShift(); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertUnauthorized(); + } + + public function test_assignable_persons_wrong_org_returns_403(): void + { + $shift = $this->createOpenShift(); + + Sanctum::actingAs($this->outsider); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertForbidden(); + } + + public function test_assignable_persons_sorts_available_first(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]); + + $available = $this->createPerson(['name' => 'Anna Bakker']); + $conflicted = $this->createPerson(['name' => 'Bob Jansen']); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $conflicted->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift2->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $available->id) + ->assertJsonPath('data.0.is_available', true) + ->assertJsonPath('data.1.id', $conflicted->id) + ->assertJsonPath('data.1.is_available', false); + } + + public function test_assignable_persons_includes_crowd_type(): void + { + $shift = $this->createOpenShift(); + $this->createPerson(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson( + "/api/v1/events/{$this->event->id}/shifts/{$shift->id}/assignable-persons", + ); + + $response->assertOk() + ->assertJsonPath('data.0.crowd_type.system_type', 'VOLUNTEER') + ->assertJsonPath('data.0.crowd_type.name', $this->crowdType->name); + } + + // ========================================================================= + // Improved conflict error messages + // ========================================================================= + + public function test_assign_conflict_error_includes_section_and_timeslot(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]); + $person = $this->createPerson(); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->otherSection->id}/shifts/{$shift2->id}/assign", + ['person_id' => $person->id], + ); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['person_id']); + + $message = $response->json('errors.person_id.0'); + $this->assertStringContainsString($this->section->name, $message); + $this->assertStringContainsString($this->timeSlot->name, $message); + $this->assertStringContainsString('Deze persoon is al ingepland bij', $message); + } + + public function test_claim_conflict_error_uses_volunteer_language(): void + { + $shift1 = $this->createOpenShift(); + $shift2 = $this->createOpenShift(['festival_section_id' => $this->otherSection->id]); + $person = $this->createPerson(['user_id' => $this->orgAdmin->id]); + + ShiftAssignment::factory()->create([ + 'shift_id' => $shift1->id, + 'person_id' => $person->id, + 'time_slot_id' => $this->timeSlot->id, + 'status' => ShiftAssignmentStatus::APPROVED, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/events/{$this->event->id}/sections/{$this->otherSection->id}/shifts/{$shift2->id}/claim", + ['person_id' => $person->id], + ); + + $response->assertUnprocessable(); + + $message = $response->json('errors.person_id.0'); + $this->assertStringContainsString('Je bent al ingepland bij', $message); + } +} diff --git a/apps/app/src/components/sections/SectionsShiftsPanel.vue b/apps/app/src/components/sections/SectionsShiftsPanel.vue index 9d747094..b9209e8b 100644 --- a/apps/app/src/components/sections/SectionsShiftsPanel.vue +++ b/apps/app/src/components/sections/SectionsShiftsPanel.vue @@ -5,9 +5,13 @@ import { useShiftList, useDeleteShift } from '@/composables/api/useShifts' import CreateSectionDialog from '@/components/sections/CreateSectionDialog.vue' import EditSectionDialog from '@/components/sections/EditSectionDialog.vue' import CreateShiftDialog from '@/components/sections/CreateShiftDialog.vue' -import AssignShiftDialog from '@/components/sections/AssignShiftDialog.vue' +import AssignPersonDialog from '@/components/shifts/AssignPersonDialog.vue' +import ShiftDetailPanel from '@/components/shifts/ShiftDetailPanel.vue' +import { useShiftDetailStore } from '@/stores/useShiftDetailStore' import type { FestivalSection, Shift, ShiftStatus } from '@/types/section' +const shiftDetailStore = useShiftDetailStore() + const props = defineProps<{ eventId: string isSubEvent?: boolean @@ -179,9 +183,10 @@ const statusLabel: Record = { cancelled: 'Geannuleerd', } -function fillRateColor(rate: number): string { - if (rate >= 80) return 'success' - if (rate >= 40) return 'warning' +function fillRateColor(shift: Shift): string { + if (shift.is_overbooked) return 'warning' + if (shift.fill_rate >= 80) return 'success' + if (shift.fill_rate >= 40) return 'warning' return 'error' } @@ -197,6 +202,12 @@ function formatDate(iso: string) { return dateFormatter.format(new Date(iso)) } +// Selected shift for detail panel (resolved from store ID) +const selectedShift = computed(() => { + if (!shiftDetailStore.selectedShiftId || !shifts.value) return null + return shifts.value.find(s => s.id === shiftDetailStore.selectedShiftId) ?? null +}) + // Success snackbar const showSuccess = ref(false) const successMessage = ref('') @@ -481,8 +492,8 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean;
{{ shift.filled_slots }}/{{ shift.slots_total }} +
@@ -499,11 +516,32 @@ function onSectionCreated(payload: { name: string; redirectedToParent: boolean; > {{ statusLabel[shift.status] }} + + Overbezet +
+ + + tabler-eye + + + Details bekijken + + - + + + +import { useAssignablePersons, useAssignPersonToShift } from '@/composables/api/useShiftAssignments' +import type { AssignablePerson } from '@/types/shiftAssignment' +import type { Shift } from '@/types/section' + +const props = defineProps<{ + eventId: string + sectionId: string + shift: Shift | null +}>() + +const emit = defineEmits<{ + assigned: [] +}>() + +const modelValue = defineModel({ required: true }) + +const eventIdRef = computed(() => props.eventId) +const shiftIdRef = computed(() => props.shift?.id ?? '') + +const { data: assignablePersons, isLoading } = useAssignablePersons(eventIdRef, shiftIdRef) +const { mutateAsync: assignPerson, isPending: isAssigning } = useAssignPersonToShift(eventIdRef) + +// Search and filters +const searchQuery = ref('') +const showOnlyAvailable = ref(true) +const selectedCrowdType = ref(null) +const assignError = ref(null) + +// Clear error on filter changes +watch([searchQuery, showOnlyAvailable, selectedCrowdType], () => { + assignError.value = null +}) + +// Reset state when dialog opens +watch(modelValue, (open) => { + if (open) { + searchQuery.value = '' + showOnlyAvailable.value = true + selectedCrowdType.value = null + assignError.value = null + } +}) + +// Crowd type filter options (derived from data) +const crowdTypeOptions = computed(() => { + if (!assignablePersons.value) return [] + const seen = new Map() + for (const p of assignablePersons.value) { + if (p.crowd_type && !seen.has(p.crowd_type.system_type)) { + seen.set(p.crowd_type.system_type, p.crowd_type.name) + } + } + + return Array.from(seen, ([value, title]) => ({ title, value })) +}) + +// Filtered persons +const filteredPersons = computed(() => { + if (!assignablePersons.value) return [] + + return assignablePersons.value.filter((person) => { + if (searchQuery.value) { + const q = searchQuery.value.toLowerCase() + if (!person.name.toLowerCase().includes(q) && !person.email.toLowerCase().includes(q)) { + return false + } + } + + if (showOnlyAvailable.value) { + if (!person.is_available || person.already_assigned) return false + } + + if (selectedCrowdType.value) { + if (person.crowd_type?.system_type !== selectedCrowdType.value) return false + } + + return true + }) +}) + +// Empty state reason +const emptyReason = computed(() => { + if (!assignablePersons.value?.length) { + return 'Er zijn geen goedgekeurde personen voor dit evenement.' + } + if (showOnlyAvailable.value && !filteredPersons.value.length && assignablePersons.value.length) { + return 'Alle personen zijn al ingepland voor dit tijdslot. Zet \'Alleen beschikbaar\' uit om alle personen te zien.' + } + + return 'Geen personen gevonden voor deze zoekopdracht.' +}) + +function getInitials(name: string) { + return name + .split(' ') + .map(p => p[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} + +async function handleAssign(person: AssignablePerson) { + if (!props.shift) return + assignError.value = null + + try { + await assignPerson({ + sectionId: props.sectionId, + shiftId: props.shift.id, + personId: person.id, + }) + emit('assigned') + modelValue.value = false + } + catch (error: any) { + const message = error.response?.data?.errors?.person_id?.[0] + ?? error.response?.data?.message + ?? 'Er is een fout opgetreden bij het toewijzen.' + assignError.value = message + } +} + + + diff --git a/apps/app/src/components/shifts/ShiftDetailPanel.vue b/apps/app/src/components/shifts/ShiftDetailPanel.vue new file mode 100644 index 00000000..4238de62 --- /dev/null +++ b/apps/app/src/components/shifts/ShiftDetailPanel.vue @@ -0,0 +1,817 @@ + + + + + diff --git a/apps/app/src/composables/api/useShiftAssignments.ts b/apps/app/src/composables/api/useShiftAssignments.ts new file mode 100644 index 00000000..15ca41a2 --- /dev/null +++ b/apps/app/src/composables/api/useShiftAssignments.ts @@ -0,0 +1,161 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { MaybeRef, Ref } from 'vue' +import { unref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { AssignablePerson, ShiftAssignment } from '@/types/shiftAssignment' + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +interface PaginatedResponse { + data: T[] + links: Record + meta: { + current_page: number + per_page: number + total: number + last_page: number + } +} + +export interface ShiftAssignmentFilters { + shift_id?: string + person_id?: string + section_id?: string + status?: string +} + +export function useShiftAssignmentList( + eventId: Ref, + filters?: Ref, +) { + return useQuery({ + queryKey: ['shift-assignments', eventId, filters], + queryFn: async () => { + const params: Record = {} + if (filters?.value?.shift_id) params.shift_id = filters.value.shift_id + if (filters?.value?.person_id) params.person_id = filters.value.person_id + if (filters?.value?.section_id) params.section_id = filters.value.section_id + if (filters?.value?.status) params.status = filters.value.status + + const { data } = await apiClient.get>( + `/events/${eventId.value}/shift-assignments`, + { params }, + ) + + return data + }, + enabled: () => !!eventId.value, + }) +} + +export function useApproveAssignment(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (assignmentId: string) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/shift-assignments/${assignmentId}/approve`, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['shifts'] }) + }, + }) +} + +export function useRejectAssignment(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/shift-assignments/${assignmentId}/reject`, + { reason }, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['shifts'] }) + }, + }) +} + +export function useCancelAssignment(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (assignmentId: string) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/shift-assignments/${assignmentId}/cancel`, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['shifts'] }) + }, + }) +} + +export function useBulkApproveAssignments(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (assignmentIds: string[]) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/shift-assignments/bulk-approve`, + { assignment_ids: assignmentIds }, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['shifts'] }) + }, + }) +} + +export function useAssignPersonToShift(eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ sectionId, shiftId, personId }: { sectionId: string; shiftId: string; personId: string }) => { + const { data } = await apiClient.post>( + `/events/${eventId.value}/sections/${sectionId}/shifts/${shiftId}/assign`, + { person_id: personId }, + ) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shift-assignments', eventId.value] }) + queryClient.invalidateQueries({ queryKey: ['shifts'] }) + queryClient.invalidateQueries({ queryKey: ['persons', eventId.value] }) + }, + }) +} + +export function useAssignablePersons(eventId: MaybeRef, shiftId: MaybeRef) { + return useQuery({ + queryKey: ['assignable-persons', eventId, shiftId], + queryFn: async () => { + const { data } = await apiClient.get<{ data: AssignablePerson[] }>( + `/events/${unref(eventId)}/shifts/${unref(shiftId)}/assignable-persons`, + ) + + return data.data + }, + enabled: () => !!unref(eventId) && !!unref(shiftId), + }) +} diff --git a/apps/app/src/types/shiftAssignment.ts b/apps/app/src/types/shiftAssignment.ts new file mode 100644 index 00000000..855c40cc --- /dev/null +++ b/apps/app/src/types/shiftAssignment.ts @@ -0,0 +1,67 @@ +import type { Person } from './person' +import type { Shift } from './section' + +export const ShiftAssignmentStatus = { + PENDING_APPROVAL: 'pending_approval', + APPROVED: 'approved', + REJECTED: 'rejected', + CANCELLED: 'cancelled', + COMPLETED: 'completed', +} as const + +export type ShiftAssignmentStatus = (typeof ShiftAssignmentStatus)[keyof typeof ShiftAssignmentStatus] + +export interface ShiftAssignment { + id: string + shift_id: string + person_id: string + time_slot_id: string + status: ShiftAssignmentStatus + auto_approved: boolean + assigned_by: string | null + assigned_at: string | null + approved_by: string | null + approved_at: string | null + rejection_reason: string | null + hours_expected: number | null + hours_completed: number | null + checked_in_at: string | null + checked_out_at: string | null + is_cancellable: boolean + is_approvable: boolean + person?: Person + shift?: Shift + created_at: string +} + +export interface AssignPersonToShiftDto { + person_id: string +} + +export interface RejectAssignmentDto { + reason?: string +} + +export interface BulkApproveDto { + assignment_ids: string[] +} + +export interface AssignablePerson { + id: string + name: string + email: string + status: string + crowd_type: { + id: string + name: string + system_type: string + } | null + is_available: boolean + already_assigned: boolean + conflict: { + section_name: string + shift_title: string + time_slot_name: string + time: string + } | null +} diff --git a/dev-docs/API.md b/dev-docs/API.md index e071e321..825e74ce 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -180,6 +180,46 @@ Auth: org_admin or event_manager on the event's organisation. - `POST /events/{event}/shift-assignments/{shiftAssignment}/reject` — reject pending assignment - `POST /events/{event}/shift-assignments/{shiftAssignment}/cancel` — cancel assignment - `POST /events/{event}/shift-assignments/bulk-approve` — bulk approve multiple assignments +- `GET /events/{event}/shifts/{shift}/assignable-persons` — list approved persons with availability status + +### Assignable Persons + +`GET /events/{event}/shifts/{shift}/assignable-persons` + +Returns all approved persons for the event with availability status for this shift's time slot. +Persons are sorted: available first, then unavailable (conflict), then already assigned. + +```json +{ + "data": [ + { + "id": "ulid", + "name": "Jan de Vries", + "email": "jan@gmail.com", + "status": "approved", + "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, + "is_available": true, + "already_assigned": false, + "conflict": null + }, + { + "id": "ulid", + "name": "Ahmed Hassan", + "email": "ahmed.h@gmail.com", + "status": "approved", + "crowd_type": { "id": "ulid", "name": "Vrijwilliger", "system_type": "VOLUNTEER" }, + "is_available": false, + "already_assigned": false, + "conflict": { + "section_name": "EHBO", + "shift_title": "EHBO Post", + "time_slot_name": "Zaterdag Dag", + "time": "10:00–18:00" + } + } + ] +} +``` ### Query Parameters (index)