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>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
data: PublicFormSubmissionDuplicate | null
|
||||
}>()
|
||||
|
||||
// Backend is the single source of truth for copy (see
|
||||
// PublicFormSubmissionResource::formatDuplicateSubmission for plural
|
||||
// agreement + Dutch long date formatting). Frontend keeps a fallback
|
||||
// for the three pieces the backend always sets, so a future response
|
||||
// that trims `title` / `body` still renders a coherent hint.
|
||||
const FALLBACK_TITLE = 'Je hebt je eerder al aangemeld'
|
||||
|
||||
const dutchDateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
function formatDutchDate(iso: string): string {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
return dutchDateFormatter.format(new Date(iso))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackBody(data: PublicFormSubmissionDuplicate): string {
|
||||
const date = formatDutchDate(data.first_submitted_at)
|
||||
|
||||
return data.count === 1
|
||||
? `Op ${date} heb je dit formulier ook al ingevuld. De organisator ziet beide aanmeldingen en neemt zo snel mogelijk contact op.`
|
||||
: `Je hebt dit formulier al ${data.count} keer eerder ingevuld (voor het eerst op ${date}). De organisator ziet alle aanmeldingen en neemt zo snel mogelijk contact op.`
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.data) return ''
|
||||
|
||||
return props.data.title?.trim() || FALLBACK_TITLE
|
||||
})
|
||||
|
||||
const body = computed(() => {
|
||||
if (!props.data) return ''
|
||||
const fromBackend = props.data.body?.trim()
|
||||
if (fromBackend) return fromBackend
|
||||
|
||||
return fallbackBody(props.data)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAlert
|
||||
v-if="data"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
prominent
|
||||
class="duplicate-submission-hint mb-4"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ body }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const token = usePublicFormToken()
|
||||
const { data: slots, isLoading, isError, refetch } = usePublicFormTimeSlots(token)
|
||||
|
||||
const selected = computed<string[]>(() =>
|
||||
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !slots.value || slots.value.length === 0)
|
||||
|
||||
const hasMultipleEvents = computed(() => {
|
||||
if (!slots.value) return false
|
||||
|
||||
return new Set(slots.value.map(s => s.event_id)).size > 1
|
||||
})
|
||||
|
||||
interface DateGroup {
|
||||
date: string
|
||||
label: string
|
||||
events: Array<{ eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
|
||||
function formatDateLabel(iso: string): string {
|
||||
try {
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
const parts = dateFormatter.format(d)
|
||||
|
||||
return parts.charAt(0).toUpperCase() + parts.slice(1)
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function stripSeconds(t: string): string {
|
||||
// "08:00:00" → "08:00"
|
||||
const parts = t.split(':')
|
||||
|
||||
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : t
|
||||
}
|
||||
|
||||
const groups = computed<DateGroup[]>(() => {
|
||||
const data = slots.value ?? []
|
||||
|
||||
// Group by date → then by event_id within the date. Preserve the order
|
||||
// the server already sorted in (by date asc, start_time asc).
|
||||
const byDate = new Map<string, Map<string, { eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>>()
|
||||
|
||||
for (const slot of data) {
|
||||
let events = byDate.get(slot.date)
|
||||
if (!events) {
|
||||
events = new Map()
|
||||
byDate.set(slot.date, events)
|
||||
}
|
||||
|
||||
let bucket = events.get(slot.event_id)
|
||||
if (!bucket) {
|
||||
bucket = { eventId: slot.event_id, eventName: slot.event_name, slots: [] }
|
||||
events.set(slot.event_id, bucket)
|
||||
}
|
||||
bucket.slots.push(slot)
|
||||
}
|
||||
|
||||
return Array.from(byDate.entries()).map(([date, eventsMap]) => ({
|
||||
date,
|
||||
label: formatDateLabel(date),
|
||||
events: Array.from(eventsMap.values()),
|
||||
}))
|
||||
})
|
||||
|
||||
function isChecked(id: string): boolean {
|
||||
return selected.value.includes(id)
|
||||
}
|
||||
|
||||
function toggle(id: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(id)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(id)
|
||||
} else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="article"
|
||||
/>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
|
||||
<span>Kon beschikbaarheidsopties niet laden.</span>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
Er zijn nog geen tijdsloten beschikbaar.
|
||||
</VAlert>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="availability-groups"
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.date"
|
||||
class="availability-date-group mb-4"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-2">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="ev in group.events"
|
||||
:key="`${group.date}-${ev.eventId}`"
|
||||
>
|
||||
<div
|
||||
v-if="hasMultipleEvents"
|
||||
class="text-caption text-medium-emphasis mb-1"
|
||||
>
|
||||
{{ ev.eventName }}
|
||||
</div>
|
||||
<VCheckbox
|
||||
v-for="slot in ev.slots"
|
||||
:key="slot.id"
|
||||
:model-value="isChecked(slot.id)"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
class="availability-slot-checkbox"
|
||||
@update:model-value="(v: boolean | null) => toggle(slot.id, v)"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<span class="text-body-1">{{ slot.name }}</span>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
({{ stripSeconds(slot.start_time) }}–{{ stripSeconds(slot.end_time) }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-1"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Vuetify's default VCheckbox spacing is tight; adding a little block
|
||||
margin gives the date-grouped list an easier scan rhythm. */
|
||||
.availability-slot-checkbox {
|
||||
margin-block: 2px;
|
||||
}
|
||||
</style>
|
||||
40
apps/app/src/components/shared/public-form/FieldBoolean.vue
Normal file
40
apps/app/src/components/shared/public-form/FieldBoolean.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const model = computed({
|
||||
get: () => Boolean(props.modelValue),
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VSwitch
|
||||
v-model="model"
|
||||
inset
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:label="field.label + (field.is_required ? ' *' : '')"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="emit('blur')"
|
||||
/>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mt-1 mb-0"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const locale = usePublicFormLocale()
|
||||
|
||||
interface RenderOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const options = computed<RenderOption[]>(() =>
|
||||
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
|
||||
value: opt.value,
|
||||
label: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
|
||||
const selected = computed<string[]>(() =>
|
||||
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
function isChecked(value: string): boolean {
|
||||
return selected.value.includes(value)
|
||||
}
|
||||
|
||||
function toggle(value: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(value)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(value)
|
||||
}
|
||||
else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VCheckbox
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:model-value="isChecked(opt.value)"
|
||||
:label="opt.label"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@update:model-value="(v: boolean | null) => toggle(opt.value, v)"
|
||||
/>
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-1"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
37
apps/app/src/components/shared/public-form/FieldDate.vue
Normal file
37
apps/app/src/components/shared/public-form/FieldDate.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const config = { dateFormat: 'Y-m-d', allowInput: true }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDateTimePicker
|
||||
v-model="model"
|
||||
:label="field.label + (field.is_required ? ' *' : '')"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-calendar"
|
||||
:config="config"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
38
apps/app/src/components/shared/public-form/FieldEmail.vue
Normal file
38
apps/app/src/components/shared/public-form/FieldEmail.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-mail"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
22
apps/app/src/components/shared/public-form/FieldHeading.vue
Normal file
22
apps/app/src/components/shared/public-form/FieldHeading.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-h6 mt-6 mb-2">
|
||||
{{ field.label }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VDivider class="mb-2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const locale = usePublicFormLocale()
|
||||
|
||||
interface RenderOption {
|
||||
value: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const items = computed<RenderOption[]>(() =>
|
||||
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
|
||||
value: opt.value,
|
||||
title: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
|
||||
set: (v: string[]) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSelect
|
||||
v-model="model"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
54
apps/app/src/components/shared/public-form/FieldNumber.vue
Normal file
54
apps/app/src/components/shared/public-form/FieldNumber.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: number | null): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const inputValue = computed<string>(() => {
|
||||
const v = props.modelValue
|
||||
if (v === null || v === undefined) return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
|
||||
return String(v)
|
||||
})
|
||||
|
||||
function onUpdate(raw: string | number | null) {
|
||||
const s = raw === null || raw === undefined ? '' : String(raw)
|
||||
if (s === '' || s === '-') {
|
||||
emit('update:modelValue', null)
|
||||
|
||||
return
|
||||
}
|
||||
const n = Number(s)
|
||||
emit('update:modelValue', Number.isFinite(n) ? n : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
:model-value="inputValue"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:min="field.validation_rules?.min_value ?? undefined"
|
||||
:max="field.validation_rules?.max_value ?? undefined"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-body-1 text-medium-emphasis mb-4">
|
||||
{{ field.label }}
|
||||
</p>
|
||||
</template>
|
||||
39
apps/app/src/components/shared/public-form/FieldPhone.vue
Normal file
39
apps/app/src/components/shared/public-form/FieldPhone.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+31 6 12345678"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-phone"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
72
apps/app/src/components/shared/public-form/FieldRadio.vue
Normal file
72
apps/app/src/components/shared/public-form/FieldRadio.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const locale = usePublicFormLocale()
|
||||
|
||||
interface RenderOption {
|
||||
value: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const options = computed<RenderOption[]>(() =>
|
||||
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
|
||||
value: opt.value,
|
||||
title: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => {
|
||||
emit('update:modelValue', v)
|
||||
emit('blur')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
<VRadioGroup
|
||||
v-model="model"
|
||||
density="comfortable"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
hide-details="auto"
|
||||
>
|
||||
<VRadio
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.title"
|
||||
/>
|
||||
</VRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
235
apps/app/src/components/shared/public-form/FieldRenderer.vue
Normal file
235
apps/app/src/components/shared/public-form/FieldRenderer.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import FieldAvailabilityPicker from './FieldAvailabilityPicker.vue'
|
||||
import FieldBoolean from './FieldBoolean.vue'
|
||||
import FieldCheckboxList from './FieldCheckboxList.vue'
|
||||
import FieldDate from './FieldDate.vue'
|
||||
import FieldEmail from './FieldEmail.vue'
|
||||
import FieldHeading from './FieldHeading.vue'
|
||||
import FieldMultiselect from './FieldMultiselect.vue'
|
||||
import FieldNumber from './FieldNumber.vue'
|
||||
import FieldParagraph from './FieldParagraph.vue'
|
||||
import FieldPhone from './FieldPhone.vue'
|
||||
import FieldRadio from './FieldRadio.vue'
|
||||
import FieldSectionPriority from './FieldSectionPriority.vue'
|
||||
import FieldSelect from './FieldSelect.vue'
|
||||
import FieldTagPicker from './FieldTagPicker.vue'
|
||||
import FieldText from './FieldText.vue'
|
||||
import FieldTextarea from './FieldTextarea.vue'
|
||||
import FieldUrl from './FieldUrl.vue'
|
||||
import { evaluateConditionalLogic } from '@/composables/forms/composables/useConditionalLogic'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
allValues: FormValues
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: unknown): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const visible = computed(() => evaluateConditionalLogic(props.field.conditional_logic, props.allValues))
|
||||
|
||||
function colsFor(width: FormFieldDisplayWidth): number {
|
||||
switch (width) {
|
||||
case 'half': return 6
|
||||
case 'third': return 4
|
||||
case 'quarter': return 3
|
||||
case 'full':
|
||||
default:
|
||||
return 12
|
||||
}
|
||||
}
|
||||
|
||||
const smCols = computed(() => colsFor(props.field.display_width))
|
||||
|
||||
const isStubbed = computed(() =>
|
||||
props.field.field_type === FormFieldType.FILE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.IMAGE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.SIGNATURE
|
||||
|| props.field.field_type === FormFieldType.TABLE_ROWS
|
||||
|| props.field.field_type === FormFieldType.DATETIME,
|
||||
)
|
||||
|
||||
function onUpdate(v: unknown): void {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
function onBlur(): void {
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCol
|
||||
v-if="visible"
|
||||
cols="12"
|
||||
:sm="smCols"
|
||||
>
|
||||
<div :id="`ff-${field.slug}`">
|
||||
<template v-if="isStubbed">
|
||||
<VAlert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-1">
|
||||
{{ field.label }}
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Dit veldtype wordt binnenkort ondersteund.
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
|
||||
<FieldText
|
||||
v-else-if="field.field_type === FormFieldType.TEXT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldTextarea
|
||||
v-else-if="field.field_type === FormFieldType.TEXTAREA"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldEmail
|
||||
v-else-if="field.field_type === FormFieldType.EMAIL"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldPhone
|
||||
v-else-if="field.field_type === FormFieldType.PHONE"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
v-else-if="field.field_type === FormFieldType.NUMBER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldDate
|
||||
v-else-if="field.field_type === FormFieldType.DATE"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldBoolean
|
||||
v-else-if="field.field_type === FormFieldType.BOOLEAN"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldRadio
|
||||
v-else-if="field.field_type === FormFieldType.RADIO"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldSelect
|
||||
v-else-if="field.field_type === FormFieldType.SELECT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldMultiselect
|
||||
v-else-if="field.field_type === FormFieldType.MULTISELECT"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldCheckboxList
|
||||
v-else-if="field.field_type === FormFieldType.CHECKBOX_LIST"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldTagPicker
|
||||
v-else-if="field.field_type === FormFieldType.TAG_PICKER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldAvailabilityPicker
|
||||
v-else-if="field.field_type === FormFieldType.AVAILABILITY_PICKER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldSectionPriority
|
||||
v-else-if="field.field_type === FormFieldType.SECTION_PRIORITY"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldHeading
|
||||
v-else-if="field.field_type === FormFieldType.HEADING"
|
||||
:field="field"
|
||||
/>
|
||||
|
||||
<FieldParagraph
|
||||
v-else-if="field.field_type === FormFieldType.PARAGRAPH"
|
||||
:field="field"
|
||||
/>
|
||||
|
||||
<FieldUrl
|
||||
v-else-if="field.field_type === FormFieldType.URL"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</VCol>
|
||||
</template>
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: SectionPriorityValue[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const token = usePublicFormToken()
|
||||
const { data: sections, isLoading, isError, refetch } = usePublicFormSections(token)
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
const HARD_CAP = 5
|
||||
const warnedOnMalformedValue = { value: false }
|
||||
|
||||
function selfHealIncoming(value: unknown): SectionPriorityValue[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const cleaned: SectionPriorityValue[] = []
|
||||
for (const entry of value) {
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
const obj = entry as Record<string, unknown>
|
||||
const id = obj.section_id
|
||||
const prio = obj.priority
|
||||
if (typeof id !== 'string' || typeof prio !== 'number') continue
|
||||
cleaned.push({ section_id: id, priority: prio })
|
||||
}
|
||||
|
||||
// If the inbound array had data but none of it matched the shape,
|
||||
// warn once so future misuses are spotted in dev.
|
||||
if (import.meta.env.DEV && value.length > 0 && cleaned.length === 0 && !warnedOnMalformedValue.value) {
|
||||
console.warn('[FieldSectionPriority] modelValue not in {section_id, priority}[] shape — self-healing to [].')
|
||||
warnedOnMalformedValue.value = true
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const ranked = ref<SectionPriorityValue[]>(selfHealIncoming(props.modelValue))
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
ranked.value = selfHealIncoming(v)
|
||||
}, { deep: true })
|
||||
|
||||
const maxPriorities = computed(() => {
|
||||
// WS-5b canonicalised the legacy `max_priorities` key to `max_selected`;
|
||||
// the field's cap (if any) is surfaced under `validation_rules.max_selected`.
|
||||
const raw = props.field.validation_rules?.max_selected
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.min(raw, HARD_CAP)
|
||||
}
|
||||
|
||||
return HARD_CAP
|
||||
})
|
||||
|
||||
const sectionsById = computed<Record<string, PublicFormSectionOption>>(() => {
|
||||
const list = sections.value ?? []
|
||||
const map: Record<string, PublicFormSectionOption> = {}
|
||||
for (const s of list) map[s.id] = s
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const unrankedPool = computed<PublicFormSectionOption[]>(() => {
|
||||
const list = sections.value ?? []
|
||||
const rankedIds = new Set(ranked.value.map(r => r.section_id))
|
||||
|
||||
return list.filter(s => !rankedIds.has(s.id))
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !sections.value || sections.value.length === 0)
|
||||
const rankedFull = computed(() => ranked.value.length >= maxPriorities.value)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, ranked.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
function reassignPriorities(list: SectionPriorityValue[]): SectionPriorityValue[] {
|
||||
return list.map((item, index) => ({ section_id: item.section_id, priority: index + 1 }))
|
||||
}
|
||||
|
||||
function emitNow(): void {
|
||||
emit('update:modelValue', ranked.value)
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
function rankSection(section: PublicFormSectionOption): void {
|
||||
if (rankedFull.value) return
|
||||
ranked.value = reassignPriorities([
|
||||
...ranked.value,
|
||||
{ section_id: section.id, priority: ranked.value.length + 1 },
|
||||
])
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function unrankAt(index: number): void {
|
||||
const next = [...ranked.value]
|
||||
next.splice(index, 1)
|
||||
ranked.value = reassignPriorities(next)
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
// vuedraggable already mutated ranked.value via v-model — we just
|
||||
// renumber priorities and emit.
|
||||
ranked.value = reassignPriorities(ranked.value)
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function sectionNameFor(id: string): string {
|
||||
return sectionsById.value[id]?.name ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="article"
|
||||
/>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
|
||||
<span>Kon secties niet laden.</span>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
Er zijn nog geen secties gepubliceerd voor registratie.
|
||||
</VAlert>
|
||||
|
||||
<div v-else>
|
||||
<!-- Ranked list -->
|
||||
<div class="mb-2 text-caption text-medium-emphasis">
|
||||
Jouw voorkeuren ({{ ranked.length }} / {{ maxPriorities }})
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="ranked"
|
||||
item-key="section_id"
|
||||
handle=".section-priority-handle"
|
||||
ghost-class="section-priority-ghost"
|
||||
chosen-class="section-priority-chosen"
|
||||
drag-class="section-priority-drag"
|
||||
:animation="150"
|
||||
:delay="100"
|
||||
:delay-on-touch-only="true"
|
||||
class="section-priority-ranked mb-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="section-priority-ranked-item d-flex align-center pa-3 mb-2"
|
||||
:aria-label="`Voorkeur ${index + 1}: ${sectionNameFor(element.section_id)}`"
|
||||
>
|
||||
<div class="section-priority-rank me-3 text-primary font-weight-bold text-h6">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="sectionsById[element.section_id]?.icon"
|
||||
:icon="sectionsById[element.section_id]!.icon!"
|
||||
size="18"
|
||||
class="me-2 text-medium-emphasis"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-1">
|
||||
{{ sectionNameFor(element.section_id) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="sectionsById[element.section_id]?.registration_description"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
{{ sectionsById[element.section_id]!.registration_description }}
|
||||
</div>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="!mobile"
|
||||
class="section-priority-handle me-2 text-disabled"
|
||||
style="cursor: grab;"
|
||||
icon="tabler-grip-vertical"
|
||||
size="18"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-x"
|
||||
size="small"
|
||||
variant="text"
|
||||
:aria-label="`Verwijder ${sectionNameFor(element.section_id)} uit je voorkeuren`"
|
||||
@click="unrankAt(index)"
|
||||
/>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="ranked.length === 0"
|
||||
class="text-caption text-medium-emphasis mb-4"
|
||||
>
|
||||
Tik of sleep een sectie hieronder om je eerste voorkeur te kiezen.
|
||||
</div>
|
||||
|
||||
<!-- Unranked pool -->
|
||||
<div class="mb-2 text-caption text-medium-emphasis">
|
||||
Nog te kiezen
|
||||
</div>
|
||||
<VRow dense>
|
||||
<VCol
|
||||
v-for="section in unrankedPool"
|
||||
:key="section.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="section-priority-unranked-card pa-3"
|
||||
:class="{ 'section-priority-unranked-disabled': rankedFull }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Voeg ${section.name} toe aan je voorkeuren`"
|
||||
:aria-disabled="rankedFull"
|
||||
@click="rankSection(section)"
|
||||
@keydown.enter.prevent="rankSection(section)"
|
||||
@keydown.space.prevent="rankSection(section)"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon
|
||||
v-if="section.icon"
|
||||
:icon="section.icon"
|
||||
size="18"
|
||||
class="me-2 text-medium-emphasis"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-1">
|
||||
{{ section.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="section.registration_description"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
{{ section.registration_description }}
|
||||
</div>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="!rankedFull"
|
||||
icon="tabler-plus"
|
||||
size="18"
|
||||
class="text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="rankedFull"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
>
|
||||
Maximaal {{ maxPriorities }} voorkeuren
|
||||
</div>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-2"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Vuetify's VCard disabled state isn't contextual enough for an
|
||||
"over-cap" affordance — we need it to look dimmed without blocking
|
||||
assistive tech, which Vuetify's :disabled would do. */
|
||||
.section-priority-unranked-card {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.section-priority-unranked-card:hover:not(.section-priority-unranked-disabled),
|
||||
.section-priority-unranked-card:focus-visible:not(.section-priority-unranked-disabled) {
|
||||
background-color: rgb(var(--v-theme-primary) / 0.04);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.section-priority-unranked-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section-priority-rank {
|
||||
min-inline-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* vuedraggable drag states — SortableJS applies these classes. The
|
||||
defaults leave the drag-clone semi-transparent while the ghost
|
||||
stays solid at the origin, which produces text overlap mid-drag. */
|
||||
.section-priority-ghost {
|
||||
opacity: 0.3;
|
||||
background: rgb(var(--v-theme-surface-bright));
|
||||
}
|
||||
|
||||
.section-priority-drag {
|
||||
/* !important overrides SortableJS's inline opacity: 0.8 default so
|
||||
the drag-clone reads as solid + elevated. */
|
||||
opacity: 1 !important;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.section-priority-chosen {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
55
apps/app/src/components/shared/public-form/FieldSelect.vue
Normal file
55
apps/app/src/components/shared/public-form/FieldSelect.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string | null): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const locale = usePublicFormLocale()
|
||||
|
||||
interface RenderOption {
|
||||
value: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const items = computed<RenderOption[]>(() =>
|
||||
(props.field.options ?? []).map((opt: OptionSpec): RenderOption => ({
|
||||
value: opt.value,
|
||||
title: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? null) as string | null,
|
||||
set: (v: string | null) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSelect
|
||||
v-model="model"
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:clearable="!field.is_required"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
115
apps/app/src/components/shared/public-form/FieldTagPicker.vue
Normal file
115
apps/app/src/components/shared/public-form/FieldTagPicker.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedTag {
|
||||
title: string
|
||||
value: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const OVERIG = 'Overig'
|
||||
|
||||
function normalize(tag: AvailableTag): NormalizedTag {
|
||||
const category = (tag.category && tag.category.trim() !== '') ? tag.category : OVERIG
|
||||
|
||||
return { title: tag.name, value: tag.id, category }
|
||||
}
|
||||
|
||||
const items = computed<NormalizedTag[]>(() => {
|
||||
const tags = props.field.available_tags ?? []
|
||||
|
||||
// Server already orders within each category; we only need a stable
|
||||
// per-category grouping so the #item slot can emit a subheader when
|
||||
// the category flips.
|
||||
return [...tags].map(normalize).sort((a, b) => {
|
||||
if (a.category === b.category) return 0
|
||||
|
||||
return a.category < b.category ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => items.value.length === 0)
|
||||
|
||||
const model = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
|
||||
set: (v: string[]) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
// Tracks the previous category rendered during the #item slot iteration
|
||||
// so we can emit a VListSubheader right before the first item in each
|
||||
// new category. VAutocomplete re-iterates on every render.
|
||||
let lastCategory: string | null = null
|
||||
|
||||
function shouldRenderSubheader(category: string): boolean {
|
||||
const flip = lastCategory !== category
|
||||
lastCategory = category
|
||||
|
||||
return flip
|
||||
}
|
||||
|
||||
function resetSubheaderTracker(): void {
|
||||
lastCategory = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VAlert
|
||||
v-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-1">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Er zijn nog geen tags beschikbaar voor dit formulier.
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<AppAutocomplete
|
||||
v-else
|
||||
v-model="model"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:menu="(open: boolean) => { if (open) resetSubheaderTracker() }"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListSubheader v-if="shouldRenderSubheader(item.raw.category)">
|
||||
{{ item.raw.category }}
|
||||
</VListSubheader>
|
||||
<VListItem v-bind="itemProps" />
|
||||
</template>
|
||||
</AppAutocomplete>
|
||||
</div>
|
||||
</template>
|
||||
35
apps/app/src/components/shared/public-form/FieldText.vue
Normal file
35
apps/app/src/components/shared/public-form/FieldText.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
:maxlength="field.validation_rules?.max_length ?? undefined"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
37
apps/app/src/components/shared/public-form/FieldTextarea.vue
Normal file
37
apps/app/src/components/shared/public-form/FieldTextarea.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextarea
|
||||
v-model="model"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rows="4"
|
||||
auto-grow
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
:maxlength="field.validation_rules?.max_length ?? undefined"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
38
apps/app/src/components/shared/public-form/FieldUrl.vue
Normal file
38
apps/app/src/components/shared/public-form/FieldUrl.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTextField
|
||||
v-model="model"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://..."
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="tabler-link"
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</template>
|
||||
148
apps/app/src/components/shared/public-form/FormConfirmation.vue
Normal file
148
apps/app/src/components/shared/public-form/FormConfirmation.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import DuplicateSubmissionHint from './DuplicateSubmissionHint.vue'
|
||||
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue'
|
||||
import type { FormStep } from '@/composables/forms/composables/useFormSteps'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type {
|
||||
FormValues,
|
||||
PublicFormField,
|
||||
PublicFormSubmissionDuplicate,
|
||||
PublicFormSubmissionIdentityMatch,
|
||||
} from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
values: FormValues
|
||||
submitterName?: string
|
||||
submitterEmail?: string
|
||||
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
||||
duplicateSubmission?: PublicFormSubmissionDuplicate | null
|
||||
}>()
|
||||
|
||||
// TanStack Query calls — these hit the same cache the field components
|
||||
// populated during the form render (5-minute staleTime), so there's no
|
||||
// extra network round-trip on the confirmation page.
|
||||
const token = usePublicFormToken()
|
||||
const timeSlotsQuery = usePublicFormTimeSlots(token)
|
||||
const sectionsQuery = usePublicFormSections(token)
|
||||
|
||||
function displayValue(field: PublicFormField): string {
|
||||
return formatFieldValue(
|
||||
field,
|
||||
props.values[field.slug],
|
||||
timeSlotsQuery.data.value,
|
||||
sectionsQuery.data.value,
|
||||
)
|
||||
}
|
||||
|
||||
function isAnswerable(field: PublicFormField): boolean {
|
||||
return field.field_type !== FormFieldType.HEADING
|
||||
&& field.field_type !== FormFieldType.PARAGRAPH
|
||||
}
|
||||
|
||||
function answerableFields(step: FormStep): PublicFormField[] {
|
||||
return step.fields.filter(isAnswerable)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex justify-center pa-4">
|
||||
<VCard
|
||||
flat
|
||||
:max-width="720"
|
||||
class="w-100"
|
||||
>
|
||||
<VCardText class="text-center pa-8">
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<h2 class="text-h4 mb-2">
|
||||
Bedankt voor je inzending!
|
||||
</h2>
|
||||
<p class="text-body-1 text-medium-emphasis mb-0">
|
||||
We nemen zo snel mogelijk contact op.
|
||||
</p>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText
|
||||
v-if="duplicateSubmission || identityMatch"
|
||||
class="pa-6 pb-0"
|
||||
>
|
||||
<!-- Duplicate hint first: it's about the act of submitting.
|
||||
Identity match second: it's about who you are. -->
|
||||
<DuplicateSubmissionHint :data="duplicateSubmission ?? null" />
|
||||
<IdentityMatchBanner
|
||||
v-if="identityMatch"
|
||||
:status="identityMatch.status"
|
||||
:message="identityMatch.message"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText class="pa-6">
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
Contactgegevens
|
||||
</h3>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
Naam
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ submitterName || '—' }}
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
E-mailadres
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ submitterEmail || '—' }}
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<template
|
||||
v-for="step in steps"
|
||||
:key="step.key"
|
||||
>
|
||||
<template v-if="step.kind !== 'submitter' && step.kind !== 'review' && answerableFields(step).length > 0">
|
||||
<VDivider class="my-5" />
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="field in answerableFields(step)"
|
||||
:key="field.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption text-medium-emphasis mb-1">
|
||||
{{ field.label }}
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
{{ displayValue(field) }}
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
118
apps/app/src/components/shared/public-form/FormErrorState.vue
Normal file
118
apps/app/src/components/shared/public-form/FormErrorState.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrorCode } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
errorCode?: FormErrorCode | string
|
||||
message?: string
|
||||
showRetry?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'retry'): void
|
||||
}>()
|
||||
|
||||
interface Copy {
|
||||
heading: string
|
||||
body: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
terminal: boolean
|
||||
}
|
||||
|
||||
const COPY: Record<string, Copy> = {
|
||||
TOKEN_EXPIRED: {
|
||||
heading: 'Deze link is verlopen',
|
||||
body: 'Deze link is verlopen. Vraag de organisatie om een nieuwe.',
|
||||
icon: 'tabler-clock-off',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
TOKEN_REVOKED: {
|
||||
heading: 'Deze link is ingetrokken',
|
||||
body: 'Deze link is ingetrokken. Vraag de organisatie om een nieuwe.',
|
||||
icon: 'tabler-lock',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
SCHEMA_UNPUBLISHED: {
|
||||
heading: 'Formulier niet open',
|
||||
body: 'Dit formulier is op dit moment niet open voor inzendingen.',
|
||||
icon: 'tabler-file-off',
|
||||
iconColor: 'warning',
|
||||
terminal: true,
|
||||
},
|
||||
SCHEMA_NOT_FOUND: {
|
||||
heading: 'Formulier niet gevonden',
|
||||
body: 'Dit formulier bestaat niet.',
|
||||
icon: 'tabler-alert-circle',
|
||||
iconColor: 'error',
|
||||
terminal: true,
|
||||
},
|
||||
SUBMISSION_ALREADY_SUBMITTED: {
|
||||
heading: 'Al verstuurd',
|
||||
body: 'Je hebt dit formulier al verstuurd. Bedankt!',
|
||||
icon: 'tabler-check',
|
||||
iconColor: 'success',
|
||||
terminal: true,
|
||||
},
|
||||
RATE_LIMITED: {
|
||||
heading: 'Even geduld',
|
||||
body: 'Te veel verzoeken. Probeer het over een minuut opnieuw.',
|
||||
icon: 'tabler-hourglass',
|
||||
iconColor: 'warning',
|
||||
terminal: false,
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_COPY: Copy = {
|
||||
heading: 'Er ging iets mis',
|
||||
body: 'Er ging iets mis. Probeer het opnieuw of neem contact op met de organisatie.',
|
||||
icon: 'tabler-alert-circle',
|
||||
iconColor: 'error',
|
||||
terminal: false,
|
||||
}
|
||||
|
||||
const copy = computed<Copy>(() => {
|
||||
const code = props.errorCode
|
||||
if (code && code in COPY) return COPY[code]
|
||||
|
||||
return DEFAULT_COPY
|
||||
})
|
||||
|
||||
const bodyText = computed(() => props.message ?? copy.value.body)
|
||||
const canRetry = computed(() => props.showRetry !== false && !copy.value.terminal)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-center justify-center pa-4"
|
||||
style="min-block-size: 60vh;"
|
||||
>
|
||||
<VCard
|
||||
flat
|
||||
:max-width="480"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
:icon="copy.icon"
|
||||
:color="copy.iconColor"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<h2 class="text-h5 mb-2">
|
||||
{{ copy.heading }}
|
||||
</h2>
|
||||
<p class="text-body-1 text-medium-emphasis mb-6">
|
||||
{{ bodyText }}
|
||||
</p>
|
||||
<VBtn
|
||||
v-if="canRetry"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
40
apps/app/src/components/shared/public-form/FormStepper.vue
Normal file
40
apps/app/src/components/shared/public-form/FormStepper.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { FormStep } from '@/composables/forms/composables/useFormSteps'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
currentStep: number
|
||||
isActiveStepValid: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:currentStep', v: number): void
|
||||
}>()
|
||||
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
const direction = computed<'horizontal' | 'vertical'>(() => (mdAndUp.value ? 'horizontal' : 'vertical'))
|
||||
|
||||
const items = computed(() => props.steps.map(s => ({
|
||||
title: s.title,
|
||||
subtitle: s.subtitle,
|
||||
})))
|
||||
|
||||
function go(value: number): void {
|
||||
if (value > props.currentStep && !props.isActiveStepValid) return
|
||||
if (value < 0 || value >= props.steps.length) return
|
||||
emit('update:currentStep', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppStepper
|
||||
:items="items"
|
||||
:current-step="currentStep"
|
||||
:direction="direction"
|
||||
:is-active-step-valid="isActiveStepValid"
|
||||
align="start"
|
||||
@update:current-step="go"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
status: 'pending' | 'matched' | 'none' | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Frontend fallbacks for each state — mirror the backend copy so the
|
||||
// banner still renders correctly if a future response trims `message`.
|
||||
// Backend `message` is authoritative (single source of truth for copy).
|
||||
const FALLBACK_TITLE: Record<Exclude<Props['status'], null>, string> = {
|
||||
pending: 'We controleren je gegevens',
|
||||
matched: 'Gegevens gekoppeld',
|
||||
none: 'Aanmelding ontvangen',
|
||||
}
|
||||
const FALLBACK_BODY: Record<Exclude<Props['status'], null>, string> = {
|
||||
pending: 'We kijken of je al bekend bent bij de organisator. Je gegevens worden automatisch gekoppeld zodra zij dit bevestigen.',
|
||||
matched: 'Je bent automatisch gekoppeld aan je bestaande account bij de organisator.',
|
||||
none: 'De organisator neemt contact met je op zodra je aanmelding is verwerkt.',
|
||||
}
|
||||
const TYPE: Record<Exclude<Props['status'], null>, 'info' | 'success'> = {
|
||||
pending: 'info',
|
||||
matched: 'success',
|
||||
none: 'success',
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
if (!props.status) return ''
|
||||
const backend = (props.message ?? '').trim()
|
||||
if (backend) return backend
|
||||
|
||||
return FALLBACK_BODY[props.status]
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.status) return ''
|
||||
|
||||
return FALLBACK_TITLE[props.status]
|
||||
})
|
||||
|
||||
const alertType = computed(() => {
|
||||
if (!props.status) return 'info'
|
||||
|
||||
return TYPE[props.status]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAlert
|
||||
v-if="status"
|
||||
:type="alertType"
|
||||
variant="tonal"
|
||||
prominent
|
||||
class="identity-match-banner mb-4"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ body }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { emailValidator, requiredValidator } from '@core/utils/validators'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
email: string
|
||||
errors?: { name?: string; email?: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:name', v: string): void
|
||||
(e: 'update:email', v: string): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const nameModel = computed({
|
||||
get: () => props.name,
|
||||
set: v => emit('update:name', v),
|
||||
})
|
||||
const emailModel = computed({
|
||||
get: () => props.email,
|
||||
set: v => emit('update:email', v),
|
||||
})
|
||||
|
||||
const nameRules = [
|
||||
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je naam in.'),
|
||||
(v: unknown) => (v === null || v === undefined || String(v).length <= 150 ? true : 'Maximaal 150 tekens.'),
|
||||
]
|
||||
const emailRules = [
|
||||
(v: unknown) => (requiredValidator(v) === true ? true : 'Vul je e-mailadres in.'),
|
||||
(v: unknown) => (emailValidator(v) === true ? true : 'Vul een geldig e-mailadres in.'),
|
||||
(v: unknown) => (v === null || v === undefined || String(v).length <= 255 ? true : 'Maximaal 255 tekens.'),
|
||||
]
|
||||
|
||||
const nameError = computed(() => props.errors?.name ? [props.errors.name] : [])
|
||||
const emailError = computed(() => props.errors?.email ? [props.errors.email] : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model="nameModel"
|
||||
label="Naam *"
|
||||
placeholder="Voor- en achternaam"
|
||||
autocomplete="name"
|
||||
:rules="nameRules"
|
||||
:error-messages="nameError"
|
||||
:maxlength="150"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<AppTextField
|
||||
v-model="emailModel"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
label="E-mailadres *"
|
||||
placeholder="je@email.nl"
|
||||
prepend-inner-icon="tabler-mail"
|
||||
:rules="emailRules"
|
||||
:error-messages="emailError"
|
||||
:maxlength="255"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
We gebruiken je gegevens alleen om contact op te nemen over deze aanvraag.
|
||||
</p>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DuplicateSubmissionHint from '@/components/shared/public-form/DuplicateSubmissionHint.vue'
|
||||
import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function mountHint(data: PublicFormSubmissionDuplicate | null) {
|
||||
return mount(DuplicateSubmissionHint, {
|
||||
props: { data },
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type', 'variant', 'prominent'],
|
||||
template: '<div class="v-alert-stub" :data-type="type" :data-variant="variant"><slot/></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('DuplicateSubmissionHint', () => {
|
||||
it('renders nothing when data is null', () => {
|
||||
const w = mountHint(null)
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers the backend title and body when provided', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: 'Je hebt je eerder al aangemeld',
|
||||
body: 'Op 22 april 2026 heb je dit formulier ook al ingevuld.',
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||
expect(w.text()).toContain('Op 22 april 2026')
|
||||
})
|
||||
|
||||
it('falls back to singular copy when the backend body is missing (count=1)', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: '',
|
||||
body: '',
|
||||
})
|
||||
|
||||
// Fallback title + body.
|
||||
expect(w.text()).toContain('Je hebt je eerder al aangemeld')
|
||||
expect(w.text()).toMatch(/Op\s+22\s+april\s+2026.*ook al ingevuld/)
|
||||
expect(w.text()).toContain('De organisator ziet beide aanmeldingen')
|
||||
})
|
||||
|
||||
it('falls back to plural copy with count when the backend body is missing', () => {
|
||||
const w = mountHint({
|
||||
count: 3,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: '',
|
||||
body: '',
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('3 keer eerder ingevuld')
|
||||
expect(w.text()).toContain('22 april 2026')
|
||||
expect(w.text()).toContain('De organisator ziet alle aanmeldingen')
|
||||
})
|
||||
|
||||
it('renders as a warning-typed tonal VAlert', () => {
|
||||
const w = mountHint({
|
||||
count: 1,
|
||||
first_submitted_at: '2026-04-22T10:00:00+00:00',
|
||||
title: 'x',
|
||||
body: 'y',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('warning')
|
||||
expect(alert.attributes('data-variant')).toBe('tonal')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Expose mutable state for the mocked composable so each test can steer
|
||||
// loading / error / data scenarios without a vue-query harness.
|
||||
const state = {
|
||||
data: ref<Array<Record<string, unknown>> | undefined>(undefined),
|
||||
isLoading: ref(false),
|
||||
isError: ref(false),
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/composables/api/usePublicFormTimeSlots', () => ({
|
||||
usePublicFormTimeSlots: () => state,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/publicFormInjection', () => ({
|
||||
usePublicFormToken: () => ref('TKN'),
|
||||
providePublicFormToken: () => {},
|
||||
}))
|
||||
|
||||
import FieldAvailabilityPicker from '@/components/shared/public-form/FieldAvailabilityPicker.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'beschikbaarheid',
|
||||
field_type: FormFieldType.AVAILABILITY_PICKER,
|
||||
label: 'Wanneer ben je beschikbaar?',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function slot(partial: Partial<PublicFormTimeSlot>): PublicFormTimeSlot {
|
||||
return {
|
||||
id: partial.id ?? '01A',
|
||||
name: partial.name ?? 'Vrijdag avond',
|
||||
date: partial.date ?? '2026-07-10',
|
||||
start_time: partial.start_time ?? '18:00:00',
|
||||
end_time: partial.end_time ?? '23:00:00',
|
||||
duration_hours: partial.duration_hours ?? 5,
|
||||
event_id: partial.event_id ?? 'evt_1',
|
||||
event_name: partial.event_name ?? 'Echt Feesten',
|
||||
}
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldAvailabilityPicker, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VSkeletonLoader: { name: 'VSkeletonLoader', template: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
VCheckbox: {
|
||||
name: 'VCheckbox',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<label class="v-checkbox-stub">
|
||||
<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target).checked)"/>
|
||||
<slot name="label"/>
|
||||
</label>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldAvailabilityPicker', () => {
|
||||
beforeEach(() => {
|
||||
state.data.value = undefined
|
||||
state.isLoading.value = false
|
||||
state.isError.value = false
|
||||
state.refetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the skeleton while loading', () => {
|
||||
state.isLoading.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-skeleton-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error alert with a retry button when isError', async () => {
|
||||
state.isError.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error')
|
||||
await w.find('.v-btn-stub').trigger('click')
|
||||
expect(state.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the info empty-state when the slots list is empty', () => {
|
||||
state.data.value = []
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('Er zijn nog geen tijdsloten beschikbaar.')
|
||||
})
|
||||
|
||||
it('groups slots by date with Dutch weekday labels', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', date: '2026-07-11', name: 'Za middag' }),
|
||||
slot({ id: 'b', date: '2026-07-12', name: 'Zo middag' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// "zaterdag 11 juli" — capitalised. Rendered with nl-NL locale.
|
||||
expect(w.text()).toMatch(/Zaterdag\s*11\s*juli/)
|
||||
expect(w.text()).toMatch(/Zondag\s*12\s*juli/)
|
||||
})
|
||||
|
||||
it('adds event-name subheaders when multiple events are present', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', event_id: 'e1', event_name: 'Parent festival' }),
|
||||
slot({ id: 'b', event_id: 'e2', event_name: 'Dag 1' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('Parent festival')
|
||||
expect(w.text()).toContain('Dag 1')
|
||||
})
|
||||
|
||||
it('omits event-name subheaders in the single-event case', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', event_id: 'e1', event_name: 'Only event' }),
|
||||
slot({ id: 'b', event_id: 'e1', event_name: 'Only event' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// Only event_name appears somewhere in the DOM? In the single-event
|
||||
// case, the subheader should NOT be rendered — count the occurrences
|
||||
// to verify only checkbox labels (slot names) appear, not the event
|
||||
// name as a standalone subheader.
|
||||
const text = w.text()
|
||||
// The event_name "Only event" should not appear as a subheader; slot
|
||||
// names are different ("Vrijdag avond"), so event_name shouldn't be
|
||||
// found anywhere in the visible text.
|
||||
expect(text).not.toContain('Only event')
|
||||
})
|
||||
|
||||
it('emits update:modelValue as string[] of time_slot IDs on toggle', async () => {
|
||||
state.data.value = [slot({ id: 'alpha' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
const checkbox = w.find('.v-checkbox-stub input')
|
||||
await checkbox.setValue(true)
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('formats time with seconds stripped', () => {
|
||||
state.data.value = [slot({ start_time: '08:00:00', end_time: '13:00:00' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('08:00–13:00')
|
||||
expect(w.text()).not.toContain('08:00:00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computed, defineComponent, h, ref } from 'vue'
|
||||
import FieldCheckboxList from '@/components/shared/public-form/FieldCheckboxList.vue'
|
||||
import FieldMultiselect from '@/components/shared/public-form/FieldMultiselect.vue'
|
||||
import FieldRadio from '@/components/shared/public-form/FieldRadio.vue'
|
||||
import FieldSelect from '@/components/shared/public-form/FieldSelect.vue'
|
||||
import { providePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
const stubs = {
|
||||
VRadioGroup: { name: 'VRadioGroup', template: '<div class="v-radio-group-stub"><slot/></div>' },
|
||||
VRadio: {
|
||||
name: 'VRadio',
|
||||
props: ['value', 'label'],
|
||||
template: '<div class="v-radio-stub" :data-value="value" :data-label="label" />',
|
||||
},
|
||||
VCheckbox: {
|
||||
name: 'VCheckbox',
|
||||
props: ['modelValue', 'label'],
|
||||
template: '<div class="v-checkbox-stub" :data-label="label" />',
|
||||
},
|
||||
AppSelect: {
|
||||
name: 'AppSelect',
|
||||
props: ['modelValue', 'items', 'itemTitle', 'itemValue', 'label'],
|
||||
template: `<div class="app-select-stub">
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
class="stub-item"
|
||||
:data-value="item.value"
|
||||
:data-title="item.title"
|
||||
>{{ item.title }}</span>
|
||||
</div>`,
|
||||
},
|
||||
}
|
||||
|
||||
function fieldOf(field_type: PublicFormField['field_type'], options: OptionSpec[]): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'choice',
|
||||
field_type,
|
||||
label: 'Choice',
|
||||
help_text: null,
|
||||
options,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
}
|
||||
}
|
||||
|
||||
function harness(component: any, field: PublicFormField, locale: string) {
|
||||
// Tiny wrapper that calls providePublicFormLocale before rendering the
|
||||
// target component, mimicking what [public_token].vue does at the page
|
||||
// root.
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
providePublicFormLocale(ref(locale))
|
||||
|
||||
return () => h(component, { field, modelValue: null })
|
||||
},
|
||||
})
|
||||
|
||||
return mount(Wrapper, { global: { stubs } })
|
||||
}
|
||||
|
||||
const optionsSample: OptionSpec[] = [
|
||||
{ value: 'red', label: 'Red', sort_order: 0, translations: { nl: 'Rood', de: 'Rot' } },
|
||||
{ value: 'green', label: 'Green', sort_order: 1 }, // no translations — fallback
|
||||
]
|
||||
|
||||
describe('Option-bearing field locale resolution', () => {
|
||||
describe('FieldRadio', () => {
|
||||
it('prefers translations[locale] over the default label', () => {
|
||||
const wrapper = harness(FieldRadio, fieldOf(FormFieldType.RADIO, optionsSample), 'nl')
|
||||
const radios = wrapper.findAll('.v-radio-stub')
|
||||
|
||||
expect(radios.length).toBe(2)
|
||||
expect(radios[0].attributes('data-label')).toBe('Rood')
|
||||
})
|
||||
|
||||
it('falls back to label when translation is missing for the current locale', () => {
|
||||
const wrapper = harness(FieldRadio, fieldOf(FormFieldType.RADIO, optionsSample), 'nl')
|
||||
const radios = wrapper.findAll('.v-radio-stub')
|
||||
|
||||
expect(radios[1].attributes('data-label')).toBe('Green')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FieldSelect', () => {
|
||||
it('emits translated title for matching locale', () => {
|
||||
const wrapper = harness(FieldSelect, fieldOf(FormFieldType.SELECT, optionsSample), 'de')
|
||||
const items = wrapper.findAll('.stub-item')
|
||||
|
||||
expect(items[0].attributes('data-title')).toBe('Rot')
|
||||
})
|
||||
|
||||
it('falls back to label on missing translation', () => {
|
||||
const wrapper = harness(FieldSelect, fieldOf(FormFieldType.SELECT, optionsSample), 'de')
|
||||
const items = wrapper.findAll('.stub-item')
|
||||
|
||||
expect(items[1].attributes('data-title')).toBe('Green')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FieldMultiselect', () => {
|
||||
it('emits translated title for matching locale', () => {
|
||||
const wrapper = harness(FieldMultiselect, fieldOf(FormFieldType.MULTISELECT, optionsSample), 'nl')
|
||||
const items = wrapper.findAll('.stub-item')
|
||||
|
||||
expect(items[0].attributes('data-title')).toBe('Rood')
|
||||
})
|
||||
|
||||
it('falls back to label on missing translation', () => {
|
||||
const wrapper = harness(FieldMultiselect, fieldOf(FormFieldType.MULTISELECT, optionsSample), 'nl')
|
||||
const items = wrapper.findAll('.stub-item')
|
||||
|
||||
expect(items[1].attributes('data-title')).toBe('Green')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FieldCheckboxList', () => {
|
||||
it('emits translated label for matching locale', () => {
|
||||
const wrapper = harness(FieldCheckboxList, fieldOf(FormFieldType.CHECKBOX_LIST, optionsSample), 'nl')
|
||||
const checkboxes = wrapper.findAll('.v-checkbox-stub')
|
||||
|
||||
expect(checkboxes[0].attributes('data-label')).toBe('Rood')
|
||||
})
|
||||
|
||||
it('falls back to label on missing translation', () => {
|
||||
const wrapper = harness(FieldCheckboxList, fieldOf(FormFieldType.CHECKBOX_LIST, optionsSample), 'nl')
|
||||
const checkboxes = wrapper.findAll('.v-checkbox-stub')
|
||||
|
||||
expect(checkboxes[1].attributes('data-label')).toBe('Green')
|
||||
})
|
||||
})
|
||||
|
||||
it('default-locale fallback (no provider on the tree) uses the option label as-is', () => {
|
||||
// No providePublicFormLocale in the wrapper — usePublicFormLocale
|
||||
// defaults to 'nl', which doesn't appear in options that only carry
|
||||
// 'de' translations, so we get the raw label.
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
return () => h(FieldRadio, {
|
||||
field: fieldOf(FormFieldType.RADIO, [
|
||||
{ value: 'a', label: 'Apple', sort_order: 0, translations: { de: 'Apfel' } },
|
||||
]),
|
||||
modelValue: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Wrapper, { global: { stubs } })
|
||||
const radios = wrapper.findAll('.v-radio-stub')
|
||||
|
||||
expect(radios[0].attributes('data-label')).toBe('Apple')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FieldRenderer from '@/components/shared/public-form/FieldRenderer.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function makeField(partial: Partial<PublicFormField>): PublicFormField {
|
||||
return {
|
||||
id: partial.id ?? 'id',
|
||||
slug: partial.slug ?? 'slug',
|
||||
field_type: partial.field_type ?? FormFieldType.TEXT,
|
||||
label: partial.label ?? 'Label',
|
||||
help_text: partial.help_text ?? null,
|
||||
options: partial.options ?? null,
|
||||
available_tags: partial.available_tags ?? null,
|
||||
validation_rules: partial.validation_rules ?? null,
|
||||
is_required: partial.is_required ?? false,
|
||||
display_width: partial.display_width ?? 'full',
|
||||
conditional_logic: partial.conditional_logic ?? null,
|
||||
sort_order: partial.sort_order ?? 0,
|
||||
form_schema_section_id: partial.form_schema_section_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function mountRenderer(field: PublicFormField, allValues: Record<string, unknown> = {}) {
|
||||
return mount(FieldRenderer, {
|
||||
props: { field, modelValue: undefined, allValues },
|
||||
global: {
|
||||
stubs: {
|
||||
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
|
||||
VAlert: { name: 'VAlert', template: '<div class="v-alert-stub"><slot/></div>' },
|
||||
FieldText: { name: 'FieldText', template: '<div class="field-text-stub"/>' },
|
||||
FieldTextarea: { name: 'FieldTextarea', template: '<div class="field-textarea-stub"/>' },
|
||||
FieldEmail: { name: 'FieldEmail', template: '<div class="field-email-stub"/>' },
|
||||
FieldPhone: { name: 'FieldPhone', template: '<div class="field-phone-stub"/>' },
|
||||
FieldNumber: { name: 'FieldNumber', template: '<div class="field-number-stub"/>' },
|
||||
FieldDate: { name: 'FieldDate', template: '<div class="field-date-stub"/>' },
|
||||
FieldBoolean: { name: 'FieldBoolean', template: '<div class="field-boolean-stub"/>' },
|
||||
FieldRadio: { name: 'FieldRadio', template: '<div class="field-radio-stub"/>' },
|
||||
FieldSelect: { name: 'FieldSelect', template: '<div class="field-select-stub"/>' },
|
||||
FieldMultiselect: { name: 'FieldMultiselect', template: '<div class="field-multiselect-stub"/>' },
|
||||
FieldCheckboxList: { name: 'FieldCheckboxList', template: '<div class="field-checkboxlist-stub"/>' },
|
||||
FieldTagPicker: { name: 'FieldTagPicker', template: '<div class="field-tagpicker-stub"/>' },
|
||||
FieldAvailabilityPicker: { name: 'FieldAvailabilityPicker', template: '<div class="field-availabilitypicker-stub"/>' },
|
||||
FieldSectionPriority: { name: 'FieldSectionPriority', template: '<div class="field-sectionpriority-stub"/>' },
|
||||
FieldHeading: { name: 'FieldHeading', template: '<div class="field-heading-stub"/>' },
|
||||
FieldParagraph: { name: 'FieldParagraph', template: '<div class="field-paragraph-stub"/>' },
|
||||
FieldUrl: { name: 'FieldUrl', template: '<div class="field-url-stub"/>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldRenderer', () => {
|
||||
it.each<[string, string]>([
|
||||
[FormFieldType.TEXT, 'field-text-stub'],
|
||||
[FormFieldType.TEXTAREA, 'field-textarea-stub'],
|
||||
[FormFieldType.EMAIL, 'field-email-stub'],
|
||||
[FormFieldType.PHONE, 'field-phone-stub'],
|
||||
[FormFieldType.NUMBER, 'field-number-stub'],
|
||||
[FormFieldType.DATE, 'field-date-stub'],
|
||||
[FormFieldType.BOOLEAN, 'field-boolean-stub'],
|
||||
[FormFieldType.RADIO, 'field-radio-stub'],
|
||||
[FormFieldType.SELECT, 'field-select-stub'],
|
||||
[FormFieldType.MULTISELECT, 'field-multiselect-stub'],
|
||||
[FormFieldType.CHECKBOX_LIST, 'field-checkboxlist-stub'],
|
||||
[FormFieldType.TAG_PICKER, 'field-tagpicker-stub'],
|
||||
[FormFieldType.AVAILABILITY_PICKER, 'field-availabilitypicker-stub'],
|
||||
[FormFieldType.SECTION_PRIORITY, 'field-sectionpriority-stub'],
|
||||
[FormFieldType.HEADING, 'field-heading-stub'],
|
||||
[FormFieldType.PARAGRAPH, 'field-paragraph-stub'],
|
||||
[FormFieldType.URL, 'field-url-stub'],
|
||||
])('dispatches to the right component for %s', (fieldType, className) => {
|
||||
const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] }))
|
||||
expect(wrapper.find(`.${className}`).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it.each<string>([
|
||||
FormFieldType.FILE_UPLOAD,
|
||||
FormFieldType.IMAGE_UPLOAD,
|
||||
FormFieldType.SIGNATURE,
|
||||
FormFieldType.TABLE_ROWS,
|
||||
FormFieldType.DATETIME,
|
||||
])('renders placeholder alert for out-of-scope type %s', fieldType => {
|
||||
const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] }))
|
||||
expect(wrapper.find('.v-alert-stub').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('binnenkort ondersteund')
|
||||
})
|
||||
|
||||
it('hides the field when conditional logic evaluates to false', () => {
|
||||
const field = makeField({
|
||||
field_type: FormFieldType.TEXT,
|
||||
conditional_logic: {
|
||||
show_when: { all: [{ field_slug: 'gate', operator: 'equals', value: 'yes' }] },
|
||||
},
|
||||
})
|
||||
|
||||
const hidden = mountRenderer(field, { gate: 'no' })
|
||||
expect(hidden.find('.field-text-stub').exists()).toBe(false)
|
||||
expect(hidden.find('.v-col-stub').exists()).toBe(false)
|
||||
|
||||
const shown = mountRenderer(field, { gate: 'yes' })
|
||||
expect(shown.find('.field-text-stub').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const state = {
|
||||
data: ref<Array<Record<string, unknown>> | undefined>(undefined),
|
||||
isLoading: ref(false),
|
||||
isError: ref(false),
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/composables/api/usePublicFormSections', () => ({
|
||||
usePublicFormSections: () => state,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/publicFormInjection', () => ({
|
||||
usePublicFormToken: () => ref('TKN'),
|
||||
providePublicFormToken: () => {},
|
||||
}))
|
||||
|
||||
// Vuetify's useDisplay touches window.matchMedia which jsdom doesn't
|
||||
// implement; stub to always return desktop for consistent behaviour.
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mobile: ref(false) }),
|
||||
}))
|
||||
|
||||
// vuedraggable depends on SortableJS which pokes the DOM on mount; a
|
||||
// minimal slot-passthrough stub is enough for the tap-based behaviour
|
||||
// under test.
|
||||
vi.mock('vuedraggable', () => ({
|
||||
default: {
|
||||
name: 'draggable',
|
||||
props: ['modelValue', 'ghostClass', 'dragClass', 'chosenClass', 'itemKey', 'handle', 'animation'],
|
||||
template: '<div class="draggable-stub" :data-ghost-class="ghostClass" :data-drag-class="dragClass" :data-chosen-class="chosenClass"><template v-for="(el, i) in modelValue" :key="i"><slot name="item" :element="el" :index="i"/></template></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
import FieldSectionPriority from '@/components/shared/public-form/FieldSectionPriority.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'sectie_voorkeur',
|
||||
field_type: FormFieldType.SECTION_PRIORITY,
|
||||
label: 'Bij welke sectie wil je werken?',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function section(partial: Partial<PublicFormSectionOption>): PublicFormSectionOption {
|
||||
return {
|
||||
id: partial.id ?? '01A',
|
||||
name: partial.name ?? 'Bar',
|
||||
category: partial.category ?? null,
|
||||
icon: partial.icon ?? null,
|
||||
registration_description: partial.registration_description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldSectionPriority, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VSkeletonLoader: { name: 'VSkeletonLoader', template: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
props: ['ariaLabel', 'icon'],
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
VCard: {
|
||||
name: 'VCard',
|
||||
// Do not re-emit click/keydown — the parent's @click listener
|
||||
// falls through to the root element, and emitting + fallthrough
|
||||
// would wire the handler twice.
|
||||
inheritAttrs: true,
|
||||
template: '<div class="v-card-stub"><slot/></div>',
|
||||
},
|
||||
VRow: { name: 'VRow', template: '<div class="v-row-stub"><slot/></div>' },
|
||||
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
|
||||
VIcon: { name: 'VIcon', template: '<i class="v-icon-stub"/>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldSectionPriority', () => {
|
||||
beforeEach(() => {
|
||||
state.data.value = undefined
|
||||
state.isLoading.value = false
|
||||
state.isError.value = false
|
||||
state.refetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the skeleton while loading', () => {
|
||||
state.isLoading.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-skeleton-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error alert with retry wiring', async () => {
|
||||
state.isError.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error')
|
||||
await w.find('.v-btn-stub').trigger('click')
|
||||
expect(state.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the info empty-state when no sections are published', () => {
|
||||
state.data.value = []
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('Er zijn nog geen secties gepubliceerd voor registratie.')
|
||||
})
|
||||
|
||||
it('renders all sections in the unranked pool initially', () => {
|
||||
state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('Bar')
|
||||
expect(w.text()).toContain('Hospitality')
|
||||
})
|
||||
|
||||
it('tap-to-rank moves a section to the ranked list at priority 1', async () => {
|
||||
state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// First unranked card tap
|
||||
const cards = w.findAll('.v-card-stub')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
const emits = w.emitted('update:modelValue') as unknown as Array<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
expect(last).toEqual([{ section_id: 'a', priority: 1 }])
|
||||
})
|
||||
|
||||
it('tapping a second section lands it at priority 2', async () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
// Only section b is still in the pool — it renders as a card.
|
||||
const poolCards = w.findAll('.v-card-stub').filter(c => c.text().length > 0)
|
||||
await poolCards[poolCards.length - 1].trigger('click')
|
||||
|
||||
const emits = w.emitted('update:modelValue') as unknown as Array<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
expect(last).toEqual([
|
||||
{ section_id: 'a', priority: 1 },
|
||||
{ section_id: 'b', priority: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('respects validation_rules.max_priorities when present', async () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
const w = mountPicker({
|
||||
field: field({ validation_rules: { max_selected: 1 } }),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
// Cap is 1; the only unranked card should be disabled per the
|
||||
// rendered "Maximaal" hint.
|
||||
expect(w.text()).toContain('Maximaal 1 voorkeuren')
|
||||
})
|
||||
|
||||
it('self-heals an incoming string[] modelValue to []', () => {
|
||||
state.data.value = [section({ id: 'a' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: ['a', 'b'], // wrong shape (string[])
|
||||
})
|
||||
|
||||
// Still renders fine — no crash, empty ranked list, both sections
|
||||
// in the pool.
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
expect(w.text()).toContain('Bar')
|
||||
})
|
||||
|
||||
it('clamps max_priorities to the hard cap of 5 when the rule is too high', () => {
|
||||
state.data.value = [
|
||||
section({ id: 'a' }),
|
||||
section({ id: 'b' }),
|
||||
section({ id: 'c' }),
|
||||
section({ id: 'd' }),
|
||||
section({ id: 'e' }),
|
||||
]
|
||||
const ranked = [
|
||||
{ section_id: 'a', priority: 1 },
|
||||
{ section_id: 'b', priority: 2 },
|
||||
{ section_id: 'c', priority: 3 },
|
||||
{ section_id: 'd', priority: 4 },
|
||||
{ section_id: 'e', priority: 5 },
|
||||
]
|
||||
const w = mountPicker({
|
||||
field: field({ validation_rules: { max_selected: 99 } as Record<string, unknown> }),
|
||||
modelValue: ranked,
|
||||
})
|
||||
|
||||
// Cap falls back to 5 — counter reads "5 / 5" rather than "5 / 99".
|
||||
expect(w.text()).toContain('5 / 5')
|
||||
})
|
||||
|
||||
it('exposes the ranked counter in the UI copy', () => {
|
||||
state.data.value = [section({ id: 'a' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('1 / 5')
|
||||
})
|
||||
|
||||
it('wires ghost-class / drag-class / chosen-class through to <draggable>', () => {
|
||||
state.data.value = [section({ id: 'a' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
const d = w.find('.draggable-stub')
|
||||
expect(d.attributes('data-ghost-class')).toBe('section-priority-ghost')
|
||||
expect(d.attributes('data-drag-class')).toBe('section-priority-drag')
|
||||
expect(d.attributes('data-chosen-class')).toBe('section-priority-chosen')
|
||||
})
|
||||
|
||||
it('toggles the disabled class on unranked cards when max is reached', () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
|
||||
// Not at cap — unranked cards must not carry the disabled marker.
|
||||
const notFull = mountPicker({
|
||||
field: field({ validation_rules: { max_selected: 3 } }),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
expect(notFull.html()).not.toContain('section-priority-unranked-disabled')
|
||||
|
||||
// Hit the cap — remaining unranked cards switch to the disabled class.
|
||||
const full = mountPicker({
|
||||
field: field({ validation_rules: { max_selected: 1 } }),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
expect(full.html()).toContain('section-priority-unranked-disabled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FieldTagPicker from '@/components/shared/public-form/FieldTagPicker.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'vaardigheden',
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
label: 'Vaardigheden',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function tag(partial: Partial<AvailableTag>): AvailableTag {
|
||||
return { id: partial.id ?? 't_1', name: partial.name ?? 'EHBO', category: partial.category ?? 'Veiligheid' }
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldTagPicker, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: { name: 'VAlert', template: '<div class="v-alert-stub"><slot/></div>' },
|
||||
AppAutocomplete: {
|
||||
name: 'AppAutocomplete',
|
||||
props: ['modelValue', 'items', 'label', 'hint', 'required', 'errorMessages'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<div class="app-autocomplete-stub" :data-label="label">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
class="stub-item"
|
||||
:data-value="item.value"
|
||||
:data-category="item.category"
|
||||
@click="$emit('update:modelValue', [...(modelValue || []), item.value])"
|
||||
>{{ item.title }}</button>
|
||||
<button
|
||||
class="stub-unselect"
|
||||
@click="$emit('update:modelValue', (modelValue || []).slice(0, -1))"
|
||||
>unselect-last</button>
|
||||
</div>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldTagPicker', () => {
|
||||
it('renders the info empty-state when available_tags is null', () => {
|
||||
const w = mountPicker({ field: field({ available_tags: null }), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(true)
|
||||
expect(w.text()).toContain('Er zijn nog geen tags beschikbaar')
|
||||
})
|
||||
|
||||
it('renders the info empty-state when available_tags is an empty array', () => {
|
||||
const w = mountPicker({ field: field({ available_tags: [] }), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalises null/empty category to "Overig"', () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a', name: 'Onbekend', category: '' })] }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
expect(items[0].attributes('data-category')).toBe('Overig')
|
||||
})
|
||||
|
||||
it('renders one item per tag with its id as value', () => {
|
||||
const w = mountPicker({
|
||||
field: field({
|
||||
available_tags: [
|
||||
tag({ id: 'a', name: 'EHBO', category: 'Veiligheid' }),
|
||||
tag({ id: 'b', name: 'BHV', category: 'Veiligheid' }),
|
||||
],
|
||||
}),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
expect(items.length).toBe(2)
|
||||
expect(items.map(i => i.attributes('data-value'))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('emits update:modelValue as string[] of tag IDs on selection', async () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a', name: 'EHBO' })] }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
await w.find('.stub-item').trigger('click')
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('unselecting a tag re-emits the trimmed array', async () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a' }), tag({ id: 'b' })] }),
|
||||
modelValue: ['a', 'b'],
|
||||
})
|
||||
|
||||
await w.find('.stub-unselect').trigger('click')
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('renders the required indicator in the empty-state when is_required', () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: null, is_required: true }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
expect(w.find('.text-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts items grouped by category', () => {
|
||||
const w = mountPicker({
|
||||
field: field({
|
||||
available_tags: [
|
||||
tag({ id: 'a', category: 'Zeta' }),
|
||||
tag({ id: 'b', category: 'Alpha' }),
|
||||
tag({ id: 'c', category: 'Alpha' }),
|
||||
],
|
||||
}),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
const cats = items.map(i => i.attributes('data-category'))
|
||||
// Alpha tags come first, then Zeta.
|
||||
expect(cats).toEqual(['Alpha', 'Alpha', 'Zeta'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import IdentityMatchBanner from '@/components/shared/public-form/IdentityMatchBanner.vue'
|
||||
|
||||
function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) {
|
||||
return mount(IdentityMatchBanner, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type', 'variant', 'prominent'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('IdentityMatchBanner', () => {
|
||||
it('renders nothing when status is null', () => {
|
||||
const w = mountBanner({ status: null })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the pending banner with info type and backend message when provided', () => {
|
||||
const w = mountBanner({
|
||||
status: 'pending',
|
||||
message: 'We controleren of je al bekend bent bij de organisator.',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('We controleren')
|
||||
})
|
||||
|
||||
it('renders the matched banner with success type and backend message when provided', () => {
|
||||
const w = mountBanner({
|
||||
status: 'matched',
|
||||
message: 'Je account is gekoppeld aan een bekende deelnemer.',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('success')
|
||||
expect(w.text()).toContain('gekoppeld')
|
||||
})
|
||||
|
||||
it('falls back to frontend copy when backend message is missing', () => {
|
||||
const w = mountBanner({ status: 'none', message: null })
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('success')
|
||||
expect(w.text()).toContain('Aanmelding ontvangen')
|
||||
})
|
||||
})
|
||||
@@ -3,19 +3,19 @@
|
||||
// the enums at api/app/Enums/FormBuilder/.
|
||||
//
|
||||
// Shared schema types (FormFieldType, FormFieldDisplayWidth, etc.) are
|
||||
// imported from @form-schema so portal and app stay in sync on the
|
||||
// submit-side contract. Organizer-only types (lifecycle, payloads,
|
||||
// purpose/submission_mode enums) live here.
|
||||
// imported from the inlined form-schema source at composables/forms so
|
||||
// portal and app stay in sync on the submit-side contract. Organizer-only
|
||||
// types (lifecycle, payloads, purpose/submission_mode enums) live here.
|
||||
|
||||
import type {
|
||||
ConditionalLogic,
|
||||
FormFieldDisplayWidth,
|
||||
FormFieldType,
|
||||
FormFieldValidationRules,
|
||||
} from '@form-schema/types/formBuilder'
|
||||
} from '@/composables/forms/types/formBuilder'
|
||||
|
||||
// Re-export shared field primitives so consumers of this module don't
|
||||
// need to reach into @form-schema directly.
|
||||
// need to reach into the forms package directly.
|
||||
export type { ConditionalLogic, FormFieldDisplayWidth, FormFieldType, FormFieldValidationRules }
|
||||
|
||||
// Mirrors api/app/Enums/FormBuilder/FormPurpose.php
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Deterministic idempotency-key generation for useFormDraft tests.
|
||||
if (!globalThis.crypto) {
|
||||
;(globalThis as { crypto: Crypto }).crypto = {
|
||||
randomUUID: () => '00000000-0000-4000-8000-000000000000',
|
||||
getRandomValues: (buf: Uint8Array) => {
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = 0
|
||||
|
||||
return buf
|
||||
},
|
||||
} as unknown as Crypto
|
||||
}
|
||||
|
||||
// Default vue-router mock — individual tests can override with their own mock.
|
||||
// Page-level tests that exercise the actual router should not import this.
|
||||
vi.mock('vue-router', () => ({
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
"@validators": [
|
||||
"./src/@core/utils/validators"
|
||||
],
|
||||
"@form-schema/*": [
|
||||
"../../packages/form-schema/src/*"
|
||||
],
|
||||
"vue": [
|
||||
"./node_modules/vue"
|
||||
]
|
||||
@@ -69,8 +66,7 @@
|
||||
"./src/**/*.vue",
|
||||
"./themeConfig.ts",
|
||||
"./auto-imports.d.ts",
|
||||
"./components.d.ts",
|
||||
"../../packages/form-schema/src/**/*"
|
||||
"./components.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"./dist",
|
||||
|
||||
Reference in New Issue
Block a user