feat: portal cross-event my-shifts endpoint and dashboard page

GET /portal/my-shifts aggregates shift assignments across all events
the logged-in user is linked to via Person records. Groups by event
then date, showing only active assignments (approved/pending_approval)
for approved/pending persons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 15:07:08 +02:00
parent d4004c798c
commit 53100d4f6d
7 changed files with 812 additions and 9 deletions

View File

@@ -1,12 +1,25 @@
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'
import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
interface ApiResponse<T> {
data: T
}
export function useAllMyShifts() {
return useQuery({
queryKey: ['portal-all-my-shifts'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<AllMyShiftsEventGroup[]>>(
'/portal/my-shifts',
)
return data.data
},
})
}
export function useAvailableShifts(eventId: Ref<string | null>) {
return useQuery({
queryKey: ['available-shifts', eventId],

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import { useAllMyShifts } from '@/composables/api/usePortalShifts'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
definePage({
name: 'portal-shifts',
meta: {
@@ -6,6 +10,26 @@ definePage({
requiresAuth: true,
},
})
const auth = useAuthStore()
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
const statusConfig: Record<string, { label: string; color: string }> = {
pending_approval: { label: 'In afwachting', color: 'warning' },
approved: { label: 'Bevestigd', color: 'success' },
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
}
function getStatusConfig(assignment: AllMyShiftsAssignment) {
return statusConfig[assignment.status] ?? { label: assignment.status, color: 'default' }
}
</script>
<template>
@@ -15,14 +39,199 @@ definePage({
md="8"
lg="6"
>
<VCard class="text-center pa-6">
<VCardTitle class="text-h5">
Mijn Shifts
</VCardTitle>
<VCardSubtitle>
Overzicht van je ingeplande shifts
</VCardSubtitle>
</VCard>
<h5 class="text-h5 mb-6">
Mijn diensten
</h5>
<!-- Not authenticated -->
<VAlert
v-if="!auth.isAuthenticated"
type="info"
variant="tonal"
>
<VIcon
start
icon="tabler-login"
/>
Log in om je diensten te bekijken.
</VAlert>
<template v-else>
<!-- 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>
<!-- Empty state -->
<VCard
v-else-if="!eventGroups?.length"
variant="flat"
class="text-center pa-8"
>
<VAvatar
size="64"
color="primary"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-calendar-off"
size="32"
/>
</VAvatar>
<p class="text-body-1 text-medium-emphasis mb-4">
Je hebt nog geen diensten toegewezen gekregen.
</p>
<VBtn
color="primary"
variant="tonal"
to="/evenementen"
>
Bekijk je evenementen
</VBtn>
</VCard>
<!-- Shift groups -->
<template v-else>
<div
v-for="eventGroup in eventGroups"
:key="eventGroup.event.id"
class="mb-8"
>
<!-- Event header -->
<div class="d-flex align-center gap-2 mb-4">
<VIcon
icon="tabler-calendar-event"
size="20"
color="primary"
/>
<span class="text-subtitle-1 font-weight-bold">
{{ eventGroup.event.name }}
</span>
</div>
<!-- Date groups -->
<div
v-for="dateGroup in eventGroup.assignments"
:key="dateGroup.date"
class="mb-4"
>
<div class="text-subtitle-2 text-medium-emphasis mb-2">
{{ dateGroup.date_label ?? formatDate(dateGroup.date) }}
</div>
<VCard
v-for="assignment in dateGroup.shifts"
:key="assignment.id"
variant="outlined"
class="mb-2 shift-card"
:class="`shift-card--${assignment.status}`"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="assignment.shift.section_icon"
:icon="assignment.shift.section_icon"
size="24"
color="primary"
/>
<VAvatar
v-else
size="32"
color="primary"
variant="tonal"
>
{{ assignment.shift.section_name[0] }}
</VAvatar>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ assignment.shift.title }}
</VCardTitle>
<VCardSubtitle>{{ assignment.shift.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="getStatusConfig(assignment).color"
size="small"
variant="tonal"
>
{{ getStatusConfig(assignment).label }}
</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-clock"
size="14"
class="me-1"
/>
{{ assignment.shift.start_time }} - {{ assignment.shift.end_time }}
</span>
<span v-if="assignment.shift.report_time">
<VIcon
icon="tabler-alert-circle"
size="14"
class="me-1"
/>
Aanwezig: {{ assignment.shift.report_time }}
</span>
<span v-if="assignment.shift.location">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ assignment.shift.location.name }}
</span>
</div>
</VCardText>
</VCard>
</div>
</div>
</template>
</template>
</VCol>
</VRow>
</template>
<style scoped>
.shift-card {
border-inline-start: 3px solid transparent;
}
.shift-card--approved {
border-inline-start-color: rgb(var(--v-theme-success));
}
.shift-card--pending_approval {
border-inline-start-color: rgb(var(--v-theme-warning));
}
</style>

View File

@@ -48,3 +48,40 @@ export interface MyShiftAssignment {
report_time: string | null
can_cancel: boolean
}
// Cross-event "all my shifts" types
export interface AllMyShiftsAssignment {
id: string
status: string
shift: {
id: string
title: string
section_name: string
section_icon: string | null
time_slot_name: string
date: string
start_time: string
end_time: string
report_time: string | null
location: {
name: string
address: string | null
} | null
}
}
export interface AllMyShiftsDateGroup {
date: string
date_label: string
shifts: AllMyShiftsAssignment[]
}
export interface AllMyShiftsEventGroup {
event: {
id: string
name: string
start_date: string
end_date: string
}
assignments: AllMyShiftsDateGroup[]
}