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:
6
apps/portal/components.d.ts
vendored
6
apps/portal/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
2
apps/portal/env.d.ts
vendored
2
apps/portal/env.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
349
apps/portal/src/components/event/ClaimenTab.vue
Normal file
349
apps/portal/src/components/event/ClaimenTab.vue
Normal 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 }} · {{ 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>
|
||||||
93
apps/portal/src/components/event/InformatieTab.vue
Normal file
93
apps/portal/src/components/event/InformatieTab.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
436
apps/portal/src/components/event/RoosterTab.vue
Normal file
436
apps/portal/src/components/event/RoosterTab.vue
Normal 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 →
|
||||||
|
</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> —
|
||||||
|
{{ 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>
|
||||||
136
apps/portal/src/components/portal/EventCard.vue
Normal file
136
apps/portal/src/components/portal/EventCard.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 }} · {{ 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>
|
|
||||||
@@ -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 →
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
182
apps/portal/src/pages/evenementen/[eventId].vue
Normal file
182
apps/portal/src/pages/evenementen/[eventId].vue
Normal 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>
|
||||||
105
apps/portal/src/pages/evenementen/index.vue
Normal file
105
apps/portal/src/pages/evenementen/index.vue
Normal 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>
|
||||||
@@ -18,7 +18,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
router.replace('/dashboard')
|
router.replace('/evenementen')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ definePage({
|
|||||||
meta: {
|
meta: {
|
||||||
layout: 'portal',
|
layout: 'portal',
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
hideEventMenu: true,
|
navMode: 'platform',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
5
apps/portal/typed-router.d.ts
vendored
5
apps/portal/typed-router.d.ts
vendored
@@ -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> }>,
|
||||||
|
|||||||
Reference in New Issue
Block a user