feat: frontend end
This commit is contained in:
parent
a05fc140d2
commit
b6cf4aeb1b
3 changed files with 351 additions and 80 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 d’accepter la réservation #${b.id} : aucun cargobike n’est 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 d’accepter #${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>
|
||||||
|
|
|
||||||
|
|
@ -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 l’AGEP traite ces données pour gérer votre réservation. Vous recevrez une confirmation par e‑mail si nécessaire.
|
En soumettant, vous acceptez que l’AGEP traite ces données pour gérer votre réservation. Vous recevrez une confirmation par e‑mail 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 d’envoyer 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 e‑mail 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 n’a 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 ci‑dessus.'
|
||||||
|
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 e‑mail pour confirmer la réservation.'
|
||||||
|
openModal('success', 'Votre demande a bien été envoyée. Vous recevrez bientôt un e‑mail pour confirmer la réservation.')
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.error = 'Échec de l’enregistrement. Réessayez plus tard.'
|
status.error = 'Échec de l’enregistrement. Réessayez plus tard.'
|
||||||
|
openModal('error', 'Un problème est survenu lors de l’envoi de votre demande. Réessayez plus tard.')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue