refactor(portal): move components to shared/public-form and portal/{event,*}
- public-form/** (18 files + 7 component tests) → shared/public-form/**
This is the runtime form-renderer; goes into shared/ because it will
be reused by the organizer-app Form Builder preview (S3b).
- event/{Claimen,Informatie,Overzicht,Rooster}Tab.vue → portal/event/**
- portal/{StatusCard,EventCard,UserAvatarMenu}.vue → portal/** (no
path change — both apps had a portal/ subfolder).
- AppLoadingIndicator.vue, auth/{PasswordRequirements,MfaChallengeCard}.vue,
settings/Mfa{Disable,Email,Totp}SetupDialog.vue: portal copies
deleted as duplicates of pre-existing apps/app components (diffs
were trivial formatting only).
Inside the moved files: rewrote @form-schema/* → @/composables/forms/*
and @/components/{public-form,event/[Tab]} → new sub-zone paths.
Updated apps/app/tsconfig.json to drop the @form-schema path alias
and the packages/form-schema include path. Updated formSchema.ts to
import from @/composables/forms/types/formBuilder. Carried the
crypto polyfill from apps/portal/tests/setup.ts into
apps/app/tests/setup.ts (needed by useFormDraft tests landing in C.4).
NOTE: Some moved tests still fail because they reference portal
composables (usePublicFormSections, useFormDraft) that move in C.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
136
apps/app/src/components/portal/EventCard.vue
Normal file
136
apps/app/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>
|
||||
245
apps/app/src/components/portal/StatusCard.vue
Normal file
245
apps/app/src/components/portal/StatusCard.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
variant: 'pending' | 'approved' | 'rejected'
|
||||
eventName: string
|
||||
registeredAt?: string | null
|
||||
nextShiftSummary?: string | null
|
||||
upcomingCount?: number
|
||||
availableCount?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
switchTab: [tab: string]
|
||||
}>()
|
||||
|
||||
const registeredLabel = computed(() => {
|
||||
if (!props.registeredAt) return null
|
||||
try {
|
||||
return new Date(props.registeredAt).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
variant="tonal"
|
||||
:color="variant === 'approved' ? 'success' : variant === 'pending' ? 'warning' : 'error'"
|
||||
class="pa-6"
|
||||
>
|
||||
<template v-if="variant === 'pending'">
|
||||
<div class="d-flex align-start gap-3">
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="32"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="text-h5 mb-2">
|
||||
Je registratie wordt beoordeeld
|
||||
</h5>
|
||||
<p class="text-body-1 mb-2">
|
||||
Je hebt je aangemeld voor <strong>{{ eventName }}</strong>.
|
||||
De organisatie beoordeelt je registratie.
|
||||
</p>
|
||||
<p class="text-body-1 mb-2">
|
||||
Je ontvangt een e-mail zodra er een besluit is.
|
||||
</p>
|
||||
<p
|
||||
v-if="registeredLabel"
|
||||
class="text-body-2 text-medium-emphasis mb-0"
|
||||
>
|
||||
Aangemeld op: {{ registeredLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="variant === 'rejected'">
|
||||
<div class="d-flex align-start gap-3">
|
||||
<VIcon
|
||||
icon="tabler-circle-x"
|
||||
size="32"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="text-h5 mb-2">
|
||||
Je aanmelding is niet geselecteerd
|
||||
</h5>
|
||||
<p class="text-body-1 mb-0">
|
||||
Helaas is je aanmelding voor <strong>{{ eventName }}</strong> niet geselecteerd.
|
||||
Neem contact op met de organisatie als je vragen hebt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="d-flex align-start gap-3 mb-6">
|
||||
<VIcon
|
||||
icon="tabler-circle-check"
|
||||
size="32"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="text-h5 mb-1">
|
||||
Welkom bij {{ eventName }}!
|
||||
</h5>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
Je bent goedgekeurd als vrijwilliger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick action cards -->
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
@click="emit('switchTab', 'rooster')"
|
||||
>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-calendar-check"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Mijn Rooster
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Rooster bekijken
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
@click="emit('switchTab', 'claimen')"
|
||||
>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-calendar-plus"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Diensten Claimen
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Schrijf je in
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
@click="emit('switchTab', 'informatie')"
|
||||
>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-info-circle"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Informatie
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Evenement details
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Upcoming shift -->
|
||||
<div class="text-subtitle-1 font-weight-bold mb-2">
|
||||
Komende dienst
|
||||
</div>
|
||||
<p
|
||||
v-if="nextShiftSummary"
|
||||
class="text-body-1 mb-0"
|
||||
>
|
||||
{{ nextShiftSummary }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-body-2 text-medium-emphasis mb-0"
|
||||
>
|
||||
Nog geen diensten ingepland.
|
||||
<a
|
||||
href="#"
|
||||
class="text-primary font-weight-medium"
|
||||
@click.prevent="emit('switchTab', 'claimen')"
|
||||
>
|
||||
Diensten claimen
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div
|
||||
v-if="upcomingCount !== undefined || availableCount !== null"
|
||||
class="d-flex flex-wrap gap-4 mt-4 pt-4"
|
||||
style="border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));"
|
||||
>
|
||||
<div
|
||||
v-if="upcomingCount !== undefined"
|
||||
class="text-body-2"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-calendar-check"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Diensten ingepland: <strong>{{ upcomingCount }}</strong>
|
||||
</div>
|
||||
<a
|
||||
v-if="availableCount !== null && availableCount !== undefined"
|
||||
href="#"
|
||||
class="text-body-2 text-primary text-decoration-none"
|
||||
@click.prevent="emit('switchTab', 'claimen')"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-calendar-plus"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Beschikbare diensten bekijken
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portal-action-card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-action-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
82
apps/app/src/components/portal/UserAvatarMenu.vue
Normal file
82
apps/app/src/components/portal/UserAvatarMenu.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const user = authStore.user
|
||||
if (!user) return '?'
|
||||
const first = user.first_name?.charAt(0) ?? ''
|
||||
const last = user.last_name?.charAt(0) ?? ''
|
||||
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
const userFullName = computed(() => authStore.user?.full_name ?? '')
|
||||
const userEmail = computed(() => authStore.user?.email ?? '')
|
||||
|
||||
async function logout() {
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
location="bottom end"
|
||||
:close-on-content-click="false"
|
||||
min-width="220"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<VAvatar
|
||||
v-bind="menuProps"
|
||||
size="36"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span class="text-body-2 font-weight-medium text-white">
|
||||
{{ userInitials }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<VList density="compact">
|
||||
<!-- User info -->
|
||||
<VListItem class="pb-0">
|
||||
<VListItemTitle class="font-weight-bold text-body-1">
|
||||
{{ userFullName }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ userEmail }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- Profile link -->
|
||||
<VListItem
|
||||
to="/profiel"
|
||||
prepend-icon="tabler-user"
|
||||
title="Mijn Profiel"
|
||||
/>
|
||||
|
||||
<!-- Events link -->
|
||||
<VListItem
|
||||
to="/evenementen"
|
||||
prepend-icon="tabler-calendar-event"
|
||||
title="Mijn evenementen"
|
||||
/>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- Logout -->
|
||||
<VListItem
|
||||
prepend-icon="tabler-logout"
|
||||
title="Uitloggen"
|
||||
class="text-error"
|
||||
@click="logout"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
352
apps/app/src/components/portal/event/ClaimenTab.vue
Normal file
352
apps/app/src/components/portal/event/ClaimenTab.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import { useAvailableShifts, useClaimShift } from '@/composables/api/usePortalShifts'
|
||||
import type { AvailableShift } from '@/types/portal-shift'
|
||||
import axios from 'axios'
|
||||
import type { ApiErrorResponse } from '@/types/api'
|
||||
|
||||
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: unknown) {
|
||||
claimError.value = axios.isAxiosError<ApiErrorResponse>(err)
|
||||
? err.response?.data?.message ?? 'Er is een fout opgetreden.'
|
||||
: 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
|
||||
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/app/src/components/portal/event/InformatieTab.vue
Normal file
93
apps/app/src/components/portal/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>
|
||||
123
apps/app/src/components/portal/event/OverzichtTab.vue
Normal file
123
apps/app/src/components/portal/event/OverzichtTab.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import StatusCard from '@/components/portal/StatusCard.vue'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
import { useMyShifts } from '@/composables/api/usePortalShifts'
|
||||
import type { PortalPersonPayload } from '@/types/portal'
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
switchTab: [tab: string]
|
||||
}>()
|
||||
|
||||
const portal = usePortalStore()
|
||||
const eventIdRef = computed(() => props.eventId as string | null)
|
||||
|
||||
const { data: shifts } = useMyShifts(eventIdRef)
|
||||
|
||||
const effectiveStatus = computed(() => {
|
||||
const fromPerson = portal.currentPerson?.status
|
||||
if (fromPerson) return fromPerson
|
||||
|
||||
return portal.activeEvent?.person_status ?? 'pending'
|
||||
})
|
||||
|
||||
const statusVariant = computed((): 'pending' | 'approved' | 'rejected' => {
|
||||
const s = effectiveStatus.value
|
||||
if (s === 'approved') return 'approved'
|
||||
if (s === 'rejected') return 'rejected'
|
||||
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const eventTitle = computed(() => portal.activeEvent?.event_name ?? 'dit evenement')
|
||||
|
||||
const registeredAt = computed(() => portal.currentPerson?.created_at ?? null)
|
||||
|
||||
const upcomingCount = computed(() => shifts.value?.upcoming.length ?? 0)
|
||||
|
||||
function formatNextShift(person: PortalPersonPayload | null): string | null {
|
||||
const list = person?.shift_assignments
|
||||
if (!list?.length) return null
|
||||
|
||||
const usable = list.filter(
|
||||
a => a.shift?.time_slot?.date && (a.status === 'approved' || a.status === 'pending_approval'),
|
||||
)
|
||||
if (!usable.length) return null
|
||||
|
||||
usable.sort((a, b) => {
|
||||
const da = a.shift?.time_slot?.date ?? ''
|
||||
const db = b.shift?.time_slot?.date ?? ''
|
||||
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
|
||||
const a = usable[0]!
|
||||
const slot = a.shift?.time_slot
|
||||
const section = a.shift?.festival_section?.name
|
||||
if (!slot?.date) return null
|
||||
|
||||
const dateStr = new Date(`${slot.date}T12:00:00`).toLocaleDateString('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
const start = slot.start_time?.slice(0, 5) ?? ''
|
||||
const end = slot.end_time?.slice(0, 5) ?? ''
|
||||
const timePart = start && end ? `${start} – ${end}` : start || ''
|
||||
|
||||
const place = section ? ` — ${section}` : ''
|
||||
|
||||
return `${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
|
||||
}
|
||||
|
||||
const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VSkeletonLoader
|
||||
v-if="portal.isLoadingEvents"
|
||||
type="article"
|
||||
/>
|
||||
|
||||
<VAlert
|
||||
v-else-if="portal.loadError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ portal.loadError }}
|
||||
</VAlert>
|
||||
|
||||
<template v-else>
|
||||
<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
|
||||
v-else
|
||||
:variant="statusVariant"
|
||||
:event-name="eventTitle"
|
||||
:registered-at="registeredAt"
|
||||
:next-shift-summary="nextShiftSummary"
|
||||
:upcoming-count="upcomingCount"
|
||||
@switch-tab="emit('switchTab', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
440
apps/app/src/components/portal/event/RoosterTab.vue
Normal file
440
apps/app/src/components/portal/event/RoosterTab.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyShifts, useCancelAssignment } from '@/composables/api/usePortalShifts'
|
||||
import type { MyShiftAssignment } from '@/types/portal-shift'
|
||||
import axios from 'axios'
|
||||
import type { ApiErrorResponse } from '@/types/api'
|
||||
|
||||
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: unknown) {
|
||||
cancelError.value = axios.isAxiosError<ApiErrorResponse>(err)
|
||||
? err.response?.data?.message ?? 'Er is een fout opgetreden.'
|
||||
: '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>
|
||||
Reference in New Issue
Block a user