472 lines
18 KiB
Vue
472 lines
18 KiB
Vue
<template>
|
||
<section class="grid gap-6">
|
||
<!-- Header -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h1 class="card-title">Administration des réservations</h1>
|
||
<p class="text-sm text-slate-500">Validez, refusez, modifiez les réservations et visualisez les plannings par cargobike.</p>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<button class="btn h-9 px-3" @click="refresh" :disabled="loading">{{ loading ? 'Rafraîchissement…' : 'Rafraîchir' }}</button>
|
||
<span class="text-xs text-slate-500" v-if="lastLoaded">Dernier chargement: {{ formatDateTime(lastLoaded) }}</span>
|
||
<span class="text-xs text-red-600" v-if="error">{{ error }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- List of bookings -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<h2 class="card-title">Réservations</h2>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-xs text-slate-500">Filtrer</label>
|
||
<select v-model="filter" class="input h-9 w-[160px]">
|
||
<option value="all">Toutes</option>
|
||
<option value="pending">En attente</option>
|
||
<option value="accepted">Acceptées</option>
|
||
<option value="ongoing">En cours</option>
|
||
<option value="archived">Archivées</option>
|
||
<option value="refused">Refusées</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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">
|
||
<div v-for="b in filtered" :key="b.id" class="rounded-lg border border-slate-200 p-3 grid gap-2">
|
||
<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">
|
||
<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>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Calendar per cargobike -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="flex flex-wrap items-center gap-3 justify-between">
|
||
<h2 class="card-title">Calendrier par cargobike</h2>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<label class="text-xs text-slate-500">Cargobike</label>
|
||
<select v-model="selectedBike" class="input h-9 w-[160px]">
|
||
<option value="all">Tous</option>
|
||
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-content grid gap-3">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<button class="btn btn-outline h-8 px-3" @click="prevWeek">Semaine -</button>
|
||
<button class="btn btn-outline h-8 px-3" @click="nextWeek">Semaine +</button>
|
||
<button class="btn btn-secondary h-8 px-3" @click="goToday">Aujourd’hui</button>
|
||
<div class="text-sm text-slate-600 ml-2">Semaine du {{ formatDate(weekStart) }} au {{ formatDate(weekEnd) }}</div>
|
||
</div>
|
||
|
||
<!-- Week grid -->
|
||
<div class="overflow-auto border border-slate-200 rounded-md">
|
||
<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 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>
|
||
</div>
|
||
|
||
<!-- 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 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>
|
||
</div>
|
||
|
||
<!-- Day columns -->
|
||
<div v-for="(d, dayIdx) in weekDays" :key="dayIdx" class="relative border-l border-slate-200" :data-date="d.toISOString()">
|
||
<!-- Hour lines -->
|
||
<div v-for="h in hours" :key="h" class="h-12 border-b border-slate-100"></div>
|
||
<!-- Events -->
|
||
<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"
|
||
:class="ev.status === 'pending' ? 'bg-amber-500' : 'bg-blue-600'"
|
||
:style="eventStyle(ev)">
|
||
<div class="font-medium truncate">{{ ev.name }}</div>
|
||
<div class="opacity-90 truncate">{{ ev.timeLabel }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-xs text-slate-500">Les réservations refusées ne sont pas affichées dans le calendrier.</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { api } from '@/services/api'
|
||
|
||
const bookings = ref([])
|
||
const bikes = ref([])
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const lastLoaded = ref(null)
|
||
const now = ref(new Date())
|
||
|
||
// Filter for the list
|
||
const filter = ref('all')
|
||
|
||
// Editing state
|
||
const editingId = ref(null)
|
||
const editForm = reactive({ bike_type: null })
|
||
|
||
// Calendar state
|
||
const selectedBike = ref('all')
|
||
const calendarAnchor = ref(new Date())
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadBikes(), loadBookings()])
|
||
if (!selectedBike.value && bikes.value.length) selectedBike.value = bikes.value[0]
|
||
// Tick 'now' every minute for live status
|
||
setInterval(() => { now.value = new Date() }, 60 * 1000)
|
||
})
|
||
|
||
async function loadBikes() {
|
||
try {
|
||
bikes.value = await api.getBikes()
|
||
} catch (e) {
|
||
error.value = `Erreur chargement vélos: ${e?.message || e}`
|
||
}
|
||
}
|
||
|
||
async function loadBookings() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
bookings.value = await api.listBookings()
|
||
lastLoaded.value = new Date()
|
||
} catch (e) {
|
||
error.value = `Impossible de charger les réservations. ${e?.message || ''}`
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function refresh() { return loadBookings() }
|
||
|
||
function getSingleBikeType(b) {
|
||
const val = b?.bike_type
|
||
return typeof val === 'number' && Number.isFinite(val) ? val : null
|
||
}
|
||
|
||
function getMultiBikeTypes(b) {
|
||
if (typeof b?.bike_types === 'string' && b.bike_types.trim()) {
|
||
return b.bike_types.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
|
||
}
|
||
return []
|
||
}
|
||
|
||
function includesBike(b, bike) {
|
||
if (bike === 'all') return true
|
||
const single = getSingleBikeType(b)
|
||
const multi = getMultiBikeTypes(b)
|
||
if (single !== null) return single === bike
|
||
if (multi.length) return multi.includes(bike)
|
||
return false
|
||
}
|
||
|
||
function safeHHmm(timeStr) {
|
||
if (!timeStr || typeof timeStr !== 'string') return ''
|
||
const [h = '00', m = '00'] = timeStr.split(':')
|
||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`
|
||
}
|
||
|
||
function formatRange(b) {
|
||
// Use raw times to avoid TZ issues; still compute if same day
|
||
const s = parseDateTime(b.start_date, b.start_time)
|
||
const e = parseDateTime(b.end_date, b.end_time, true)
|
||
const sameDay = s.toDateString() === e.toDateString()
|
||
const st = safeHHmm(b.start_time)
|
||
const et = safeHHmm(b.end_time)
|
||
if (sameDay) return `${formatDate(s)} ${st} → ${et}`
|
||
return `${formatDate(s)} ${st} → ${formatDate(e)} ${et}`
|
||
}
|
||
|
||
function formatBikes(b) {
|
||
const parts = []
|
||
const single = getSingleBikeType(b)
|
||
if (single !== null) parts.push(String(single))
|
||
const multi = getMultiBikeTypes(b).map(n => String(n))
|
||
parts.push(...multi)
|
||
return parts.length ? parts.join(', ') : '—'
|
||
}
|
||
|
||
function parseDateTime(dateStr, timeStr, end = false) {
|
||
// dateStr = 'YYYY-MM-DD', timeStr = 'HH:MM' or null
|
||
const [y, m, d] = (dateStr || '').split('-').map(Number)
|
||
let h = 0, min = 0
|
||
if (timeStr && timeStr.includes(':')) {
|
||
const [hh, mm] = timeStr.split(':').map(Number)
|
||
h = hh; min = mm
|
||
} else if (end) {
|
||
h = 23; min = 59
|
||
}
|
||
return new Date(y, (m || 1) - 1, d || 1, h, min, 0, 0)
|
||
}
|
||
|
||
function formatDate(d) {
|
||
const dd = String(d.getDate()).padStart(2, '0')
|
||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||
const yyyy = d.getFullYear()
|
||
return `${dd}/${mm}/${yyyy}`
|
||
}
|
||
function formatTime(d) {
|
||
const h = String(d.getHours()).padStart(2, '0')
|
||
const m = String(d.getMinutes()).padStart(2, '0')
|
||
return `${h}:${m}`
|
||
}
|
||
function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` }
|
||
|
||
function startEdit(b) {
|
||
editingId.value = b.id
|
||
const bt = getSingleBikeType(b)
|
||
editForm.bike_type = bt !== null ? bt : null
|
||
}
|
||
function cancelEdit() { editingId.value = null }
|
||
async function saveEdit(b) {
|
||
try {
|
||
const payload = { bike_type: editForm.bike_type, bike_types: null }
|
||
await api.updateBooking(b.id, payload)
|
||
editingId.value = null
|
||
await loadBookings()
|
||
} catch (e) {
|
||
alert('Échec de la mise à jour.')
|
||
}
|
||
}
|
||
|
||
// UI status helper for filtering
|
||
function uiStatus(b) {
|
||
if (b.status === 'refused') return 'refused'
|
||
const s = parseDateTime(b.start_date, b.start_time)
|
||
const e = parseDateTime(b.end_date, b.end_time, true)
|
||
const n = now.value
|
||
if (b.status === 'accepted') {
|
||
if (n < s) return 'accepted'
|
||
if (n >= s && n <= e) return 'ongoing'
|
||
if (n > e) return 'archived'
|
||
}
|
||
return 'pending'
|
||
}
|
||
|
||
// The filtered list used by the template
|
||
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)
|
||
})
|
||
|
||
// Calendar computed
|
||
const hours = Array.from({ length: 17 }, (_, i) => i + 6) // 06:00 -> 22:00
|
||
const weekStart = computed(() => {
|
||
const d = new Date(calendarAnchor.value)
|
||
const day = d.getDay() || 7 // 1..7, Monday=1
|
||
const start = new Date(d)
|
||
start.setDate(d.getDate() - (day - 1))
|
||
start.setHours(0,0,0,0)
|
||
return start
|
||
})
|
||
const weekDays = computed(() => Array.from({ length: 7 }, (_, i) => new Date(weekStart.value.getFullYear(), weekStart.value.getMonth(), weekStart.value.getDate() + i)))
|
||
const weekEnd = computed(() => {
|
||
const end = new Date(weekStart.value)
|
||
end.setDate(end.getDate() + 6)
|
||
return end
|
||
})
|
||
function weekdayShort(d) {
|
||
return ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'][((d.getDay()||7)-1)]
|
||
}
|
||
|
||
function prevWeek() { const d = new Date(calendarAnchor.value); d.setDate(d.getDate() - 7); calendarAnchor.value = d }
|
||
function nextWeek() { const d = new Date(calendarAnchor.value); d.setDate(d.getDate() + 7); calendarAnchor.value = d }
|
||
function goToday() { calendarAnchor.value = new Date() }
|
||
|
||
const gridTemplateDays = computed(() => ({ gridTemplateColumns: `80px repeat(7, minmax(0, 1fr))` }))
|
||
|
||
const dayEvents = computed(() => {
|
||
const map = Array.from({ length: 7 }, () => [])
|
||
if (!selectedBike.value) return map
|
||
|
||
const startOfWeek = new Date(weekStart.value)
|
||
const endOfWeek = new Date(weekStart.value)
|
||
endOfWeek.setDate(endOfWeek.getDate() + 7)
|
||
|
||
const relevant = bookings.value.filter(b => b.status !== 'refused' && includesBike(b, selectedBike.value))
|
||
|
||
for (const b of relevant) {
|
||
const s = parseDateTime(b.start_date, b.start_time)
|
||
const e = parseDateTime(b.end_date, b.end_time, true)
|
||
// Skip if no overlap with the week
|
||
if (e <= startOfWeek || s >= endOfWeek) continue
|
||
|
||
for (let i = 0; i < 7; i++) {
|
||
const day = new Date(weekStart.value)
|
||
day.setDate(day.getDate() + i)
|
||
const dayStart = new Date(day)
|
||
dayStart.setHours(0,0,0,0)
|
||
const dayEnd = new Date(day)
|
||
dayEnd.setHours(23,59,59,999)
|
||
|
||
const segStart = new Date(Math.max(s.getTime(), dayStart.getTime()))
|
||
const segEnd = new Date(Math.min(e.getTime(), dayEnd.getTime()))
|
||
if (segEnd <= segStart) continue
|
||
|
||
const startMin = segStart.getHours() * 60 + segStart.getMinutes()
|
||
const endMin = segEnd.getHours() * 60 + segEnd.getMinutes()
|
||
|
||
// Restrict to visible hour range
|
||
const visStart = 6 * 60
|
||
const visEnd = 22 * 60
|
||
const topMin = Math.max(startMin, visStart)
|
||
const botMin = Math.min(endMin, visEnd)
|
||
if (botMin <= visStart || topMin >= visEnd) continue
|
||
|
||
const ev = {
|
||
dayIdx: i,
|
||
topMin,
|
||
bottomMin: botMin,
|
||
status: b.status,
|
||
name: b.name,
|
||
timeLabel: `${String(segStart.getHours()).padStart(2,'0')}:${String(segStart.getMinutes()).padStart(2,'0')} → ${String(segEnd.getHours()).padStart(2,'0')}:${String(segEnd.getMinutes()).padStart(2,'0')}`
|
||
}
|
||
map[i].push(ev)
|
||
}
|
||
}
|
||
|
||
// Layout: simple column assignment to avoid overlap
|
||
for (let i = 0; i < 7; i++) {
|
||
const events = map[i].sort((a,b) => a.topMin - b.topMin)
|
||
const cols = [] // each col is last bottomMin
|
||
for (const ev of events) {
|
||
let placed = false
|
||
for (let c = 0; c < cols.length; c++) {
|
||
if (ev.topMin >= cols[c]) { ev.col = c; cols[c] = ev.bottomMin; placed = true; break }
|
||
}
|
||
if (!placed) { ev.col = cols.length; cols.push(ev.bottomMin) }
|
||
}
|
||
const count = Math.max(1, cols.length)
|
||
for (const ev of events) ev.colCount = count
|
||
}
|
||
|
||
return map
|
||
})
|
||
|
||
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 height = Math.max(18, (ev.bottomMin - ev.topMin) * pxPerMin)
|
||
const widthPct = 100 / ev.colCount
|
||
const leftPct = ev.col * widthPct
|
||
return {
|
||
top: top + 'px',
|
||
height: height + 'px',
|
||
left: leftPct + '%',
|
||
width: `calc(${widthPct}% - 4px)`,
|
||
marginLeft: '2px',
|
||
marginRight: '2px',
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- Status badge as a small sub-component to fix wrapping (whitespace-nowrap) -->
|
||
<script>
|
||
export default {
|
||
name: 'AdminPage',
|
||
components: {
|
||
StatusBadge: {
|
||
props: { booking: { type: Object, required: true }, now: { type: Object, required: true } },
|
||
methods: {
|
||
parse(dateStr, timeStr, end = false) {
|
||
const [y,m,d] = (dateStr||'').split('-').map(Number)
|
||
let h=0,min=0; if (timeStr && timeStr.includes(':')) { const [hh,mm]=timeStr.split(':').map(Number); h=hh; min=mm } else if (end) { h=23; min=59 }
|
||
return new Date(y, (m||1)-1, d||1, h, min, 0, 0)
|
||
},
|
||
},
|
||
computed: {
|
||
ui() {
|
||
const b = this.booking
|
||
if (b.status === 'refused') return { key: 'archived', label: 'Archivé', cls: 'bg-slate-200 text-slate-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
|
||
if (b.status === 'accepted') {
|
||
if (n < s) return { key: 'accepted', label: 'Accepté', cls: 'bg-emerald-100 text-emerald-700' }
|
||
if (n >= s && n <= e) return { key: 'ongoing', label: 'En cours', cls: 'bg-blue-100 text-blue-700' }
|
||
if (n > e) return { key: 'archived', label: 'Archivé', cls: 'bg-slate-200 text-slate-700' }
|
||
}
|
||
return { key: 'pending', label: 'En attente', cls: 'bg-amber-100 text-amber-800' }
|
||
}
|
||
},
|
||
template: `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap" :class="ui.cls">{{ ui.label }}</span>`
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/***** simple card/inputs if not present *****/
|
||
/* This page reuses utility classes from BookingPage (btn, input, card). */
|
||
</style>
|