feat(portal): restructure into three-screen architecture with event tabs

Replace scattered dashboard pages with a three-screen volunteer portal:

1. Mijn evenementen (/evenementen) - landing page with visual event cards
   in a responsive grid, sorted upcoming-first
2. Event-pagina (/evenementen/:eventId) - single page with hash-based tabs
   (Overzicht, Mijn rooster, Diensten claimen, Informatie) replacing the
   old separate dashboard/my-shifts/claim-shifts pages
3. Mijn profiel (/profiel) - unchanged, platform-level settings

Key changes:
- Extract page content into tab components (RoosterTab, ClaimenTab,
  OverzichtTab, InformatieTab) that receive eventId as prop
- Dual-mode navbar: platform mode (Crewli logo) vs event mode (org name
  + event name + back link)
- StatusCard now emits switchTab events instead of route navigation
- Smart login redirect: 1 event → direct to event, 2+ → overview
- Backward-compat redirects for /dashboard/* → /evenementen
- Delete EventSwitcher (replaced by events overview page)
- Update UserAvatarMenu with "Mijn evenementen" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 13:30:20 +02:00
parent 2d7464e05b
commit f9faeb7ea0
22 changed files with 1482 additions and 1102 deletions

View File

@@ -23,6 +23,7 @@ declare module 'vue' {
CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default'] CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default']
CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default'] CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default']
CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default'] CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default']
ClaimenTab: typeof import('./src/components/event/ClaimenTab.vue')['default']
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default'] CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default']
CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default'] CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']
CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default'] CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default']
@@ -32,11 +33,14 @@ declare module 'vue' {
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default'] CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default']
EventSwitcher: typeof import('./src/components/portal/EventSwitcher.vue')['default'] EventCard: typeof import('./src/components/portal/EventCard.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default']
InformatieTab: typeof import('./src/components/event/InformatieTab.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
OverzichtTab: typeof import('./src/components/event/OverzichtTab.vue')['default']
ProductDescriptionEditor: typeof import('./src/@core/components/ProductDescriptionEditor.vue')['default'] ProductDescriptionEditor: typeof import('./src/@core/components/ProductDescriptionEditor.vue')['default']
RoosterTab: typeof import('./src/components/event/RoosterTab.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default'] ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']

View File

@@ -8,5 +8,7 @@ declare module 'vue-router' {
requiresToken?: boolean requiresToken?: boolean
public?: boolean public?: boolean
hideEventMenu?: boolean hideEventMenu?: boolean
navMode?: 'platform' | 'event'
navTitle?: string
} }
} }

View File

@@ -0,0 +1,349 @@
<script setup lang="ts">
import { useAvailableShifts, useClaimShift } from '@/composables/api/usePortalShifts'
import type { AvailableShift } from '@/types/portal-shift'
const props = defineProps<{
eventId: string
}>()
const emit = defineEmits<{
switchTab: [tab: string]
}>()
const eventIdRef = computed(() => props.eventId as string | null)
const { data: days, isLoading, isError, refetch } = useAvailableShifts(eventIdRef)
const claimMutation = useClaimShift(eventIdRef)
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'
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5">
Diensten claimen
</h5>
<VBtn
variant="text"
size="small"
@click="emit('switchTab', 'rooster')"
>
<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"
>
<div class="d-flex align-center gap-2 mb-3">
<VIcon
icon="tabler-calendar"
size="20"
color="primary"
/>
<h6 class="text-h6 mb-0">
{{ day.date_label }}
</h6>
</div>
<div
v-for="slot in day.time_slots"
:key="slot.time_slot_id"
class="mb-4"
>
<div class="d-flex align-center gap-2 text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
<VIcon
icon="tabler-clock"
size="16"
/>
{{ slot.name }} &middot; {{ 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 claim-card"
:class="{ 'claim-card--conflict': shift.has_conflict }"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="shift.section_icon"
:icon="shift.section_icon"
size="24"
:color="shift.has_conflict ? 'disabled' : 'primary'"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ shift.title }}
</VCardTitle>
<VCardSubtitle>{{ shift.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="availabilityColor(shift.slots_available)"
size="small"
variant="tonal"
>
{{ shift.slots_available }}/{{ shift.slots_open_for_claiming }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2 mb-2">
<span v-if="shift.location_name">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ shift.location_name }}
</span>
<span v-if="shift.report_time">
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
Aanwezig: {{ shift.report_time }}
</span>
</div>
<!-- Availability progress -->
<div class="mb-2">
<div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
<span>{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar</span>
</div>
<VProgressLinear
:model-value="((shift.slots_open_for_claiming - shift.slots_available) / shift.slots_open_for_claiming) * 100"
:color="availabilityColor(shift.slots_available)"
height="6"
rounded
/>
</div>
<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"
>
<template #prepend>
<VIcon
icon="tabler-alert-triangle"
size="18"
/>
</template>
{{ shift.conflict_reason }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:variant="shift.has_conflict ? 'tonal' : 'elevated'"
: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>
</div>
</template>
<style scoped>
.claim-card--conflict {
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
defineProps<{
eventId: string
}>()
const portal = usePortalStore()
const event = computed(() => portal.activeEvent)
function formatEventDates(startDate: string, endDate: string): string {
try {
const start = new Date(`${startDate}T12:00:00`)
const end = new Date(`${endDate}T12:00:00`)
const opts: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }
return `${start.toLocaleDateString('nl-NL', opts)} ${end.toLocaleDateString('nl-NL', opts)}`
}
catch {
return `${startDate} ${endDate}`
}
}
</script>
<template>
<div>
<h5 class="text-h5 mb-4">
Informatie
</h5>
<VCard
v-if="event"
class="mb-4"
>
<VCardText>
<VList class="pa-0">
<VListItem class="px-0">
<template #prepend>
<VIcon
icon="tabler-calendar-event"
size="22"
color="primary"
class="me-2"
/>
</template>
<VListItemTitle class="font-weight-medium">
{{ event.event_name }}
</VListItemTitle>
<VListItemSubtitle>
{{ formatEventDates(event.start_date, event.end_date) }}
</VListItemSubtitle>
</VListItem>
<VDivider class="my-2" />
<VListItem class="px-0">
<template #prepend>
<VIcon
icon="tabler-building"
size="22"
color="primary"
class="me-2"
/>
</template>
<VListItemTitle class="font-weight-medium">
Organisatie
</VListItemTitle>
<VListItemSubtitle>
{{ event.organisation_name || 'Niet beschikbaar' }}
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<VCard>
<VCardText>
<div class="d-flex align-center gap-2 mb-3">
<VIcon
icon="tabler-info-circle"
size="22"
color="primary"
/>
<span class="text-subtitle-1 font-weight-medium">Praktische informatie</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Neem contact op met {{ event?.organisation_name || 'de organisatie' }} voor meer informatie over dit evenement.
</p>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -4,19 +4,18 @@ import { usePortalStore } from '@/stores/usePortalStore'
import { useMyShifts } from '@/composables/api/usePortalShifts' import { useMyShifts } from '@/composables/api/usePortalShifts'
import type { PortalPersonPayload } from '@/types/portal' import type { PortalPersonPayload } from '@/types/portal'
definePage({ const props = defineProps<{
name: 'portal-dashboard', eventId: string
meta: { }>()
layout: 'portal',
requiresAuth: true, const emit = defineEmits<{
}, switchTab: [tab: string]
}) }>()
const portal = usePortalStore() const portal = usePortalStore()
const eventId = computed(() => portal.activeEventId) const eventIdRef = computed(() => props.eventId as string | null)
// Fetch my shifts to show upcoming count const { data: shifts } = useMyShifts(eventIdRef)
const { data: shifts } = useMyShifts(eventId)
const effectiveStatus = computed(() => { const effectiveStatus = computed(() => {
const fromPerson = portal.currentPerson?.status const fromPerson = portal.currentPerson?.status
@@ -75,65 +74,50 @@ function formatNextShift(person: PortalPersonPayload | null): string | null {
} }
const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson)) const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson))
// Portal hydration now happens automatically in the router guard
</script> </script>
<template> <template>
<VRow justify="center"> <div>
<VCol <VSkeletonLoader
cols="12" v-if="portal.isLoadingEvents"
lg="10" type="article"
/>
<VAlert
v-else-if="portal.loadError"
type="warning"
variant="tonal"
class="mb-4"
> >
{{ portal.loadError }}
</VAlert>
<template v-else>
<VSkeletonLoader <VSkeletonLoader
v-if="portal.isLoadingEvents" v-if="portal.isLoadingPerson && !portal.currentPerson"
type="article" type="article"
class="mb-4"
/> />
<VAlert <VAlert
v-else-if="portal.loadError" v-else-if="!portal.currentPerson && !portal.isLoadingPerson"
type="warning" type="warning"
variant="tonal" variant="tonal"
class="mb-4" class="mb-4"
> >
{{ portal.loadError }} We konden je registratie voor dit evenement niet ophalen. Controleer of je met het juiste account bent ingelogd,
of probeer het later opnieuw.
</VAlert> </VAlert>
<VAlert <StatusCard
v-else-if="!portal.userEvents.length" v-else
type="info" :variant="statusVariant"
variant="tonal" :event-name="eventTitle"
class="mb-4" :registered-at="registeredAt"
> :next-shift-summary="nextShiftSummary"
Je hebt nog geen evenementen waarvoor je bent aangemeld, of ze zijn niet gekoppeld aan dit account. :upcoming-count="upcomingCount"
Meld je aan via de link van je organisatie, of log in met hetzelfde e-mailadres als bij je aanmelding. @switch-tab="emit('switchTab', $event)"
</VAlert> />
</template>
<template v-else> </div>
<VSkeletonLoader
v-if="portal.isLoadingPerson && !portal.currentPerson"
type="article"
class="mb-4"
/>
<VAlert
v-else-if="!portal.currentPerson && !portal.isLoadingPerson"
type="warning"
variant="tonal"
class="mb-4"
>
We konden je registratie voor dit evenement niet ophalen. Controleer of je met het juiste account bent ingelogd,
of probeer het later opnieuw.
</VAlert>
<StatusCard
:variant="statusVariant"
:event-name="eventTitle"
:registered-at="registeredAt"
:next-shift-summary="nextShiftSummary"
:upcoming-count="upcomingCount"
/>
</template>
</VCol>
</VRow>
</template> </template>

View File

@@ -0,0 +1,436 @@
<script setup lang="ts">
import { useMyShifts, useCancelAssignment } from '@/composables/api/usePortalShifts'
import type { MyShiftAssignment } from '@/types/portal-shift'
const props = defineProps<{
eventId: string
}>()
const emit = defineEmits<{
switchTab: [tab: string]
}>()
const eventIdRef = computed(() => props.eventId as string | null)
const { data: shifts, isLoading, isError, refetch } = useMyShifts(eventIdRef)
const cancelMutation = useCancelAssignment(eventIdRef)
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.'
}
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h5 class="text-h5">
Mijn diensten
</h5>
<VBtn
variant="text"
size="small"
@click="emit('switchTab', 'claimen')"
>
<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">
<h6 class="text-h6 mb-3">
Komende diensten
</h6>
<template v-if="shifts.upcoming.length">
<VCard
v-for="assignment in shifts.upcoming"
:key="assignment.assignment_id"
variant="outlined"
class="mb-3 shift-card"
:class="`shift-card--${assignment.status}`"
>
<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.
<a
href="#"
class="text-primary font-weight-medium"
@click.prevent="emit('switchTab', 'claimen')"
>
Diensten claimen &rarr;
</a>
</VAlert>
</div>
<!-- Past -->
<div
v-if="shifts.past.length"
class="mb-6"
>
<h6 class="text-h6 mb-3">
Afgelopen diensten
</h6>
<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">
<h6 class="text-h6 mb-3">
Geannuleerd / Afgewezen
</h6>
<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>
</div>
</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));
}
.shift-card--rejected {
border-inline-start-color: rgb(var(--v-theme-error));
}
.shift-card--cancelled {
border-inline-start-color: rgb(var(--v-theme-secondary));
}
.shift-card--completed {
border-inline-start-color: rgb(var(--v-theme-info));
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import type { PortalEvent } from '@/types/portal'
const props = defineProps<{
event: PortalEvent
}>()
function statusColor(status: string): string {
if (status === 'approved') return 'success'
if (status === 'pending' || status === 'applied') return 'warning'
if (status === 'invited') return 'info'
if (status === 'rejected') return 'error'
return 'secondary'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'In afwachting',
applied: 'In afwachting',
invited: 'Uitgenodigd',
approved: 'Goedgekeurd',
rejected: 'Afgewezen',
no_show: 'Niet verschenen',
}
return map[status] ?? status
}
function formatDates(startDate: string, endDate: string): string {
try {
const start = new Date(`${startDate}T12:00:00`)
const end = new Date(`${endDate}T12:00:00`)
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }
if (startDate === endDate) {
return start.toLocaleDateString('nl-NL', opts)
}
return `${start.toLocaleDateString('nl-NL', opts)} ${end.toLocaleDateString('nl-NL', opts)}`
}
catch {
return `${startDate} ${endDate}`
}
}
const isPast = computed(() => {
try {
return new Date(`${props.event.end_date}T23:59:59`) < new Date()
}
catch {
return false
}
})
</script>
<template>
<VCard
:to="`/evenementen/${event.event_id}`"
class="h-100 event-card text-decoration-none"
:class="{ 'event-card--past': isPast }"
elevation="1"
>
<!-- Banner placeholder with gradient -->
<div
class="event-card__banner d-flex align-center justify-center"
:style="{
background: `linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgba(var(--v-theme-primary), 0.7) 100%)`,
}"
>
<VIcon
icon="tabler-calendar-event"
size="48"
color="white"
class="event-card__banner-icon"
/>
</div>
<VCardText class="pa-4">
<h6 class="text-subtitle-1 font-weight-bold mb-1 text-high-emphasis">
{{ event.event_name }}
</h6>
<div class="text-body-2 text-medium-emphasis mb-1">
<VIcon
icon="tabler-calendar"
size="14"
class="me-1"
/>
{{ formatDates(event.start_date, event.end_date) }}
</div>
<div
v-if="event.organisation_name"
class="text-caption text-disabled mb-3"
>
{{ event.organisation_name }}
</div>
<VChip
:color="statusColor(event.person_status)"
size="small"
label
variant="tonal"
>
{{ statusLabel(event.person_status) }}
</VChip>
</VCardText>
</VCard>
</template>
<style scoped>
.event-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
overflow: hidden;
}
.event-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
}
.event-card--past {
opacity: 0.6;
}
.event-card__banner {
height: 120px;
position: relative;
}
.event-card__banner-icon {
opacity: 0.3;
}
</style>

View File

@@ -1,123 +0,0 @@
<script setup lang="ts">
import { usePortalStore } from '@/stores/usePortalStore'
const portal = usePortalStore()
const menuOpen = ref(false)
function statusColor(status: string): string {
if (status === 'approved') return 'success'
if (status === 'pending' || status === 'applied' || status === 'invited') return 'warning'
if (status === 'rejected') return 'error'
return 'secondary'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'In behandeling',
applied: 'In behandeling',
invited: 'Uitgenodigd',
approved: 'Goedgekeurd',
rejected: 'Afgewezen',
no_show: 'Niet verschenen',
}
return map[status] ?? status
}
function selectEvent(id: string) {
portal.setActiveEvent(id)
menuOpen.value = false
}
</script>
<template>
<div
v-if="portal.userEvents.length === 0"
class="text-body-2 text-medium-emphasis ms-2 ms-sm-4 d-flex align-center min-w-0"
>
Geen evenement
</div>
<div
v-else-if="portal.userEvents.length === 1 && portal.activeEvent"
class="ms-2 ms-sm-4 d-flex align-center gap-2 min-w-0 flex-grow-1 flex-sm-grow-0"
>
<VIcon
icon="tabler-calendar-event"
size="20"
class="flex-shrink-0"
/>
<span class="text-body-1 font-weight-medium text-truncate">{{ portal.activeEvent.event_name }}</span>
<VChip
:color="statusColor(portal.activeEvent.person_status)"
size="small"
label
class="flex-shrink-0"
>
{{ statusLabel(portal.activeEvent.person_status) }}
</VChip>
</div>
<VMenu
v-else
v-model="menuOpen"
location="bottom"
:close-on-content-click="true"
>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
variant="text"
class="ms-2 ms-sm-4 text-none min-w-0"
rounded="lg"
>
<VIcon
icon="tabler-calendar-event"
start
size="20"
/>
<span class="text-truncate max-w-[200px] sm:max-w-[280px]">{{ portal.activeEvent?.event_name ?? 'Kies evenement' }}</span>
<VIcon
icon="tabler-chevron-down"
end
size="18"
/>
</VBtn>
</template>
<VList
density="compact"
min-width="280"
>
<VListSubheader class="text-caption">
Jouw evenementen
</VListSubheader>
<VListItem
v-for="ev in portal.userEvents"
:key="ev.event_id"
:active="ev.event_id === portal.activeEventId"
@click="selectEvent(ev.event_id)"
>
<VListItemTitle class="text-wrap">
{{ ev.event_name }}
</VListItemTitle>
<VListItemSubtitle class="d-flex flex-column gap-1 mt-1">
<div class="d-flex align-center gap-2 flex-wrap">
<VChip
:color="statusColor(ev.person_status)"
size="x-small"
label
>
{{ statusLabel(ev.person_status) }}
</VChip>
</div>
<span
v-if="ev.organisation_name"
class="text-caption text-medium-emphasis"
>{{ ev.organisation_name }}</span>
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</template>

View File

@@ -8,6 +8,10 @@ const props = defineProps<{
availableCount?: number | null availableCount?: number | null
}>() }>()
const emit = defineEmits<{
switchTab: [tab: string]
}>()
const registeredLabel = computed(() => { const registeredLabel = computed(() => {
if (!props.registeredAt) return null if (!props.registeredAt) return null
try { try {
@@ -97,9 +101,9 @@ const registeredLabel = computed(() => {
sm="4" sm="4"
> >
<VCard <VCard
:to="{ name: 'portal-my-shifts' }"
class="h-100 text-decoration-none portal-action-card" class="h-100 text-decoration-none portal-action-card"
elevation="1" elevation="1"
@click="emit('switchTab', 'rooster')"
> >
<VCardText class="d-flex flex-column align-center text-center pa-4"> <VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon <VIcon
@@ -109,7 +113,7 @@ const registeredLabel = computed(() => {
class="mb-2" class="mb-2"
/> />
<div class="text-subtitle-2 font-weight-bold mb-1"> <div class="text-subtitle-2 font-weight-bold mb-1">
Mijn Diensten Mijn Rooster
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-caption text-medium-emphasis">
Rooster bekijken Rooster bekijken
@@ -122,9 +126,9 @@ const registeredLabel = computed(() => {
sm="4" sm="4"
> >
<VCard <VCard
:to="{ name: 'portal-claim-shifts' }"
class="h-100 text-decoration-none portal-action-card" class="h-100 text-decoration-none portal-action-card"
elevation="1" elevation="1"
@click="emit('switchTab', 'claimen')"
> >
<VCardText class="d-flex flex-column align-center text-center pa-4"> <VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon <VIcon
@@ -147,22 +151,22 @@ const registeredLabel = computed(() => {
sm="4" sm="4"
> >
<VCard <VCard
:to="{ name: 'portal-profiel' }"
class="h-100 text-decoration-none portal-action-card" class="h-100 text-decoration-none portal-action-card"
elevation="1" elevation="1"
@click="emit('switchTab', 'informatie')"
> >
<VCardText class="d-flex flex-column align-center text-center pa-4"> <VCardText class="d-flex flex-column align-center text-center pa-4">
<VIcon <VIcon
icon="tabler-user" icon="tabler-info-circle"
size="28" size="28"
color="primary" color="primary"
class="mb-2" class="mb-2"
/> />
<div class="text-subtitle-2 font-weight-bold mb-1"> <div class="text-subtitle-2 font-weight-bold mb-1">
Mijn Profiel Informatie
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-caption text-medium-emphasis">
Gegevens bekijken Evenement details
</div> </div>
</VCardText> </VCardText>
</VCard> </VCard>
@@ -184,12 +188,13 @@ const registeredLabel = computed(() => {
class="text-body-2 text-medium-emphasis mb-0" class="text-body-2 text-medium-emphasis mb-0"
> >
Nog geen diensten ingepland. Nog geen diensten ingepland.
<RouterLink <a
:to="{ name: 'portal-claim-shifts' }" href="#"
class="text-primary font-weight-medium" class="text-primary font-weight-medium"
@click.prevent="emit('switchTab', 'claimen')"
> >
Diensten claimen Diensten claimen
</RouterLink> </a>
</p> </p>
<!-- Quick stats --> <!-- Quick stats -->
@@ -209,10 +214,11 @@ const registeredLabel = computed(() => {
/> />
Diensten ingepland: <strong>{{ upcomingCount }}</strong> Diensten ingepland: <strong>{{ upcomingCount }}</strong>
</div> </div>
<RouterLink <a
v-if="availableCount !== null && availableCount !== undefined" v-if="availableCount !== null && availableCount !== undefined"
:to="{ name: 'portal-claim-shifts' }" href="#"
class="text-body-2 text-primary text-decoration-none" class="text-body-2 text-primary text-decoration-none"
@click.prevent="emit('switchTab', 'claimen')"
> >
<VIcon <VIcon
icon="tabler-calendar-plus" icon="tabler-calendar-plus"
@@ -220,7 +226,7 @@ const registeredLabel = computed(() => {
class="me-1" class="me-1"
/> />
Beschikbare diensten bekijken Beschikbare diensten bekijken
</RouterLink> </a>
</div> </div>
</template> </template>
</VCard> </VCard>

View File

@@ -61,6 +61,13 @@ async function logout() {
title="Mijn Profiel" title="Mijn Profiel"
/> />
<!-- Events link -->
<VListItem
to="/evenementen"
prepend-icon="tabler-calendar-event"
title="Mijn evenementen"
/>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- Logout --> <!-- Logout -->

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import EventSwitcher from '@/components/portal/EventSwitcher.vue'
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue' import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
import { useAuthStore } from '@/stores/useAuthStore' import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore' import { usePortalStore } from '@/stores/usePortalStore'
@@ -14,41 +13,20 @@ const route = useRoute()
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const hideEventMenu = computed(() => route.meta.hideEventMenu === true) // Navbar mode: 'event' shows org name + event name + back link
// Default ('platform') shows Crewli logo + page title
const isEventMode = computed(() => route.meta.navMode === 'event')
const navTitle = computed(() => (route.meta as any).navTitle as string | undefined)
const isApproved = computed(() => portal.currentPerson?.status === 'approved') const eventName = computed(() => portal.activeEvent?.event_name ?? '')
const orgName = computed(() => portal.activeEvent?.organisation_name ?? '')
const hasActiveEvent = computed(() => !!portal.activeEventId) // Mobile nav items
const showEventMenu = computed(() => {
if (hideEventMenu.value) return false
if (!hasActiveEvent.value) return false
return true
})
const menuItems = computed(() => {
if (!showEventMenu.value) return []
const items = [
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-layout-dashboard' },
]
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' },
)
}
return items
})
// Mobile drawer items include profile + logout
const mobileNavItems = computed(() => { const mobileNavItems = computed(() => {
const items = [...menuItems.value] const items = [
{ title: 'Mijn evenementen', to: '/evenementen', icon: 'tabler-calendar-event' },
items.push({ title: 'Mijn Profiel', to: '/profiel', icon: 'tabler-user' }) { title: 'Mijn Profiel', to: '/profiel', icon: 'tabler-user' },
]
return items return items
}) })
@@ -89,46 +67,73 @@ async function logout() {
class="d-flex align-center py-0" class="d-flex align-center py-0"
style="max-inline-size: 1440px;" style="max-inline-size: 1440px;"
> >
<!-- Left section: Logo + Event Switcher --> <!-- Event mode: Org name + Event name + Back link -->
<RouterLink <template v-if="isEventMode">
to="/dashboard" <!-- Org name / logo placeholder -->
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0" <div class="d-flex align-center gap-x-2 flex-shrink-0">
> <VIcon
<VIcon icon="tabler-building"
icon="tabler-users-group" size="24"
size="26" color="primary"
color="primary" />
/> <span
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline"> v-if="orgName"
Crewli class="text-subtitle-1 font-weight-medium text-high-emphasis d-none d-sm-inline text-truncate"
style="max-width: 200px;"
>
{{ orgName }}
</span>
</div>
<!-- Event name -->
<span
v-if="eventName"
class="text-body-1 text-medium-emphasis ms-2 text-truncate d-none d-sm-inline"
style="max-width: 250px;"
>
{{ eventName }}
</span> </span>
</RouterLink>
<EventSwitcher class="min-w-0 flex-grow-1 flex-sm-grow-0" /> <!-- Back link -->
<!-- Center section: Desktop menu items -->
<div
v-if="menuItems.length > 0"
class="d-none d-md-flex align-center gap-1 ms-4"
>
<VBtn <VBtn
v-for="item in menuItems"
:key="item.to"
:to="item.to"
variant="text" variant="text"
color="default"
size="small" size="small"
exact color="default"
class="portal-nav-btn" class="text-medium-emphasis ms-2 d-none d-md-flex"
to="/evenementen"
> >
<VIcon <VIcon
start start
:icon="item.icon" icon="tabler-arrow-left"
size="18" size="16"
/> />
{{ item.title }} Evenementen
</VBtn> </VBtn>
</div> </template>
<!-- Platform mode: Crewli logo + optional page title -->
<template v-else>
<RouterLink
to="/evenementen"
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
>
<VIcon
icon="tabler-users-group"
size="26"
color="primary"
/>
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
Crewli
</span>
</RouterLink>
<span
v-if="navTitle"
class="text-body-1 text-medium-emphasis ms-4 d-none d-md-inline"
>
{{ navTitle }}
</span>
</template>
<VSpacer /> <VSpacer />
@@ -226,31 +231,3 @@ async function logout() {
</VFooter> </VFooter>
</VApp> </VApp>
</template> </template>
<style scoped>
.portal-nav-btn {
position: relative;
font-weight: 500;
letter-spacing: 0.01em;
}
.portal-nav-btn::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: rgb(var(--v-theme-primary));
transition: width 0.2s ease, left 0.2s ease;
}
.portal-nav-btn.router-link-active {
color: rgb(var(--v-theme-primary)) !important;
}
.portal-nav-btn.router-link-active::after {
width: 60%;
left: 20%;
}
</style>

View File

@@ -1,358 +0,0 @@
<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'
}
// Portal hydration now happens automatically in the router guard
</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"
>
<div class="d-flex align-center gap-2 mb-3">
<VIcon
icon="tabler-calendar"
size="20"
color="primary"
/>
<h5 class="text-h5 mb-0">
{{ day.date_label }}
</h5>
</div>
<div
v-for="slot in day.time_slots"
:key="slot.time_slot_id"
class="mb-4"
>
<div class="d-flex align-center gap-2 text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
<VIcon
icon="tabler-clock"
size="16"
/>
{{ slot.name }} &middot; {{ 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 claim-card"
:class="{ 'claim-card--conflict': shift.has_conflict }"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="shift.section_icon"
:icon="shift.section_icon"
size="24"
:color="shift.has_conflict ? 'disabled' : 'primary'"
/>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ shift.title }}
</VCardTitle>
<VCardSubtitle>{{ shift.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="availabilityColor(shift.slots_available)"
size="small"
variant="tonal"
>
{{ shift.slots_available }}/{{ shift.slots_open_for_claiming }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2 mb-2">
<span v-if="shift.location_name">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ shift.location_name }}
</span>
<span v-if="shift.report_time">
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
Aanwezig: {{ shift.report_time }}
</span>
</div>
<!-- Availability progress -->
<div class="mb-2">
<div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
<span>{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar</span>
</div>
<VProgressLinear
:model-value="((shift.slots_open_for_claiming - shift.slots_available) / shift.slots_open_for_claiming) * 100"
:color="availabilityColor(shift.slots_available)"
height="6"
rounded
/>
</div>
<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"
>
<template #prepend>
<VIcon
icon="tabler-alert-triangle"
size="18"
/>
</template>
{{ shift.conflict_reason }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
color="primary"
:variant="shift.has_conflict ? 'tonal' : 'elevated'"
: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>
<style scoped>
.claim-card--conflict {
opacity: 0.6;
}
</style>

View File

@@ -1,444 +0,0 @@
<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.'
}
}
// Portal hydration now happens automatically in the router guard
</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 shift-card"
:class="`shift-card--${assignment.status}`"
>
<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>
<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));
}
.shift-card--rejected {
border-inline-start-color: rgb(var(--v-theme-error));
}
.shift-card--cancelled {
border-inline-start-color: rgb(var(--v-theme-secondary));
}
.shift-card--completed {
border-inline-start-color: rgb(var(--v-theme-info));
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import OverzichtTab from '@/components/event/OverzichtTab.vue'
import RoosterTab from '@/components/event/RoosterTab.vue'
import ClaimenTab from '@/components/event/ClaimenTab.vue'
import InformatieTab from '@/components/event/InformatieTab.vue'
import { usePortalStore } from '@/stores/usePortalStore'
definePage({
name: 'portal-event-detail',
meta: {
layout: 'portal',
requiresAuth: true,
navMode: 'event',
},
})
const route = useRoute('portal-event-detail')
const router = useRouter()
const portal = usePortalStore()
const eventId = computed(() => route.params.eventId as string)
const isApproved = computed(() => portal.currentPerson?.status === 'approved')
// Sync the store's active event with the route param
watch(eventId, (id) => {
if (id && id !== portal.activeEventId) {
portal.setActiveEvent(id)
}
}, { immediate: true })
// Tab definitions
const allTabs = computed(() => {
const tabs = [
{ value: 'overzicht', label: 'Overzicht', icon: 'tabler-home' },
]
if (isApproved.value) {
tabs.push(
{ value: 'rooster', label: 'Mijn rooster', icon: 'tabler-calendar-check' },
{ value: 'claimen', label: 'Diensten claimen', icon: 'tabler-calendar-plus' },
)
}
tabs.push({ value: 'informatie', label: 'Informatie', icon: 'tabler-info-circle' })
return tabs
})
// Hash-based tab navigation
const activeTab = computed({
get() {
const hash = route.hash?.replace('#', '') || 'overzicht'
// Validate the hash is one of the valid tab values
if (allTabs.value.some(t => t.value === hash)) return hash
return 'overzicht'
},
set(tab: string) {
router.replace({ hash: `#${tab}` })
},
})
function switchTab(tab: string) {
activeTab.value = tab
}
// Validate the event exists in the user's events
const eventExists = computed(() =>
portal.userEvents.some(e => e.event_id === eventId.value),
)
const eventLoaded = computed(() => portal.isHydrated && !portal.isLoadingEvents)
</script>
<template>
<div>
<!-- Loading -->
<template v-if="!eventLoaded">
<VSkeletonLoader
type="heading"
class="mb-4"
/>
<VSkeletonLoader type="article" />
</template>
<!-- Event not found -->
<VCard
v-else-if="!eventExists"
variant="flat"
class="text-center pa-8"
>
<VAvatar
size="80"
color="warning"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-alert-triangle"
size="40"
/>
</VAvatar>
<h5 class="text-h5 mb-2">
Evenement niet gevonden
</h5>
<p class="text-body-1 text-medium-emphasis mb-4">
Dit evenement bestaat niet of je hebt er geen toegang toe.
</p>
<VBtn
color="primary"
to="/evenementen"
>
Terug naar mijn evenementen
</VBtn>
</VCard>
<!-- Event content with tabs -->
<template v-else>
<VTabs
v-model="activeTab"
class="mb-6"
>
<VTab
v-for="tab in allTabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
start
:icon="tab.icon"
size="18"
/>
{{ tab.label }}
</VTab>
</VTabs>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
>
<VWindowItem value="overzicht">
<OverzichtTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem
v-if="isApproved"
value="rooster"
>
<RoosterTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem
v-if="isApproved"
value="claimen"
>
<ClaimenTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem value="informatie">
<InformatieTab :event-id="eventId" />
</VWindowItem>
</VWindow>
</template>
</div>
</template>
<style scoped>
.disable-tab-transition :deep(.v-window__container) {
transition: none !important;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import EventCard from '@/components/portal/EventCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
definePage({
name: 'portal-evenementen',
meta: {
layout: 'portal',
requiresAuth: true,
navMode: 'platform',
navTitle: 'Mijn evenementen',
},
})
const portal = usePortalStore()
const sortedEvents = computed(() => {
const now = new Date()
const events = [...portal.userEvents]
return events.sort((a, b) => {
const aEnd = new Date(`${a.end_date}T23:59:59`)
const bEnd = new Date(`${b.end_date}T23:59:59`)
const aIsPast = aEnd < now
const bIsPast = bEnd < now
// Upcoming first, past last
if (aIsPast !== bIsPast) return aIsPast ? 1 : -1
// Within same group, sort by start_date ascending (soonest first)
return a.start_date.localeCompare(b.start_date)
})
})
</script>
<template>
<div>
<!-- Loading -->
<template v-if="portal.isLoadingEvents">
<VRow>
<VCol
v-for="n in 3"
:key="n"
cols="12"
sm="6"
md="4"
>
<VSkeletonLoader type="card" />
</VCol>
</VRow>
</template>
<!-- Error -->
<VAlert
v-else-if="portal.loadError"
type="warning"
variant="tonal"
class="mb-4"
>
{{ portal.loadError }}
</VAlert>
<!-- Empty state -->
<VCard
v-else-if="!sortedEvents.length"
variant="flat"
class="text-center pa-8 pa-sm-12"
>
<VAvatar
size="80"
color="primary"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-calendar-off"
size="40"
/>
</VAvatar>
<h5 class="text-h5 mb-2">
Geen evenementen
</h5>
<p class="text-body-1 text-medium-emphasis mb-0">
Je bent nog niet aangemeld voor een evenement.
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Heb je een uitnodiging ontvangen? Neem contact op met de organisatie.
</p>
</VCard>
<!-- Event grid -->
<VRow v-else>
<VCol
v-for="ev in sortedEvents"
:key="ev.event_id"
cols="12"
sm="6"
md="4"
>
<EventCard :event="ev" />
</VCol>
</VRow>
</div>
</template>

View File

@@ -18,7 +18,7 @@ onMounted(async () => {
} }
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
router.replace('/dashboard') router.replace('/evenementen')
} }
}) })
</script> </script>

View File

@@ -58,10 +58,22 @@ async function onSubmit(): Promise<void> {
// Navigate after login — outside try/catch so navigation errors // Navigate after login — outside try/catch so navigation errors
// (e.g. stale dynamic imports) don't mask a successful login. // (e.g. stale dynamic imports) don't mask a successful login.
const redirect = typeof route.query.to === 'string' ? route.query.to : '/dashboard' let redirect = typeof route.query.to === 'string' ? route.query.to : ''
router.replace(redirect || '/dashboard').catch(() => {
// Smart redirect based on number of events
if (!redirect) {
const events = portalStore.userEvents
if (events.length === 1) {
redirect = `/evenementen/${events[0]!.event_id}`
}
else {
redirect = '/evenementen'
}
}
router.replace(redirect).catch(() => {
// Dynamic import can fail after Vite HMR; a full reload recovers. // Dynamic import can fail after Vite HMR; a full reload recovers.
window.location.href = redirect || '/dashboard' window.location.href = redirect
}) })
} }
</script> </script>

View File

@@ -8,7 +8,8 @@ definePage({
meta: { meta: {
layout: 'portal', layout: 'portal',
requiresAuth: true, requiresAuth: true,
hideEventMenu: true, navMode: 'platform',
navTitle: 'Mijn profiel',
}, },
}) })
@@ -94,8 +95,7 @@ function formatEventDates(startDate: string, endDate: string): string {
} }
function viewEvent(eventId: string) { function viewEvent(eventId: string) {
portal.setActiveEvent(eventId) router.push(`/evenementen/${eventId}`)
router.push('/dashboard')
} }
async function saveProfile() { async function saveProfile() {

View File

@@ -23,7 +23,7 @@ definePage({
meta: { meta: {
layout: 'portal', layout: 'portal',
requiresAuth: false, requiresAuth: false,
hideEventMenu: true, navMode: 'platform',
}, },
}) })

View File

@@ -6,7 +6,7 @@ definePage({
meta: { meta: {
layout: 'portal', layout: 'portal',
requiresAuth: false, requiresAuth: false,
hideEventMenu: true, navMode: 'platform',
}, },
}) })
@@ -83,11 +83,11 @@ const isAuthenticated = computed(() => route.query.authenticated === '1' || auth
<div class="d-flex flex-wrap justify-center gap-4"> <div class="d-flex flex-wrap justify-center gap-4">
<VBtn <VBtn
v-if="isAuthenticated" v-if="isAuthenticated"
to="/dashboard" to="/evenementen"
color="primary" color="primary"
prepend-icon="tabler-dashboard" prepend-icon="tabler-calendar-event"
> >
Ga naar je dashboard Ga naar je evenementen
</VBtn> </VBtn>
<VBtn <VBtn

View File

@@ -4,6 +4,13 @@ import { usePortalStore } from '@/stores/usePortalStore'
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten'] const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
// Old dashboard routes that need backward-compat redirects
const dashboardRedirects: Record<string, string> = {
'/dashboard': '/evenementen',
'/dashboard/my-shifts': '/evenementen',
'/dashboard/claim-shifts': '/evenementen',
}
export function setupGuards(router: Router) { export function setupGuards(router: Router) {
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -18,12 +25,18 @@ export function setupGuards(router: Router) {
await portalStore.hydrateIfNeeded() await portalStore.hydrateIfNeeded()
} }
// Backward-compat redirects for old dashboard routes
const redirect = dashboardRedirects[to.path]
if (redirect && authStore.isAuthenticated) {
return { path: redirect }
}
const requiresAuth = to.meta.requiresAuth === true const requiresAuth = to.meta.requiresAuth === true
// Public routes — no auth check needed // Public routes — no auth check needed
if (!requiresAuth) { if (!requiresAuth) {
if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p || to.path.startsWith(`${p}/`))) { if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p || to.path.startsWith(`${p}/`))) {
return { path: '/dashboard' } return { path: '/evenementen' }
} }
return return

View File

@@ -21,9 +21,8 @@ declare module 'vue-router/auto-routes' {
'root': RouteRecordInfo<'root', '/', Record<never, never>, Record<never, never>>, 'root': RouteRecordInfo<'root', '/', Record<never, never>, Record<never, never>>,
'not-found': RouteRecordInfo<'not-found', '/:path(.*)', { path: ParamValue<true> }, { path: ParamValue<false> }>, '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> }>, '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-evenementen': RouteRecordInfo<'portal-evenementen', '/evenementen', Record<never, never>, Record<never, never>>,
'portal-claim-shifts': RouteRecordInfo<'portal-claim-shifts', '/dashboard/claim-shifts', Record<never, never>, Record<never, never>>, 'portal-event-detail': RouteRecordInfo<'portal-event-detail', '/evenementen/:eventId', { eventId: ParamValue<true> }, { eventId: ParamValue<false> }>,
'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>>, 'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
'portal-profiel': RouteRecordInfo<'portal-profiel', '/profiel', Record<never, never>, Record<never, never>>, 'portal-profiel': RouteRecordInfo<'portal-profiel', '/profiel', Record<never, never>, Record<never, never>>,
'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>, 'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>,