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:
@@ -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>
|
||||
|
||||
73
apps/portal/src/composables/api/usePortalShifts.ts
Normal file
73
apps/portal/src/composables/api/usePortalShifts.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
330
apps/portal/src/pages/dashboard/claim-shifts.vue
Normal file
330
apps/portal/src/pages/dashboard/claim-shifts.vue
Normal 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>
|
||||
421
apps/portal/src/pages/dashboard/my-shifts.vue
Normal file
421
apps/portal/src/pages/dashboard/my-shifts.vue
Normal 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 →
|
||||
</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> —
|
||||
{{ 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>
|
||||
50
apps/portal/src/types/portal-shift.ts
Normal file
50
apps/portal/src/types/portal-shift.ts
Normal 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
|
||||
}
|
||||
2
apps/portal/typed-router.d.ts
vendored
2
apps/portal/typed-router.d.ts
vendored
@@ -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> }>,
|
||||
|
||||
Reference in New Issue
Block a user