From b6cf4aeb1b232707ff8cbe7c26ce85d8e754d2a0 Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Tue, 14 Oct 2025 04:41:25 +0200 Subject: [PATCH] feat: frontend end --- src/components/DateTimePicker.vue | 15 ++ src/pages/AdminPage.vue | 342 +++++++++++++++++++++++------- src/pages/BookingPage.vue | 74 ++++++- 3 files changed, 351 insertions(+), 80 deletions(-) diff --git a/src/components/DateTimePicker.vue b/src/components/DateTimePicker.vue index 502ef1e..71ac1e9 100644 --- a/src/components/DateTimePicker.vue +++ b/src/components/DateTimePicker.vue @@ -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" /> diff --git a/src/pages/AdminPage.vue b/src/pages/AdminPage.vue index b7b5207..17c157c 100644 --- a/src/pages/AdminPage.vue +++ b/src/pages/AdminPage.vue @@ -36,64 +36,136 @@
Aucune réservation pour le moment.
-
-
-
-
-
- #{{ b.id }} - + +
+ +
+
Demandes en attente
+
Aucune demande de réservation pour le moment.
+
+
+
+ +
+
+
+ #{{ b.id }} + +
+
Association: {{ b.name }}
+
Email(s): {{ b.email }}
+
Période: {{ formatRange(b) }}
+
Cargobike(s): {{ formatBikes(b) }}
+
+
+
+ + + + + +
+
Créée le {{ formatDateTime(new Date(b.created_at)) }}
+
+
-
- Association: - {{ b.name }} -
-
Email(s): {{ b.email }}
-
- Période: - {{ formatRange(b) }} -
-
- Cargobike(s): - {{ formatBikes(b) }} -
-
- -
-
- - - - - -
-
Créée le {{ formatDateTime(new Date(b.created_at)) }}
+
- -
-
-
- - -

Astuce: pour un choix multiple, utilisez la page de réservation; ici on affecte un seul vélo.

+ +
+
Réservations (en cours et à venir)
+
+
+
+
+
+
+ #{{ b.id }} + +
+
Association: {{ b.name }}
+
Email(s): {{ b.email }}
+
Période: {{ formatRange(b) }}
+
Cargobike(s): {{ formatBikes(b) }}
+
+ +
+
+ + + +
+
Créée le {{ formatDateTime(new Date(b.created_at)) }}
+
+
-
- - +
Aucune réservation en cours ou à venir.
+
+
+
+
+ + +
+
+
+
+
+
+ #{{ b.id }} + +
+
Association: {{ b.name }}
+
Email(s): {{ b.email }}
+
Période: {{ formatRange(b) }}
+
Cargobike(s): {{ formatBikes(b) }}
+
+ +
+
+ + +
+
Créée le {{ formatDateTime(new Date(b.created_at)) }}
+ +
@@ -127,7 +199,7 @@
-
Heure
+
Heure
{{ weekdayShort(d) }} {{ d.getDate() }}/{{ (d.getMonth()+1).toString().padStart(2,'0') }}
@@ -136,7 +208,7 @@
-
+
{{ h.toString().padStart(2,'0') }}:00
@@ -150,7 +222,7 @@
Seules les réservations acceptées sont affichées dans le calendrier.
+ + +
+
+
+
+

Attribuer des cargobikes

+ +
+
+
+
Grands cargos
+
+ +
+
+
+
Petits cargos
+
+ +
+
+
+ + +
+
+
+
@@ -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 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: sotherStart + 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 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)) -