agep-cargo/src/pages/AdminPage.vue
2025-10-13 05:27:04 +02:00

472 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">Aujourdhui</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>