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: s
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 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))
-