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 }, modelValue: { type: Date, default: null },
minuteStep: { type: Number, default: 5 }, // minute granularity (1..30) minuteStep: { type: Number, default: 5 }, // minute granularity (1..30)
locale: { type: String, default: "en-US" }, // for date formatting 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"]) const emit = defineEmits(["update:modelValue"])
@ -52,6 +55,17 @@ const toJsDate = (v) => {
return undefined 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 ---------- */ /* ---------- sync from v-model ---------- */
watch( watch(
() => props.modelValue, () => props.modelValue,
@ -152,6 +166,7 @@ function transHour(h) {
:key="date?.toDateString?.() ?? 'empty'" :key="date?.toDateString?.() ?? 'empty'"
@update:modelValue="onDateChange" @update:modelValue="onDateChange"
initial-focus initial-focus
:isDateDisabled="calendarIsDisabled"
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View file

@ -36,64 +36,136 @@
<div class="card-content"> <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-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 <div v-else-if="filter==='all'" class="grid gap-6">
v-for="b in filtered" <!-- Pending requests -->
:key="b.id" <div>
:ref="el => setBookingRef(b.id, el)" <div class="text-sm font-medium mb-2">Demandes en attente</div>
:id="`booking-${b.id}`" <div v-if="pendingList.length===0" class="text-xs text-slate-500">Aucune demande de réservation pour le moment.</div>
class="rounded-lg border border-slate-200 p-3 grid gap-2" <div v-else class="overflow-y-auto max-h-[30vh] pr-1 border rounded-md">
:class="{ 'ring-2 ring-sky-400 border-sky-300': highlightId === b.id }" <div class="grid gap-3 p-2">
> <div
<div class="flex items-start justify-between gap-3"> v-for="b in pendingList"
<div class="grid gap-1"> :key="b.id"
<div class="flex items-center gap-2"> :ref="el => setBookingRef(b.id, el)"
<span class="font-medium">#{{ b.id }}</span> :id="`booking-${b.id}`"
<StatusBadge :booking="b" :now="now" /> 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 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">
<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>
<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>
<!-- Inline edit row --> <!-- Active bookings: ongoing + upcoming accepted -->
<div v-if="editingId === b.id" class="rounded-md bg-slate-50 p-3 border border-slate-200"> <div>
<div class="grid sm:grid-cols-[1fr_auto] gap-3 items-end"> <div class="text-sm font-medium mb-2">Réservations (en cours et à venir)</div>
<div class="grid gap-2"> <div class="overflow-y-auto max-h-[40vh] pr-1 border rounded-md">
<label class="label">Attribuer un cargobike</label> <div class="grid gap-3 p-2">
<select v-model="editForm.bike_type" class="input h-9"> <div
<option :value="null"> Non défini </option> v-for="b in activeList"
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option> :key="b.id"
</select> :ref="el => setBookingRef(b.id, el)"
<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> :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>
<div class="flex gap-2"> <div v-if="activeList.length===0" class="text-xs text-slate-500">Aucune réservation en cours ou à venir.</div>
<button class="btn h-9 px-3" @click="saveEdit(b)">Enregistrer</button> </div>
<button class="btn btn-secondary h-9 px-3" @click="cancelEdit">Annuler</button> </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"
:ref="el => setBookingRef(b.id, el)"
:id="`booking-${b.id}`"
class="rounded-lg border border-slate-200 p-3 grid gap-2"
: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">
<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>
</div> </div>
<!-- Inline edit removed; modal is used instead -->
</div> </div>
</div> </div>
</div> </div>
@ -127,7 +199,7 @@
<div class="min-w-[900px]"> <div class="min-w-[900px]">
<!-- Header row: days --> <!-- Header row: days -->
<div class="grid" :style="gridTemplateDays"> <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"> <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') }} {{ weekdayShort(d) }} {{ d.getDate() }}/{{ (d.getMonth()+1).toString().padStart(2,'0') }}
</div> </div>
@ -136,7 +208,7 @@
<!-- Body: hours + day columns --> <!-- Body: hours + day columns -->
<div class="grid" :style="gridTemplateDays"> <div class="grid" :style="gridTemplateDays">
<!-- Hours rail --> <!-- 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"> <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 {{ h.toString().padStart(2,'0') }}:00
</div> </div>
@ -150,7 +222,7 @@
<div <div
v-for="(ev, idx) in dayEvents[dayIdx]" v-for="(ev, idx) in dayEvents[dayIdx]"
:key="idx" :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'" :class="ev.status === 'pending' ? 'bg-amber-500' : 'bg-blue-600'"
:style="eventStyle(ev)" :style="eventStyle(ev)"
role="button" 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 class="text-xs text-slate-500">Seules les réservations acceptées sont affichées dans le calendrier.</div>
</div> </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> </section>
</template> </template>
@ -188,7 +295,7 @@ const filter = ref('all')
// Editing state // Editing state
const editingId = ref(null) const editingId = ref(null)
const editForm = reactive({ bike_type: null }) const editForm = reactive({ bike_type: null, bike_types: [] })
// Calendar state // Calendar state
const selectedBike = ref('all') const selectedBike = ref('all')
@ -366,14 +473,22 @@ function formatTime(d) {
function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` } function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` }
function startEdit(b) { function startEdit(b) {
if (b.status !== 'pending') return
editingId.value = b.id editingId.value = b.id
const bt = getSingleBikeType(b) // Preload multi bike types from booking
editForm.bike_type = bt !== null ? bt : null 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) { async function saveEdit(b) {
try { 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) await api.updateBooking(b.id, payload)
editingId.value = null editingId.value = null
await loadBookings() await loadBookings()
@ -381,10 +496,53 @@ async function saveEdit(b) {
alert('Échec de la mise à jour.') 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 // Actions: accept / refuse / restore / delete
async function accept(b) { async function accept(b) {
try { 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 api.updateBookingStatus(b.id, 'accepted')
await loadBookings() await loadBookings()
} catch (e) { } catch (e) {
@ -434,16 +592,42 @@ function uiStatus(b) {
return 'pending' 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(() => { const filtered = computed(() => {
if (filter.value === 'all') return bookings.value const list = bookings.value || []
if (filter.value === 'archived') return bookings.value.filter(b => uiStatus(b) === 'archived' || b.status === 'refused') if (filter.value === 'archived') {
if (filter.value === 'refused') return bookings.value.filter(b => b.status === 'refused') return list.filter(b => uiStatus(b) === 'archived' || b.status === 'refused').sort((a,b)=> startDateTime(b)-startDateTime(a))
return bookings.value.filter(b => uiStatus(b) === filter.value) }
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 // 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 weekStart = computed(() => {
const d = new Date(calendarAnchor.value) const d = new Date(calendarAnchor.value)
const day = d.getDay() || 7 // 1..7, Monday=1 const day = d.getDay() || 7 // 1..7, Monday=1
@ -532,8 +716,8 @@ const dayEvents = computed(() => {
const endMin = segEnd.getHours() * 60 + segEnd.getMinutes() const endMin = segEnd.getHours() * 60 + segEnd.getMinutes()
// Restrict to visible hour range // Restrict to visible hour range
const visStart = 6 * 60 const visStart = 0
const visEnd = 22 * 60 const visEnd = 24 * 60
const topMin = Math.max(startMin, visStart) const topMin = Math.max(startMin, visStart)
const botMin = Math.min(endMin, visEnd) const botMin = Math.min(endMin, visEnd)
if (botMin <= visStart || topMin >= visEnd) continue if (botMin <= visStart || topMin >= visEnd) continue
@ -570,8 +754,8 @@ const dayEvents = computed(() => {
}) })
function eventStyle(ev) { function eventStyle(ev) {
const pxPerMin = 12 / 15 // 48px per hour 0.8px per minute; Wait: 12*4=48; yes 0.8 const pxPerMin = 12 / 15 // 48px per hour 0.8px per minute
const top = (ev.topMin - 6*60) * pxPerMin const top = (ev.topMin) * pxPerMin
const height = Math.max(18, (ev.bottomMin - ev.topMin) * pxPerMin) const height = Math.max(18, (ev.bottomMin - ev.topMin) * pxPerMin)
const widthPct = 100 / ev.colCount const widthPct = 100 / ev.colCount
const leftPct = ev.col * widthPct const leftPct = ev.col * widthPct
@ -579,14 +763,20 @@ function eventStyle(ev) {
top: top + 'px', top: top + 'px',
height: height + 'px', height: height + 'px',
left: leftPct + '%', left: leftPct + '%',
width: `calc(${widthPct}% - 4px)`, width: `calc(${widthPct}% - 8px)`, // increase horizontal gap between overlapping events
marginLeft: '2px', marginLeft: '4px',
marginRight: '2px', 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> </script>
<!-- Status badge component remains unchanged here -->
<script> <script>
export default { export default {
name: 'AdminPage', name: 'AdminPage',
@ -628,7 +818,8 @@ export default {
computed: { computed: {
ui() { ui() {
const b = this.booking 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 s = this.parse(b.start_date, b.start_time)
const e = this.parse(b.end_date, b.end_time, true) const e = this.parse(b.end_date, b.end_time, true)
const n = this.now const n = this.now
@ -649,4 +840,7 @@ export default {
<style scoped> <style scoped>
/***** simple card/inputs if not present *****/ /***** simple card/inputs if not present *****/
/* This page reuses utility classes from BookingPage (btn, input, card). */ /* 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> </style>

View file

@ -54,7 +54,7 @@
<span class="label">Début de la réservation</span> <span class="label">Début de la réservation</span>
<!-- Ensure DateTimePicker fills the width --> <!-- Ensure DateTimePicker fills the width -->
<div class="w-full"> <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> </div>
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p> <p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
</div> </div>
@ -63,7 +63,7 @@
<div class="grid gap-2 w-full"> <div class="grid gap-2 w-full">
<span class="label">Fin de la réservation</span> <span class="label">Fin de la réservation</span>
<div class="w-full"> <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> </div>
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p> <p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
</div> </div>
@ -72,7 +72,7 @@
<div class="grid gap-2 w-full"> <div class="grid gap-2 w-full">
<span class="label">Adresses mail du/des comptes Linka Go à autoriser</span> <span class="label">Adresses mail du/des comptes Linka Go à autoriser</span>
<div class="grid gap-2"> <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" /> <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> <button type="button" class="btn btn-outline h-10 px-3" @click="removeEmail(idx)" v-if="form.emails.length > 1">Retirer</button>
</div> </div>
@ -88,6 +88,7 @@
<button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm" :disabled="submitting">Réinitialiser</button> <button type="button" class="btn btn-secondary w-full sm:w-auto" @click="resetForm" :disabled="submitting">Réinitialiser</button>
</div> </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.success" class="text-green-700 text-sm">{{ status.message }}</p>
<p v-if="status.error" class="text-red-700 text-sm">{{ status.error }}</p> <p v-if="status.error" class="text-red-700 text-sm">{{ status.error }}</p>
</form> </form>
@ -97,6 +98,22 @@
<div class="text-xs text-slate-500"> <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. 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> </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> </section>
</template> </template>
@ -111,6 +128,19 @@ const smallBikes = [3000, 4000, 5000]
const submitting = ref(false) const submitting = ref(false)
const status = reactive({ success: false, message: '', error: '' }) 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({ const form = reactive({
association: '', association: '',
bikeTypes: [], bikeTypes: [],
@ -141,6 +171,16 @@ watch(startPicked, (d) => {
form.startDate = null form.startDate = null
form.startTime = '' 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) => { watch(endPicked, (d) => {
@ -151,6 +191,12 @@ watch(endPicked, (d) => {
form.endDate = null form.endDate = null
form.endTime = '' 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({ const errors = reactive({
@ -195,6 +241,15 @@ function toISODate(d) {
return `${yyyy}-${mm}-${dd}` 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() { function validate() {
Object.keys(errors).forEach(k => errors[k] = '') Object.keys(errors).forEach(k => errors[k] = '')
let ok = true let ok = true
@ -221,7 +276,12 @@ function validate() {
} }
async function onSubmit() { 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 submitting.value = true
status.success = false status.success = false
status.error = '' status.error = ''
@ -238,12 +298,14 @@ async function onSubmit() {
name: form.association, name: form.association,
email: form.emails.map(e => e.trim()).filter(Boolean).join(','), email: form.emails.map(e => e.trim()).filter(Boolean).join(','),
} }
const booking = await api.createBooking(payload) await api.createBooking(payload)
status.success = true 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() resetForm()
} catch (e) { } catch (e) {
status.error = 'Échec de lenregistrement. Réessayez plus tard.' 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 { } finally {
submitting.value = false submitting.value = false
} }