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

@@ -94,12 +94,12 @@ const registeredLabel = computed(() => {
sm="4"
>
<VCard
:to="{ name: 'portal-shifts' }"
:to="{ name: 'portal-my-shifts' }"
variant="outlined"
class="pa-4 h-100 text-decoration-none"
>
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Mijn Shifts
Mijn Diensten
</div>
<div class="text-body-2">
Rooster bekijken
@@ -111,14 +111,15 @@ const registeredLabel = computed(() => {
sm="4"
>
<VCard
:to="{ name: 'portal-claim-shifts' }"
variant="outlined"
class="pa-4 h-100 text-medium-emphasis"
class="pa-4 h-100 text-decoration-none"
>
<div class="text-subtitle-2 mb-1">
Shifts claimen
<div class="text-subtitle-2 text-medium-emphasis mb-1">
Diensten claimen
</div>
<div class="text-body-2">
Binnenkort beschikbaar
Schrijf je in voor diensten
</div>
</VCard>
</VCol>

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] })
},
})
}

View File

@@ -1,24 +1,37 @@
<script lang="ts" setup>
import EventSwitcher from '@/components/portal/EventSwitcher.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
const { injectSkinClasses } = useSkins()
injectSkinClasses()
const authStore = useAuthStore()
const portal = usePortalStore()
const router = useRouter()
const isMobileMenuOpen = ref(false)
const isApproved = computed(() => portal.currentPerson?.status === 'approved')
const navItems = computed(() => {
if (!authStore.isAuthenticated) return []
return [
const items = [
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-dashboard' },
{ title: 'Mijn Shifts', to: '/shifts', icon: 'tabler-calendar-event' },
{ title: 'Mijn Profiel', to: '/profile', icon: 'tabler-user' },
]
if (isApproved.value) {
items.push(
{ title: 'Mijn Diensten', to: '/dashboard/my-shifts', icon: 'tabler-calendar-check' },
{ title: 'Diensten Claimen', to: '/dashboard/claim-shifts', icon: 'tabler-calendar-plus' },
)
}
items.push({ title: 'Mijn Profiel', to: '/profile', icon: 'tabler-user' })
return items
})
const isFallbackStateActive = ref(false)

View File

@@ -0,0 +1,330 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
import { useAvailableShifts, useClaimShift } from '@/composables/api/usePortalShifts'
import type { AvailableShift } from '@/types/portal-shift'
definePage({
name: 'portal-claim-shifts',
meta: {
layout: 'portal',
requiresAuth: true,
},
})
const portal = usePortalStore()
const eventId = computed(() => portal.activeEventId)
const { data: days, isLoading, isError, refetch } = useAvailableShifts(eventId)
const claimMutation = useClaimShift(eventId)
const showConfirmDialog = ref(false)
const selectedShift = ref<AvailableShift | null>(null)
const selectedDayLabel = ref('')
const selectedTimeLabel = ref('')
const claimError = ref<string | null>(null)
const snackbar = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('success')
const expandedDescriptions = ref<Set<string>>(new Set())
function openClaimDialog(shift: AvailableShift, dayLabel: string, startTime: string, endTime: string) {
selectedShift.value = shift
selectedDayLabel.value = dayLabel
selectedTimeLabel.value = `${startTime} - ${endTime}`
claimError.value = null
showConfirmDialog.value = true
}
async function confirmClaim() {
if (!selectedShift.value) return
claimError.value = null
try {
const result = await claimMutation.mutateAsync(selectedShift.value.id)
showConfirmDialog.value = false
snackbarMessage.value = result.message
snackbarColor.value = 'success'
snackbar.value = true
}
catch (err: any) {
const message = err?.response?.data?.message ?? 'Er is een fout opgetreden.'
claimError.value = message
}
}
function toggleDescription(shiftId: string) {
if (expandedDescriptions.value.has(shiftId))
expandedDescriptions.value.delete(shiftId)
else
expandedDescriptions.value.add(shiftId)
}
function availabilityColor(slotsAvailable: number): string {
if (slotsAvailable >= 3) return 'success'
return 'warning'
}
onMounted(async () => {
if (!portal.activeEventId) {
await portal.hydrateAfterAuth()
}
})
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
lg="10"
>
<div class="d-flex align-center justify-space-between mb-4">
<h4 class="text-h4">
Diensten claimen
</h4>
<VBtn
variant="text"
size="small"
:to="{ name: 'portal-my-shifts' }"
>
<VIcon
start
icon="tabler-calendar-check"
size="18"
/>
Mijn diensten
</VBtn>
</div>
<!-- Loading -->
<template v-if="isLoading">
<VSkeletonLoader
v-for="n in 3"
:key="n"
type="card"
class="mb-4"
/>
</template>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
class="mb-4"
>
Er ging iets mis bij het ophalen van de diensten.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty -->
<VAlert
v-else-if="!days?.length"
type="info"
variant="tonal"
>
Er zijn momenteel geen diensten beschikbaar.
</VAlert>
<!-- Shift list grouped by date -->
<template v-else>
<div
v-for="day in days"
:key="day.date"
class="mb-6"
>
<h5 class="text-h5 mb-3">
{{ day.date_label }}
</h5>
<div
v-for="slot in day.time_slots"
:key="slot.time_slot_id"
class="mb-4"
>
<div class="text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
{{ slot.name }} ({{ slot.start_time }} - {{ slot.end_time }})
</div>
<VRow>
<VCol
v-for="shift in slot.shifts"
:key="shift.id"
cols="12"
sm="6"
md="4"
>
<VCard
variant="outlined"
class="h-100"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="shift.section_icon"
:icon="shift.section_icon"
size="24"
color="primary"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ shift.title }}
</VCardTitle>
<VCardSubtitle>{{ shift.section_name }}</VCardSubtitle>
</VCardItem>
<VCardText class="pt-0">
<div
v-if="shift.location_name"
class="text-body-2 mb-1"
>
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ shift.location_name }}
</div>
<div
v-if="shift.report_time"
class="text-body-2 mb-1"
>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
Aanwezig: {{ shift.report_time }}
</div>
<VChip
:color="availabilityColor(shift.slots_available)"
size="small"
variant="tonal"
class="mt-2 mb-2"
>
{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar
</VChip>
<div
v-if="shift.description"
class="mt-2"
>
<p
v-if="!expandedDescriptions.has(shift.id)"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ shift.description.length > 80 ? shift.description.slice(0, 80) + '...' : shift.description }}
<a
v-if="shift.description.length > 80"
href="#"
class="text-primary text-decoration-none"
@click.prevent="toggleDescription(shift.id)"
>meer</a>
</p>
<p
v-else
class="text-body-2 text-medium-emphasis mb-0"
>
{{ shift.description }}
<a
href="#"
class="text-primary text-decoration-none"
@click.prevent="toggleDescription(shift.id)"
>minder</a>
</p>
</div>
<VAlert
v-if="shift.has_conflict"
type="warning"
variant="tonal"
density="compact"
class="mt-3"
>
{{ shift.conflict_reason }}
</VAlert>
</VCardText>
<VCardActions>
<VBtn
color="primary"
variant="elevated"
block
:disabled="shift.has_conflict || claimMutation.isPending.value"
:loading="claimMutation.isPending.value && selectedShift?.id === shift.id"
@click="openClaimDialog(shift, day.date_label, slot.start_time, slot.end_time)"
>
Inschrijven
</VBtn>
</VCardActions>
</VCard>
</VCol>
</VRow>
</div>
</div>
</template>
<!-- Claim confirmation dialog -->
<VDialog
v-model="showConfirmDialog"
max-width="480"
>
<VCard>
<VCardTitle>Inschrijven bevestigen</VCardTitle>
<VCardText>
Wil je je inschrijven voor <strong>{{ selectedShift?.title }}</strong>
op {{ selectedDayLabel }} ({{ selectedTimeLabel }})?
<VAlert
v-if="claimError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
>
{{ claimError }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
:disabled="claimMutation.isPending.value"
@click="showConfirmDialog = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
variant="elevated"
:loading="claimMutation.isPending.value"
@click="confirmClaim"
>
Bevestigen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="4000"
>
{{ snackbarMessage }}
</VSnackbar>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,421 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
import { useMyShifts, useCancelAssignment } from '@/composables/api/usePortalShifts'
import type { MyShiftAssignment } from '@/types/portal-shift'
definePage({
name: 'portal-my-shifts',
meta: {
layout: 'portal',
requiresAuth: true,
},
})
const portal = usePortalStore()
const eventId = computed(() => portal.activeEventId)
const { data: shifts, isLoading, isError, refetch } = useMyShifts(eventId)
const cancelMutation = useCancelAssignment(eventId)
const showCancelDialog = ref(false)
const cancelTarget = ref<MyShiftAssignment | null>(null)
const cancelReason = ref('')
const cancelError = ref<string | null>(null)
const snackbar = ref(false)
const snackbarMessage = ref('')
const statusConfig: Record<string, { label: string; color: string }> = {
pending_approval: { label: 'Wacht op goedkeuring', color: 'warning' },
approved: { label: 'Goedgekeurd', color: 'success' },
rejected: { label: 'Afgewezen', color: 'error' },
cancelled: { label: 'Geannuleerd', color: 'default' },
completed: { label: 'Afgerond', color: 'info' },
}
function openCancelDialog(assignment: MyShiftAssignment) {
cancelTarget.value = assignment
cancelReason.value = ''
cancelError.value = null
showCancelDialog.value = true
}
async function confirmCancel() {
if (!cancelTarget.value) return
cancelError.value = null
try {
const result = await cancelMutation.mutateAsync({
assignmentId: cancelTarget.value.assignment_id,
reason: cancelReason.value || undefined,
})
showCancelDialog.value = false
snackbarMessage.value = result.message
snackbar.value = true
}
catch (err: any) {
cancelError.value = err?.response?.data?.message ?? 'Er is een fout opgetreden.'
}
}
onMounted(async () => {
if (!portal.activeEventId) {
await portal.hydrateAfterAuth()
}
})
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
lg="10"
>
<div class="d-flex align-center justify-space-between mb-4">
<h4 class="text-h4">
Mijn diensten
</h4>
<VBtn
variant="text"
size="small"
:to="{ name: 'portal-claim-shifts' }"
>
<VIcon
start
icon="tabler-calendar-plus"
size="18"
/>
Diensten claimen
</VBtn>
</div>
<!-- Loading -->
<template v-if="isLoading">
<VSkeletonLoader
v-for="n in 3"
:key="n"
type="card"
class="mb-4"
/>
</template>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
class="mb-4"
>
Er ging iets mis bij het ophalen van je diensten.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="shifts">
<!-- Upcoming -->
<div class="mb-6">
<h5 class="text-h5 mb-3">
Komende diensten
</h5>
<template v-if="shifts.upcoming.length">
<VCard
v-for="assignment in shifts.upcoming"
:key="assignment.assignment_id"
variant="outlined"
class="mb-3"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="assignment.section_icon"
:icon="assignment.section_icon"
size="24"
color="primary"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ assignment.shift_title }}
</VCardTitle>
<VCardSubtitle>{{ assignment.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="statusConfig[assignment.status]?.color ?? 'default'"
size="small"
variant="tonal"
>
{{ statusConfig[assignment.status]?.label ?? assignment.status }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2">
<span>
<VIcon
icon="tabler-calendar"
size="14"
class="me-1"
/>
{{ assignment.date_label }}
</span>
<span>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ assignment.start_time }} - {{ assignment.end_time }}
</span>
<span v-if="assignment.location_name">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ assignment.location_name }}
</span>
<span v-if="assignment.report_time">
<VIcon
icon="tabler-alert-circle"
size="14"
class="me-1"
/>
Aanwezig: {{ assignment.report_time }}
</span>
</div>
</VCardText>
<VCardActions v-if="assignment.can_cancel">
<VSpacer />
<VBtn
variant="text"
color="error"
size="small"
:disabled="cancelMutation.isPending.value"
@click="openCancelDialog(assignment)"
>
Annuleren
</VBtn>
</VCardActions>
</VCard>
</template>
<VAlert
v-else
type="info"
variant="tonal"
>
Je hebt nog geen diensten.
<RouterLink
:to="{ name: 'portal-claim-shifts' }"
class="text-primary font-weight-medium"
>
Diensten claimen &rarr;
</RouterLink>
</VAlert>
</div>
<!-- Past -->
<div
v-if="shifts.past.length"
class="mb-6"
>
<h5 class="text-h5 mb-3">
Afgelopen diensten
</h5>
<VCard
v-for="assignment in shifts.past"
:key="assignment.assignment_id"
variant="outlined"
class="mb-3"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="assignment.section_icon"
:icon="assignment.section_icon"
size="24"
color="primary"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ assignment.shift_title }}
</VCardTitle>
<VCardSubtitle>{{ assignment.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="statusConfig[assignment.status]?.color ?? 'default'"
size="small"
variant="tonal"
>
{{ statusConfig[assignment.status]?.label ?? assignment.status }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2">
<span>
<VIcon
icon="tabler-calendar"
size="14"
class="me-1"
/>
{{ assignment.date_label }}
</span>
<span>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ assignment.start_time }} - {{ assignment.end_time }}
</span>
<span v-if="assignment.location_name">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ assignment.location_name }}
</span>
</div>
</VCardText>
</VCard>
</div>
<!-- Cancelled / Rejected -->
<div v-if="shifts.cancelled.length">
<h5 class="text-h5 mb-3">
Geannuleerd / Afgewezen
</h5>
<VCard
v-for="assignment in shifts.cancelled"
:key="assignment.assignment_id"
variant="outlined"
class="mb-3"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="assignment.section_icon"
:icon="assignment.section_icon"
size="24"
color="primary"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ assignment.shift_title }}
</VCardTitle>
<VCardSubtitle>{{ assignment.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="statusConfig[assignment.status]?.color ?? 'default'"
size="small"
variant="tonal"
>
{{ statusConfig[assignment.status]?.label ?? assignment.status }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2">
<span>
<VIcon
icon="tabler-calendar"
size="14"
class="me-1"
/>
{{ assignment.date_label }}
</span>
<span>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ assignment.start_time }} - {{ assignment.end_time }}
</span>
</div>
</VCardText>
</VCard>
</div>
</template>
<!-- Cancel confirmation dialog -->
<VDialog
v-model="showCancelDialog"
max-width="480"
>
<VCard>
<VCardTitle>Dienst annuleren</VCardTitle>
<VCardText>
<p>
Weet je zeker dat je deze dienst wilt annuleren?
</p>
<p class="text-body-2 text-medium-emphasis mb-3">
<strong>{{ cancelTarget?.shift_title }}</strong> &mdash;
{{ cancelTarget?.date_label }} ({{ cancelTarget?.start_time }} - {{ cancelTarget?.end_time }})
</p>
<VTextarea
v-model="cancelReason"
label="Reden (optioneel)"
rows="2"
variant="outlined"
density="compact"
/>
<VAlert
v-if="cancelError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
>
{{ cancelError }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
:disabled="cancelMutation.isPending.value"
@click="showCancelDialog = false"
>
Terug
</VBtn>
<VBtn
color="error"
variant="elevated"
:loading="cancelMutation.isPending.value"
@click="confirmCancel"
>
Annuleren
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar"
color="success"
:timeout="4000"
>
{{ snackbarMessage }}
</VSnackbar>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,50 @@
export interface AvailableShiftsDay {
date: string
date_label: string
time_slots: AvailableShiftsTimeSlot[]
}
export interface AvailableShiftsTimeSlot {
time_slot_id: string
name: string
start_time: string
end_time: string
shifts: AvailableShift[]
}
export interface AvailableShift {
id: string
title: string
section_name: string
section_icon: string | null
location_name: string | null
slots_total: number
slots_open_for_claiming: number
slots_claimed: number
slots_available: number
has_conflict: boolean
conflict_reason: string | null
report_time: string | null
description: string | null
}
export interface MyShiftsResponse {
upcoming: MyShiftAssignment[]
past: MyShiftAssignment[]
cancelled: MyShiftAssignment[]
}
export interface MyShiftAssignment {
assignment_id: string
status: string
shift_title: string
section_name: string
section_icon: string | null
location_name: string | null
date: string
date_label: string
start_time: string
end_time: string
report_time: string | null
can_cancel: boolean
}

View File

@@ -22,6 +22,8 @@ declare module 'vue-router/auto-routes' {
'not-found': RouteRecordInfo<'not-found', '/:path(.*)', { path: ParamValue<true> }, { path: ParamValue<false> }>,
'artist-advance': RouteRecordInfo<'artist-advance', '/advance/:token', { token: ParamValue<true> }, { token: ParamValue<false> }>,
'portal-dashboard': RouteRecordInfo<'portal-dashboard', '/dashboard', Record<never, never>, Record<never, never>>,
'portal-claim-shifts': RouteRecordInfo<'portal-claim-shifts', '/dashboard/claim-shifts', Record<never, never>, Record<never, never>>,
'portal-my-shifts': RouteRecordInfo<'portal-my-shifts', '/dashboard/my-shifts', Record<never, never>, Record<never, never>>,
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
'portal-profile': RouteRecordInfo<'portal-profile', '/profile', Record<never, never>, Record<never, never>>,
'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>,