feat: frontend end

This commit is contained in:
Antoine Pelletier 2025-10-14 04:41:25 +02:00
parent a05fc140d2
commit b6cf4aeb1b
3 changed files with 351 additions and 80 deletions

View file

@ -11,6 +11,9 @@ const props = defineProps({
modelValue: { type: Date, default: null },
minuteStep: { type: Number, default: 5 }, // minute granularity (1..30)
locale: { type: String, default: "en-US" }, // for date formatting
// New: optional date bounds to constrain calendar selection
minDate: { type: [Date, String, Object], default: null },
maxDate: { type: [Date, String, Object], default: null },
})
const emit = defineEmits(["update:modelValue"])
@ -52,6 +55,17 @@ const toJsDate = (v) => {
return undefined
}
/* ---------- date disabling based on min/max ---------- */
const calendarIsDisabled = (d) => {
const js = toJsDate(d)
if (!js) return false
const min = toJsDate(props.minDate)
const max = toJsDate(props.maxDate)
if (min && js < min) return true
if (max && js > max) return true
return false
}
/* ---------- sync from v-model ---------- */
watch(
() => props.modelValue,
@ -152,6 +166,7 @@ function transHour(h) {
:key="date?.toDateString?.() ?? 'empty'"
@update:modelValue="onDateChange"
initial-focus
:isDateDisabled="calendarIsDisabled"
/>
</PopoverContent>
</Popover>

View file

@ -36,7 +36,99 @@
<div class="card-content">
<div v-if="bookings.length === 0" class="text-sm text-slate-500">Aucune réservation pour le moment.</div>
<div v-else class="grid gap-3">
<!-- When showing all, split into two independent scroll lists -->
<div v-else-if="filter==='all'" class="grid gap-6">
<!-- Pending requests -->
<div>
<div class="text-sm font-medium mb-2">Demandes en attente</div>
<div v-if="pendingList.length===0" class="text-xs text-slate-500">Aucune demande de réservation pour le moment.</div>
<div v-else class="overflow-y-auto max-h-[30vh] pr-1 border rounded-md">
<div class="grid gap-3 p-2">
<div
v-for="b in pendingList"
:key="b.id"
:ref="el => setBookingRef(b.id, el)"
:id="`booking-${b.id}`"
class="rounded-lg border border-slate-200 p-3 grid gap-2 bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))]"
:class="{ 'ring-2 ring-sky-400 border-sky-300': highlightId === b.id }"
>
<!-- booking row -->
<div class="flex items-start justify-between gap-3">
<div class="grid gap-1">
<div class="flex items-center gap-2">
<span class="font-medium">#{{ b.id }}</span>
<StatusBadge :booking="b" :now="now" />
</div>
<div class="text-sm"><span class="font-medium">Association:</span> <span class="ml-1">{{ b.name }}</span></div>
<div class="text-xs text-slate-500">Email(s): {{ b.email }}</div>
<div class="text-xs text-slate-600"><span class="font-medium">Période:</span> <span class="ml-1">{{ formatRange(b) }}</span></div>
<div class="text-xs text-slate-600"><span class="font-medium">Cargobike(s):</span> <span class="ml-1">{{ formatBikes(b) }}</span></div>
</div>
<div class="grid gap-2 justify-items-end min-w-[220px]">
<div class="flex flex-wrap gap-2 justify-end">
<!-- Pending actions: allow edit multi, accept/refuse, delete -->
<button class="btn btn-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
<button class="btn btn-outline h-8 px-3" @click="accept(b)">Accepter</button>
<button class="btn btn-outline h-8 px-3" @click="refuse(b)">Refuser</button>
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
</div>
<div class="text-xs text-slate-500">Créée le {{ formatDateTime(new Date(b.created_at)) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Active bookings: ongoing + upcoming accepted -->
<div>
<div class="text-sm font-medium mb-2">Réservations (en cours et à venir)</div>
<div class="overflow-y-auto max-h-[40vh] pr-1 border rounded-md">
<div class="grid gap-3 p-2">
<div
v-for="b in activeList"
:key="b.id"
:ref="el => setBookingRef(b.id, el)"
:id="`booking-${b.id}`"
class="rounded-lg border border-slate-200 p-3 grid gap-2 bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))]"
:class="{ 'ring-2 ring-sky-400 border-sky-300': highlightId === b.id }"
>
<div class="flex items-start justify-between gap-3">
<div class="grid gap-1">
<div class="flex items-center gap-2">
<span class="font-medium">#{{ b.id }}</span>
<StatusBadge :booking="b" :now="now" />
</div>
<div class="text-sm"><span class="font-medium">Association:</span> <span class="ml-1">{{ b.name }}</span></div>
<div class="text-xs text-slate-500">Email(s): {{ b.email }}</div>
<div class="text-xs text-slate-600"><span class="font-medium">Période:</span> <span class="ml-1">{{ formatRange(b) }}</span></div>
<div class="text-xs text-slate-600"><span class="font-medium">Cargobike(s):</span> <span class="ml-1">{{ formatBikes(b) }}</span></div>
</div>
<div class="grid gap-2 justify-items-end min-w-[220px]">
<div class="flex flex-wrap gap-2 justify-end">
<!-- For refused bookings show only restore; for others hide edit unless pending -->
<template v-if="b.status === 'refused'">
<button class="btn btn-outline h-8 px-3" @click="restore(b)">Restaurer</button>
</template>
<template v-else>
<!-- No 'Modifier' here unless pending; activeList excludes pending -->
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
</template>
</div>
<div class="text-xs text-slate-500">Créée le {{ formatDateTime(new Date(b.created_at)) }}</div>
</div>
</div>
</div>
<div v-if="activeList.length===0" class="text-xs text-slate-500">Aucune réservation en cours ou à venir.</div>
</div>
</div>
</div>
</div>
<!-- Other filters: single scrollable list as before -->
<div v-else class="overflow-y-auto max-h-[60vh] pr-1">
<div class="grid gap-3">
<div
v-for="b in filtered"
:key="b.id"
@ -51,49 +143,29 @@
<span class="font-medium">#{{ b.id }}</span>
<StatusBadge :booking="b" :now="now" />
</div>
<div class="text-sm">
<span class="font-medium">Association:</span>
<span class="ml-1">{{ b.name }}</span>
</div>
<div class="text-sm"><span class="font-medium">Association:</span> <span class="ml-1">{{ b.name }}</span></div>
<div class="text-xs text-slate-500">Email(s): {{ b.email }}</div>
<div class="text-xs text-slate-600">
<span class="font-medium">Période:</span>
<span class="ml-1">{{ formatRange(b) }}</span>
</div>
<div class="text-xs text-slate-600">
<span class="font-medium">Cargobike(s):</span>
<span class="ml-1">{{ formatBikes(b) }}</span>
</div>
<div class="text-xs text-slate-600"><span class="font-medium">Période:</span> <span class="ml-1">{{ formatRange(b) }}</span></div>
<div class="text-xs text-slate-600"><span class="font-medium">Cargobike(s):</span> <span class="ml-1">{{ formatBikes(b) }}</span></div>
</div>
<div class="grid gap-2 justify-items-end min-w-[220px]">
<div class="flex flex-wrap gap-2 justify-end">
<button class="btn btn-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="accept(b)" v-if="b.status!=='accepted'">Accepter</button>
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="refuse(b)" v-if="b.status!=='accepted'">Refuser</button>
<button class="btn btn-outline h-8 px-3" v-if="b.status==='refused'" @click="restore(b)">Restaurer</button>
<template v-if="b.status === 'refused'">
<button class="btn btn-outline h-8 px-3" @click="restore(b)">Restaurer</button>
</template>
<template v-else>
<button v-if="b.status==='pending'" class="btn btn-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
<button class="btn btn-outline h-8 px-3" @click="accept(b)" v-if="b.status!=='accepted'">Accepter</button>
<button class="btn btn-outline h-8 px-3" @click="refuse(b)" v-if="b.status!=='accepted'">Refuser</button>
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
</template>
</div>
<div class="text-xs text-slate-500">Créée le {{ formatDateTime(new Date(b.created_at)) }}</div>
</div>
</div>
<!-- Inline edit row -->
<div v-if="editingId === b.id" class="rounded-md bg-slate-50 p-3 border border-slate-200">
<div class="grid sm:grid-cols-[1fr_auto] gap-3 items-end">
<div class="grid gap-2">
<label class="label">Attribuer un cargobike</label>
<select v-model="editForm.bike_type" class="input h-9">
<option :value="null"> Non défini </option>
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option>
</select>
<p class="helper text-xs text-slate-500">Astuce: pour un choix multiple, utilisez la page de réservation; ici on affecte un seul vélo.</p>
</div>
<div class="flex gap-2">
<button class="btn h-9 px-3" @click="saveEdit(b)">Enregistrer</button>
<button class="btn btn-secondary h-9 px-3" @click="cancelEdit">Annuler</button>
</div>
</div>
<!-- Inline edit removed; modal is used instead -->
</div>
</div>
</div>
@ -127,7 +199,7 @@
<div class="min-w-[900px]">
<!-- Header row: days -->
<div class="grid" :style="gridTemplateDays">
<div class="sticky left-0 bg-white z-10 border-b border-slate-200 p-2 text-xs text-slate-500">Heure</div>
<div class="sticky left-0 bg-[hsl(var(--card))] z-10 border-b border-slate-200 p-2 text-xs text-slate-500">Heure</div>
<div v-for="(d, idx) in weekDays" :key="idx" class="border-b border-l border-slate-200 p-2 text-xs font-medium text-slate-700">
{{ weekdayShort(d) }} {{ d.getDate() }}/{{ (d.getMonth()+1).toString().padStart(2,'0') }}
</div>
@ -136,7 +208,7 @@
<!-- Body: hours + day columns -->
<div class="grid" :style="gridTemplateDays">
<!-- Hours rail -->
<div class="sticky left-0 bg-white z-10 border-r border-slate-200">
<div class="sticky left-0 bg-[hsl(var(--card))] z-10 border-r border-slate-200">
<div v-for="h in hours" :key="h" class="h-12 border-b border-slate-100 px-2 text-[11px] text-slate-500 flex items-start pt-1">
{{ h.toString().padStart(2,'0') }}:00
</div>
@ -150,7 +222,7 @@
<div
v-for="(ev, idx) in dayEvents[dayIdx]"
:key="idx"
class="absolute rounded-md text-[11px] leading-tight px-2 py-1 text-white shadow cursor-pointer"
class="absolute rounded-md text-[11px] leading-tight px-2 py-1 text-white shadow cursor-pointer ring-1 ring-white/60 dark:ring-white/20"
:class="ev.status === 'pending' ? 'bg-amber-500' : 'bg-blue-600'"
:style="eventStyle(ev)"
role="button"
@ -169,6 +241,41 @@
<div class="text-xs text-slate-500">Seules les réservations acceptées sont affichées dans le calendrier.</div>
</div>
</div>
<!-- Edit modal (pending multi-select) -->
<div v-if="editingOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="cancelEdit"></div>
<div class="relative w-full max-w-lg rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold">Attribuer des cargobikes</h3>
<button class="btn btn-outline h-8 px-3" @click="cancelEdit">Fermer</button>
</div>
<div class="grid gap-3">
<div class="grid gap-2">
<div class="text-sm font-medium">Grands cargos</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label v-for="t in bigBikes" :key="t" class="flex items-center gap-2 text-sm">
<input type="checkbox" :value="t" v-model="editForm.bike_types" class="h-4 w-4" />
<span>{{ t }}</span>
</label>
</div>
</div>
<div class="grid gap-2">
<div class="text-sm font-medium">Petits cargos</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label v-for="t in smallBikes" :key="t" class="flex items-center gap-2 text-sm">
<input type="checkbox" :value="t" v-model="editForm.bike_types" class="h-4 w-4" />
<span>{{ t }}</span>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-2">
<button class="btn btn-secondary h-9 px-3" @click="cancelEdit">Annuler</button>
<button class="btn h-9 px-3" @click="saveEditFromModal">Enregistrer</button>
</div>
</div>
</div>
</div>
</section>
</template>
@ -188,7 +295,7 @@ const filter = ref('all')
// Editing state
const editingId = ref(null)
const editForm = reactive({ bike_type: null })
const editForm = reactive({ bike_type: null, bike_types: [] })
// Calendar state
const selectedBike = ref('all')
@ -366,14 +473,22 @@ function formatTime(d) {
function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` }
function startEdit(b) {
if (b.status !== 'pending') return
editingId.value = b.id
const bt = getSingleBikeType(b)
editForm.bike_type = bt !== null ? bt : null
// Preload multi bike types from booking
const multi = getMultiBikeTypes(b)
editForm.bike_type = null
editForm.bike_types = [...multi]
editingOpen.value = true
}
function cancelEdit() { editingId.value = null }
function cancelEdit() { editingOpen.value = false; editingId.value = null }
async function saveEdit(b) {
try {
const payload = { bike_type: editForm.bike_type, bike_types: null }
if (!editForm.bike_types || editForm.bike_types.length === 0) {
alert('Sélectionnez au moins un cargobike.')
return
}
const payload = { bike_type: null, bike_types: editForm.bike_types.join(',') }
await api.updateBooking(b.id, payload)
editingId.value = null
await loadBookings()
@ -381,10 +496,53 @@ async function saveEdit(b) {
alert('Échec de la mise à jour.')
}
}
async function saveEditFromModal() {
if (editingId.value == null) { editingOpen.value = false; return }
await saveEdit({ id: editingId.value })
editingOpen.value = false
}
// Actions: accept / refuse / restore / delete
async function accept(b) {
try {
// Check bike assignment exists
const bikesSet = new Set([getSingleBikeType(b)].filter(n => Number.isFinite(n)))
getMultiBikeTypes(b).forEach(n => bikesSet.add(n))
if (bikesSet.size === 0) {
error.value = `Impossible daccepter la réservation #${b.id} : aucun cargobike nest attribué.`
return
}
// Check conflicts with already accepted bookings (same bikes, overlapping period)
const s = parseDateTime(b.start_date, b.start_time)
const e = parseDateTime(b.end_date, b.end_time, true)
const conflicts = []
for (const other of bookings.value) {
if (!other || other.id === b.id) continue
if (other.status !== 'accepted') continue
// bikes intersection
const otherSet = new Set([getSingleBikeType(other)].filter(n => Number.isFinite(n)))
getMultiBikeTypes(other).forEach(n => otherSet.add(n))
const shared = [...bikesSet].filter(n => otherSet.has(n))
if (shared.length === 0) continue
// time overlap: s<otherEnd && e>otherStart
const os = parseDateTime(other.start_date, other.start_time)
const oe = parseDateTime(other.end_date, other.end_time, true)
if (s < oe && e > os) {
conflicts.push({ other, shared })
}
}
if (conflicts.length > 0) {
const details = conflicts.slice(0,3).map(c => `#${c.other.id} (${c.shared.join(', ')})`).join(', ')
const more = conflicts.length > 3 ? ` et ${conflicts.length - 3} autre(s)` : ''
error.value = `Conflit: impossible daccepter #${b.id}. Cargobike(s) déjà réservé(s) sur ce créneau: ${details}${more}.`
return
}
await api.updateBookingStatus(b.id, 'accepted')
await loadBookings()
} catch (e) {
@ -434,16 +592,42 @@ function uiStatus(b) {
return 'pending'
}
// The filtered list used by the template
function startDateTime(b) { return parseDateTime(b.start_date, b.start_time) }
function byStartAsc(a, b) { return startDateTime(a) - startDateTime(b) }
// Lists for filter==='all'
const pendingList = computed(() => (bookings.value || []).filter(b => b.status==='pending').sort(byStartAsc))
const activeList = computed(() => {
const nowD = now.value
const ongoing = (bookings.value || []).filter(b => uiStatus(b)==='ongoing').sort(byStartAsc)
const upcomingAccepted = (bookings.value || []).filter(b => b.status==='accepted' && nowD < startDateTime(b)).sort(byStartAsc)
return [...ongoing, ...upcomingAccepted]
})
// The filtered list used by the template (single list for non-all filters)
const filtered = computed(() => {
if (filter.value === 'all') return bookings.value
if (filter.value === 'archived') return bookings.value.filter(b => uiStatus(b) === 'archived' || b.status === 'refused')
if (filter.value === 'refused') return bookings.value.filter(b => b.status === 'refused')
return bookings.value.filter(b => uiStatus(b) === filter.value)
const list = bookings.value || []
if (filter.value === 'archived') {
return list.filter(b => uiStatus(b) === 'archived' || b.status === 'refused').sort((a,b)=> startDateTime(b)-startDateTime(a))
}
if (filter.value === 'refused') {
return list.filter(b => b.status === 'refused').sort((a,b)=> startDateTime(b)-startDateTime(a))
}
if (filter.value === 'pending') {
return list.filter(b => b.status === 'pending').sort(byStartAsc)
}
if (filter.value === 'ongoing') {
return list.filter(b => uiStatus(b) === 'ongoing').sort(byStartAsc)
}
if (filter.value === 'accepted') {
return list.filter(b => b.status === 'accepted').sort(byStartAsc)
}
// 'all' handled separately in template
return list
})
// Calendar computed
const hours = Array.from({ length: 17 }, (_, i) => i + 6) // 06:00 -> 22:00
const hours = Array.from({ length: 24 }, (_, i) => i) // 00:00 -> 23:00
const weekStart = computed(() => {
const d = new Date(calendarAnchor.value)
const day = d.getDay() || 7 // 1..7, Monday=1
@ -532,8 +716,8 @@ const dayEvents = computed(() => {
const endMin = segEnd.getHours() * 60 + segEnd.getMinutes()
// Restrict to visible hour range
const visStart = 6 * 60
const visEnd = 22 * 60
const visStart = 0
const visEnd = 24 * 60
const topMin = Math.max(startMin, visStart)
const botMin = Math.min(endMin, visEnd)
if (botMin <= visStart || topMin >= visEnd) continue
@ -570,8 +754,8 @@ const dayEvents = computed(() => {
})
function eventStyle(ev) {
const pxPerMin = 12 / 15 // 48px per hour 0.8px per minute; Wait: 12*4=48; yes 0.8
const top = (ev.topMin - 6*60) * pxPerMin
const pxPerMin = 12 / 15 // 48px per hour 0.8px per minute
const top = (ev.topMin) * pxPerMin
const height = Math.max(18, (ev.bottomMin - ev.topMin) * pxPerMin)
const widthPct = 100 / ev.colCount
const leftPct = ev.col * widthPct
@ -579,14 +763,20 @@ function eventStyle(ev) {
top: top + 'px',
height: height + 'px',
left: leftPct + '%',
width: `calc(${widthPct}% - 4px)`,
marginLeft: '2px',
marginRight: '2px',
width: `calc(${widthPct}% - 8px)`, // increase horizontal gap between overlapping events
marginLeft: '4px',
marginRight: '4px',
}
}
// Modal state for editing
const editingOpen = ref(false)
// Bike grouping for modal
const bigBikes = computed(() => (bikes.value || []).filter(n => Number(n) < 3000))
const smallBikes = computed(() => (bikes.value || []).filter(n => Number(n) >= 3000))
</script>
<!-- Status badge component remains unchanged here -->
<script>
export default {
name: 'AdminPage',
@ -628,7 +818,8 @@ export default {
computed: {
ui() {
const b = this.booking
if (b.status === 'refused') return { key: 'archived', label: 'Archivé', cls: 'bg-slate-200 text-slate-700' }
// Distinct refused label
if (b.status === 'refused') return { key: 'refused', label: 'Refusé', cls: 'bg-red-100 text-red-700' }
const s = this.parse(b.start_date, b.start_time)
const e = this.parse(b.end_date, b.end_time, true)
const n = this.now
@ -649,4 +840,7 @@ export default {
<style scoped>
/***** simple card/inputs if not present *****/
/* This page reuses utility classes from BookingPage (btn, input, card). */
/***** Make per-booking cards follow theme background in both lists *****/
.card .border-slate-200 { border-color: hsl(var(--border)); }
</style>

View file

@ -54,7 +54,7 @@
<span class="label">Début de la réservation</span>
<!-- Ensure DateTimePicker fills the width -->
<div class="w-full">
<DateTimePicker class="w-full" v-model="startPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
<DateTimePicker class="w-full" v-model="startPicked" :use24h="true" :minute-step="5" :maxDate="endPicked" locale="fr-FR" />
</div>
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
</div>
@ -63,7 +63,7 @@
<div class="grid gap-2 w-full">
<span class="label">Fin de la réservation</span>
<div class="w-full">
<DateTimePicker class="w-full" v-model="endPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
<DateTimePicker class="w-full" v-model="endPicked" :use24h="true" :minute-step="5" :minDate="startPicked" locale="fr-FR" />
</div>
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
</div>
@ -72,7 +72,7 @@
<div class="grid gap-2 w-full">
<span class="label">Adresses mail du/des comptes Linka Go à autoriser</span>
<div class="grid gap-2">
<div v-for="(_, idx) in form.emails" :key="idx" class="flex gap-2">
<div v-for="(_, idx) in form.emails" :key="idx" class="flex gap-2 w-full">
<input :id="`email-${idx}`" v-model.trim="form.emails[idx]" type="email" class="input w-full" placeholder="prenom.nom@exemple.com" />
<button type="button" class="btn btn-outline h-10 px-3" @click="removeEmail(idx)" v-if="form.emails.length > 1">Retirer</button>
</div>
@ -88,6 +88,7 @@
<button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm" :disabled="submitting">Réinitialiser</button>
</div>
<!-- Keep inline messages as secondary feedback -->
<p v-if="status.success" class="text-green-700 text-sm">{{ status.message }}</p>
<p v-if="status.error" class="text-red-700 text-sm">{{ status.error }}</p>
</form>
@ -97,6 +98,22 @@
<div class="text-xs text-slate-500">
En soumettant, vous acceptez que lAGEP traite ces données pour gérer votre réservation. Vous recevrez une confirmation par email si nécessaire.
</div>
<!-- Centered modal for success/error -->
<div v-if="modal.open" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="closeModal"></div>
<div class="relative w-full max-w-md rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-xl p-5">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-base font-semibold" :class="modal.type==='success' ? 'text-emerald-700' : 'text-red-700'">
{{ modal.type==='success' ? 'Demande envoyée' : 'Impossible denvoyer la demande' }}
</h3>
<p class="mt-2 text-sm" v-text="modal.message"></p>
</div>
<button type="button" class="btn btn-outline h-8 px-3" @click="closeModal">Fermer</button>
</div>
</div>
</div>
</section>
</template>
@ -111,6 +128,19 @@ const smallBikes = [3000, 4000, 5000]
const submitting = ref(false)
const status = reactive({ success: false, message: '', error: '' })
// Centered modal state
const modal = reactive({ open: false, type: /** @type {'success'|'error'|''} */(''), message: '' })
function openModal(type, message) {
modal.type = type
modal.message = message
modal.open = true
// auto-dismiss after 5s for success
if (type === 'success') {
setTimeout(() => { if (modal.open && modal.type==='success') modal.open = false }, 5000)
}
}
function closeModal() { modal.open = false }
const form = reactive({
association: '',
bikeTypes: [],
@ -141,6 +171,16 @@ watch(startPicked, (d) => {
form.startDate = null
form.startTime = ''
}
// Live rule: if end is before or equal to start, clear end and set helper
if (endPicked.value instanceof Date && d instanceof Date && endPicked.value <= d) {
endPicked.value = null
errors.end = 'La fin doit être après le début.'
} else if (!endPicked.value) {
// keep message if still empty and start is set
if (!d) errors.end = ''
} else {
errors.end = ''
}
})
watch(endPicked, (d) => {
@ -151,6 +191,12 @@ watch(endPicked, (d) => {
form.endDate = null
form.endTime = ''
}
// Live check: ensure end > start
if (startPicked.value instanceof Date && d instanceof Date && d <= startPicked.value) {
errors.end = 'La fin doit être après le début.'
} else {
if (form.endDate && form.endTime) errors.end = ''
}
})
const errors = reactive({
@ -195,6 +241,15 @@ function toISODate(d) {
return `${yyyy}-${mm}-${dd}`
}
// Live email format validation
function validateEmailsLive() {
const list = form.emails.map(e => e.trim()).filter(e => e.length > 0)
if (list.length === 0) { errors.emails = '' ; return }
const bad = list.filter(e => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))
errors.emails = bad.length ? 'Une ou plusieurs adresses email sont invalides.' : ''
}
watch(() => form.emails.slice(), validateEmailsLive, { deep: true })
function validate() {
Object.keys(errors).forEach(k => errors[k] = '')
let ok = true
@ -221,7 +276,12 @@ function validate() {
}
async function onSubmit() {
if (!validate()) return
// Validate; on error show central message with a hint
if (!validate()) {
openModal('error', 'Votre demande na pas pu être envoyée. Corrigez les erreurs du formulaire (par exemple, la fin doit être après le début) puis réessayez.')
status.error = 'Veuillez corriger les erreurs cidessus.'
return
}
submitting.value = true
status.success = false
status.error = ''
@ -238,12 +298,14 @@ async function onSubmit() {
name: form.association,
email: form.emails.map(e => e.trim()).filter(Boolean).join(','),
}
const booking = await api.createBooking(payload)
await api.createBooking(payload)
status.success = true
status.message = `Votre demande a été enregistrée (n° ${booking.id}).`
status.message = 'Votre demande a bien été envoyée. Vous recevrez bientôt un email pour confirmer la réservation.'
openModal('success', 'Votre demande a bien été envoyée. Vous recevrez bientôt un email pour confirmer la réservation.')
resetForm()
} catch (e) {
status.error = 'Échec de lenregistrement. Réessayez plus tard.'
openModal('error', 'Un problème est survenu lors de lenvoi de votre demande. Réessayez plus tard.')
} finally {
submitting.value = false
}