feat(portal): shift claiming and my-shifts for volunteer portal

Backend: PortalShiftController with 4 endpoints (available-shifts,
my-shifts, claim, cancel) delegating to ShiftAssignmentService.
24 PHPUnit tests covering happy paths, auth, conflicts, and edge cases.

Frontend: claim-shifts and my-shifts pages with TanStack Query
composable, conflict detection, confirmation dialogs, and cancel flow.
Navigation and dashboard cards wired up for approved volunteers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 08:47:12 +02:00
parent 0d5523dbfe
commit 5173f7297f
10 changed files with 1790 additions and 9 deletions

View File

@@ -0,0 +1,73 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type { AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
interface ApiResponse<T> {
data: T
}
export function useAvailableShifts(eventId: Ref<string | null>) {
return useQuery({
queryKey: ['available-shifts', eventId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<AvailableShiftsDay[]>>(
`/portal/events/${eventId.value}/available-shifts`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useMyShifts(eventId: Ref<string | null>) {
return useQuery({
queryKey: ['my-shifts', eventId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<MyShiftsResponse>>(
`/portal/events/${eventId.value}/my-shifts`,
)
return data.data
},
enabled: () => !!eventId.value,
})
}
export function useClaimShift(eventId: Ref<string | null>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (shiftId: string) => {
const { data } = await apiClient.post<ApiResponse<{ assignment_id: string; status: string; message: string }>>(
`/portal/events/${eventId.value}/shifts/${shiftId}/claim`,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
},
})
}
export function useCancelAssignment(eventId: Ref<string | null>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => {
const { data } = await apiClient.post<ApiResponse<{ message: string }>>(
`/portal/events/${eventId.value}/assignments/${assignmentId}/cancel`,
reason ? { reason } : {},
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
},
})
}