feat: add conditions
This commit is contained in:
parent
795eb30b0e
commit
deb21fbf42
5 changed files with 315 additions and 107 deletions
|
|
@ -10,6 +10,22 @@ const app = express()
|
|||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// Helper: combine ISO date (YYYY-MM-DD) and time (HH:mm) into local Date
|
||||
function parseLocalDateTime(dateStr, timeStr, end = false) {
|
||||
if (!dateStr) return null
|
||||
const [y, m, d] = String(dateStr).split('-').map(Number)
|
||||
let hh = 0, mm = 0
|
||||
if (typeof timeStr === 'string' && timeStr.includes(':')) {
|
||||
const [h, mi] = timeStr.split(':').map(Number)
|
||||
hh = Number.isFinite(h) ? h : 0
|
||||
mm = Number.isFinite(mi) ? mi : 0
|
||||
} else if (end) {
|
||||
hh = 23; mm = 59
|
||||
}
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null
|
||||
return new Date(y, m - 1, d, hh, mm, 0, 0)
|
||||
}
|
||||
|
||||
// Helper: normalize booking row fields to stable strings (avoid timezone surprises)
|
||||
function sanitizeBooking(row) {
|
||||
const normDate = (v) => {
|
||||
|
|
@ -87,6 +103,30 @@ app.post('/api/bookings', async (req, res) => {
|
|||
return res.status(400).json({ error: 'Missing fields' })
|
||||
}
|
||||
|
||||
// Validate temporal constraints: require start_time/end_time and ensure start >= now, end > start
|
||||
if (!start_time || !end_time) {
|
||||
return res.status(400).json({ error: 'Start and end times are required' })
|
||||
}
|
||||
const s = parseLocalDateTime(start_date, start_time)
|
||||
const e = parseLocalDateTime(end_date, end_time, true)
|
||||
if (!s || !e) {
|
||||
return res.status(400).json({ error: 'Invalid dates' })
|
||||
}
|
||||
if (e <= s) {
|
||||
return res.status(400).json({ error: 'End must be after start' })
|
||||
}
|
||||
const now = new Date()
|
||||
if (s < now) {
|
||||
return res.status(400).json({ error: 'Start cannot be in the past' })
|
||||
}
|
||||
// Enforce max 31 days in advance for start and end
|
||||
const max = new Date()
|
||||
max.setDate(max.getDate() + 31)
|
||||
max.setHours(23, 59, 59, 999)
|
||||
if (s > max || e > max) {
|
||||
return res.status(400).json({ error: 'Bookings cannot be made more than 31 days in advance' })
|
||||
}
|
||||
|
||||
// Explicitly set status to 'pending' to avoid relying solely on DB defaults
|
||||
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email, status)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`
|
||||
|
|
@ -244,6 +284,43 @@ app.patch('/api/bookings/:id', async (req, res) => {
|
|||
|
||||
if (entries.length === 0) return res.status(400).json({ error: 'No valid fields to update' })
|
||||
|
||||
// Get current booking to validate temporal changes
|
||||
const current = await pool.query('SELECT * FROM bookings WHERE id = $1', [id])
|
||||
if (current.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||
const existing = current.rows[0]
|
||||
|
||||
// Validate temporal constraints if dates/times are being updated
|
||||
const bodyObj = req.body || {}
|
||||
const startDate = bodyObj.start_date || existing.start_date
|
||||
const startTime = bodyObj.start_time || existing.start_time
|
||||
const endDate = bodyObj.end_date || existing.end_date
|
||||
const endTime = bodyObj.end_time || existing.end_time
|
||||
|
||||
if (startDate && endDate && startTime && endTime) {
|
||||
const s = parseLocalDateTime(startDate, startTime)
|
||||
const e = parseLocalDateTime(endDate, endTime, true)
|
||||
if (!s || !e) {
|
||||
return res.status(400).json({ error: 'Invalid dates' })
|
||||
}
|
||||
if (e <= s) {
|
||||
return res.status(400).json({ error: 'End must be after start' })
|
||||
}
|
||||
// Allow editing up to 1 day in the past
|
||||
const oneDayAgo = new Date()
|
||||
oneDayAgo.setDate(oneDayAgo.getDate() - 1)
|
||||
oneDayAgo.setHours(0, 0, 0, 0)
|
||||
if (s < oneDayAgo) {
|
||||
return res.status(400).json({ error: 'Start cannot be more than 1 day in the past' })
|
||||
}
|
||||
// Enforce max 31 days in advance for start and end
|
||||
const max = new Date()
|
||||
max.setDate(max.getDate() + 31)
|
||||
max.setHours(23, 59, 59, 999)
|
||||
if (s > max || e > max) {
|
||||
return res.status(400).json({ error: 'Bookings cannot be made more than 31 days in advance' })
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
const sets = []
|
||||
const values = []
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>Cargobikes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
16
src/main.js
16
src/main.js
|
|
@ -4,6 +4,22 @@ import { createApp } from 'vue'
|
|||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Set Agepoly logo as favicon at runtime
|
||||
import agepolyLogoUrl from '@/assets/cargobike-icon.png'
|
||||
function setFavicon(href) {
|
||||
try {
|
||||
const doc = document
|
||||
let link = doc.querySelector('link[rel="icon"]') || doc.createElement('link')
|
||||
link.rel = 'icon'
|
||||
link.type = 'image/png'
|
||||
link.href = href
|
||||
if (!link.parentNode) doc.head.appendChild(link)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
// After mount, ensure favicon is set
|
||||
setFavicon(agepolyLogoUrl)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
<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 v-if="error" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<span class="text-sm font-bold text-red-700">{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,8 +70,8 @@
|
|||
<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="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 !== 'refused'">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>
|
||||
|
|
@ -107,12 +109,12 @@
|
|||
|
||||
<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 -->
|
||||
<!-- For refused bookings show only restore; for others allow editing -->
|
||||
<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-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
|
||||
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -155,9 +157,9 @@
|
|||
<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-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="refuse(b)" v-if="b.status!=='refused'">Refuser</button>
|
||||
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -242,46 +244,129 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (pending multi-select) -->
|
||||
<!-- Edit modal (bikes + dates/times) -->
|
||||
<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 class="relative w-full max-w-2xl rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-lg max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header - sticky -->
|
||||
<div class="sticky top-0 bg-[hsl(var(--card))] border-b border-border px-6 py-4 flex items-center justify-between z-10">
|
||||
<h3 class="text-lg font-semibold">Modifier la réservation #{{ editingId }}</h3>
|
||||
<button class="btn btn-outline h-9 px-3" @click="cancelEdit">Fermer</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-6 py-5">
|
||||
<div class="grid gap-6">
|
||||
<!-- Bikes selection - side by side -->
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-3">
|
||||
<div class="text-sm font-semibold">Grands cargos</div>
|
||||
<div class="grid gap-2">
|
||||
<label v-for="t in bigBikes" :key="t" class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" :value="t" v-model="editForm.bike_types" class="h-4 w-4 cursor-pointer" />
|
||||
<span>{{ t }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="text-sm font-semibold">Petits cargos</div>
|
||||
<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" />
|
||||
<label v-for="t in smallBikes" :key="t" class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" :value="t" v-model="editForm.bike_types" class="h-4 w-4 cursor-pointer" />
|
||||
<span>{{ t }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date/Time editing -->
|
||||
<div class="grid gap-4 pt-4 border-t border-border">
|
||||
<div class="text-sm font-semibold">Période de réservation</div>
|
||||
|
||||
<!-- Start -->
|
||||
<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>
|
||||
<label class="text-sm font-medium text-slate-700">Début</label>
|
||||
<DateTimePicker v-model="editStartPicked" :use24h="true" :minute-step="15" :minDate="oneDayAgo" :maxDate="editEndPicked" locale="fr-FR" />
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">Fin</label>
|
||||
<DateTimePicker v-model="editEndPicked" :use24h="true" :minute-step="15" :minDate="editStartPicked || oneDayAgo" locale="fr-FR" />
|
||||
</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>
|
||||
|
||||
<!-- Footer - sticky -->
|
||||
<div class="sticky bottom-0 bg-[hsl(var(--card))] border-t border-border px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button class="btn btn-secondary h-10 px-4" @click="cancelEdit">Annuler</button>
|
||||
<button class="btn h-10 px-4" @click="saveEditFromModal">Enregistrer les modifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue'
|
||||
|
||||
// Helper function to parse dates for status badge
|
||||
function parseStatusDateTime(dateStr, timeStr, end = false) {
|
||||
let y, m, d
|
||||
if (dateStr instanceof Date) {
|
||||
y = dateStr.getFullYear(); m = dateStr.getMonth() + 1; d = dateStr.getDate()
|
||||
} else if (typeof dateStr === 'string') {
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr)
|
||||
if (iso) { y = Number(iso[1]); m = Number(iso[2]); d = Number(iso[3]) }
|
||||
else {
|
||||
const fr = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(dateStr)
|
||||
if (fr) { d = Number(fr[1]); m = Number(fr[2]); y = Number(fr[3]) }
|
||||
else {
|
||||
const dt = new Date(dateStr)
|
||||
if (!isNaN(dt.getTime())) { y = dt.getFullYear(); m = dt.getMonth() + 1; d = dt.getDate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
y = Number.isFinite(y) ? y : new Date().getFullYear()
|
||||
m = Number.isFinite(m) ? m : 1
|
||||
d = Number.isFinite(d) ? d : 1
|
||||
|
||||
let hh = 0, min = 0
|
||||
if (timeStr && typeof timeStr === 'string' && timeStr.includes(':')) {
|
||||
const [h, mm] = timeStr.split(':').map(Number)
|
||||
hh = Number.isFinite(h) ? h : 0
|
||||
min = Number.isFinite(mm) ? mm : 0
|
||||
} else if (end) {
|
||||
hh = 23; min = 59
|
||||
}
|
||||
return new Date(y, m - 1, d, hh, min, 0, 0)
|
||||
}
|
||||
|
||||
// Compute status UI for a booking
|
||||
function getStatusUI(booking, nowDate) {
|
||||
const b = booking
|
||||
if (b.status === 'refused') return { key: 'refused', label: 'Refusé', cls: 'bg-red-100 text-red-700' }
|
||||
const s = parseStatusDateTime(b.start_date, b.start_time)
|
||||
const e = parseStatusDateTime(b.end_date, b.end_time, true)
|
||||
const n = nowDate
|
||||
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' }
|
||||
}
|
||||
|
||||
// Inline StatusBadge component using h()
|
||||
const StatusBadge = (props) => {
|
||||
const ui = getStatusUI(props.booking, props.now)
|
||||
return h('span', {
|
||||
class: `inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap ${ui.cls}`
|
||||
}, ui.label)
|
||||
}
|
||||
|
||||
const bookings = ref([])
|
||||
const bikes = ref([])
|
||||
|
|
@ -295,7 +380,18 @@ const filter = ref('all')
|
|||
|
||||
// Editing state
|
||||
const editingId = ref(null)
|
||||
const editingOpen = ref(false)
|
||||
const editForm = reactive({ bike_type: null, bike_types: [] })
|
||||
const editStartPicked = ref(null)
|
||||
const editEndPicked = ref(null)
|
||||
|
||||
// Allow editing up to 1 day in the past
|
||||
const oneDayAgo = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
})
|
||||
|
||||
// Calendar state
|
||||
const selectedBike = ref('all')
|
||||
|
|
@ -473,22 +569,63 @@ function formatTime(d) {
|
|||
function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` }
|
||||
|
||||
function startEdit(b) {
|
||||
if (b.status !== 'pending') return
|
||||
editingId.value = b.id
|
||||
// Preload multi bike types from booking
|
||||
const multi = getMultiBikeTypes(b)
|
||||
editForm.bike_type = null
|
||||
editForm.bike_types = [...multi]
|
||||
|
||||
// Preload start/end date+time
|
||||
editStartPicked.value = parseDateTime(b.start_date, b.start_time)
|
||||
editEndPicked.value = parseDateTime(b.end_date, b.end_time, true)
|
||||
|
||||
editingOpen.value = true
|
||||
}
|
||||
function cancelEdit() { editingOpen.value = false; editingId.value = null }
|
||||
|
||||
function cancelEdit() {
|
||||
editingOpen.value = false
|
||||
editingId.value = null
|
||||
editStartPicked.value = null
|
||||
editEndPicked.value = null
|
||||
}
|
||||
|
||||
function toISODate(d) {
|
||||
if (!(d instanceof Date)) return null
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function toHHmm(d) {
|
||||
if (!(d instanceof Date)) return null
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
async function saveEdit(b) {
|
||||
try {
|
||||
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(',') }
|
||||
|
||||
const payload = {
|
||||
bike_type: null,
|
||||
bike_types: editForm.bike_types.join(',')
|
||||
}
|
||||
|
||||
// Include date/time if changed
|
||||
if (editStartPicked.value) {
|
||||
payload.start_date = toISODate(editStartPicked.value)
|
||||
payload.start_time = toHHmm(editStartPicked.value)
|
||||
}
|
||||
if (editEndPicked.value) {
|
||||
payload.end_date = toISODate(editEndPicked.value)
|
||||
payload.end_time = toHHmm(editEndPicked.value)
|
||||
}
|
||||
|
||||
await api.updateBooking(b.id, payload)
|
||||
editingId.value = null
|
||||
await loadBookings()
|
||||
|
|
@ -496,10 +633,13 @@ 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
|
||||
editStartPicked.value = null
|
||||
editEndPicked.value = null
|
||||
}
|
||||
|
||||
// Actions: accept / refuse / restore / delete
|
||||
|
|
@ -769,8 +909,6 @@ function eventStyle(ev) {
|
|||
}
|
||||
}
|
||||
|
||||
// Modal state for editing
|
||||
const editingOpen = ref(false)
|
||||
|
||||
// Bike grouping for modal
|
||||
const bigBikes = computed(() => (bikes.value || []).filter(n => Number(n) < 3000))
|
||||
|
|
@ -779,61 +917,7 @@ const smallBikes = computed(() => (bikes.value || []).filter(n => Number(n) >= 3
|
|||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AdminPage',
|
||||
components: {
|
||||
StatusBadge: {
|
||||
props: { booking: { type: Object, required: true }, now: { type: Object, required: true } },
|
||||
methods: {
|
||||
parse(dateStr, timeStr, end = false) {
|
||||
let y, m, d
|
||||
if (dateStr instanceof Date) {
|
||||
y = dateStr.getFullYear(); m = dateStr.getMonth() + 1; d = dateStr.getDate()
|
||||
} else if (typeof dateStr === 'string') {
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr)
|
||||
if (iso) { y = Number(iso[1]); m = Number(iso[2]); d = Number(iso[3]) }
|
||||
else {
|
||||
const fr = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(dateStr)
|
||||
if (fr) { d = Number(fr[1]); m = Number(fr[2]); y = Number(fr[3]) }
|
||||
else {
|
||||
const dt = new Date(dateStr)
|
||||
if (!isNaN(dt.getTime())) { y = dt.getFullYear(); m = dt.getMonth() + 1; d = dt.getDate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
y = Number.isFinite(y) ? y : new Date().getFullYear()
|
||||
m = Number.isFinite(m) ? m : 1
|
||||
d = Number.isFinite(d) ? d : 1
|
||||
|
||||
let h = 0, min = 0
|
||||
if (timeStr && typeof timeStr === 'string' && timeStr.includes(':')) {
|
||||
const [hh, mm] = timeStr.split(':').map(Number)
|
||||
h = Number.isFinite(hh) ? hh : 0
|
||||
min = Number.isFinite(mm) ? mm : 0
|
||||
} else if (end) {
|
||||
h = 23; min = 59
|
||||
}
|
||||
return new Date(y, m - 1, d, h, min, 0, 0)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
ui() {
|
||||
const b = this.booking
|
||||
// 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 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>`
|
||||
}
|
||||
}
|
||||
name: 'AdminPage'
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
<!-- Association (mapped to backend: name) -->
|
||||
<div class="grid gap-2 w-full">
|
||||
<label for="association" class="label">Nom de l'association</label>
|
||||
<input id="association" v-model.trim="form.association" type="text" class="input w-full" placeholder="Nom de l’association" />
|
||||
<p v-if="errors.association" class="helper text-red-600">{{ errors.association }}</p>
|
||||
<input id="association" v-model.trim="form.association" type="text" class="input w-full" placeholder="Nom de l'association" />
|
||||
<div v-if="errors.association" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<p class="text-sm font-bold text-red-700">{{ errors.association }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bike size/type as two columns of buttons (multi-select) -->
|
||||
|
|
@ -45,34 +47,42 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-600" v-if="form.bikeTypes.length">Sélection : <span class="font-medium">{{ form.bikeTypes.join(', ') }}</span></div>
|
||||
<p v-if="errors.bikeTypes" class="helper text-red-600">{{ errors.bikeTypes }}</p>
|
||||
<div class="text-xs text-slate-600" v-if="form.bikeTypes.length">Sélection : <span class="font-medium">{{ form.bikeTypes.join(', ') }}</span></div>
|
||||
<div v-if="errors.bikeTypes" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<p class="text-sm font-bold text-red-700">{{ errors.bikeTypes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period: start -->
|
||||
<div class="grid gap-2 w-full">
|
||||
<span class="label">Début de la réservation</span>
|
||||
<!-- Ensure DateTimePicker fills the width -->
|
||||
<!-- Ensure DateTimePicker fills the width and does not allow past dates -->
|
||||
<div class="w-full">
|
||||
<DateTimePicker class="w-full" v-model="startPicked" :use24h="true" :minute-step="5" :maxDate="endPicked" locale="fr-FR" />
|
||||
<!-- Cap to endPicked (if earlier) and to maxBookingDate (31 days) -->
|
||||
<DateTimePicker class="w-full" v-model="startPicked" :use24h="true" :minute-step="5" :maxDate="(endPicked && endPicked < maxBookingDate) ? endPicked : maxBookingDate" :minDate="today" locale="fr-FR" />
|
||||
</div>
|
||||
<div v-if="errors.start" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<p class="text-sm font-bold text-red-700">{{ errors.start }}</p>
|
||||
</div>
|
||||
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Period: end -->
|
||||
<div class="grid gap-2 w-full">
|
||||
<span class="label">Fin de la réservation</span>
|
||||
<div class="w-full">
|
||||
<DateTimePicker class="w-full" v-model="endPicked" :use24h="true" :minute-step="5" :minDate="startPicked" locale="fr-FR" />
|
||||
<!-- Also cap end to 31 days max and not before start -->
|
||||
<DateTimePicker class="w-full" v-model="endPicked" :use24h="true" :minute-step="5" :minDate="startPicked || today" :maxDate="maxBookingDate" locale="fr-FR" />
|
||||
</div>
|
||||
<div v-if="errors.end" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<p class="text-sm font-bold text-red-700">{{ errors.end }}</p>
|
||||
</div>
|
||||
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Linka Go emails (multiple) -->
|
||||
<div class="grid gap-2 w-full">
|
||||
<span class="label">Adresses mail du/des comptes Linka Go à autoriser</span>
|
||||
<div class="grid gap-2">
|
||||
<div v-for="(_, idx) in form.emails" :key="idx" class="flex gap-2 w-full">
|
||||
<div v-for="(email, 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" />
|
||||
<button type="button" class="btn btn-outline h-10 px-3" @click="removeEmail(idx)" v-if="form.emails.length > 1">Retirer</button>
|
||||
</div>
|
||||
|
|
@ -80,7 +90,9 @@
|
|||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-secondary h-9 px-3" @click="addEmail">Ajouter un e‑mail</button>
|
||||
</div>
|
||||
<p v-if="errors.emails" class="helper text-red-600">{{ errors.emails }}</p>
|
||||
<div v-if="errors.emails" class="px-3 py-2 rounded-md bg-red-50 border border-red-200">
|
||||
<p class="text-sm font-bold text-red-700">{{ errors.emails }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -118,7 +130,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { reactive, ref, watch, computed } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue'
|
||||
|
||||
|
|
@ -155,6 +167,18 @@ const form = reactive({
|
|||
const startPicked = ref(null) // Date|null
|
||||
const endPicked = ref(null) // Date|null
|
||||
|
||||
// New: today at start-of-day for calendar minDate
|
||||
function startOfDay(d) { const x = new Date(d); x.setHours(0,0,0,0); return x }
|
||||
const today = startOfDay(new Date())
|
||||
|
||||
// Max booking date: 31 days from today (end of day)
|
||||
const maxBookingDate = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 31)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
})
|
||||
|
||||
function formatHHmm(d) {
|
||||
if (!(d instanceof Date)) return ''
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
|
|
@ -268,6 +292,13 @@ function validate() {
|
|||
const e = new Date(form.endDate)
|
||||
e.setHours(Number(eh), Number(em), 0, 0)
|
||||
if (e <= s) { errors.end = 'La fin doit être après le début.'; ok = false }
|
||||
// Block start in the past (compare to now)
|
||||
const now = new Date()
|
||||
if (s < now) { errors.start = 'L’heure de début ne peut pas être dans le passé.'; ok = false }
|
||||
// Block bookings beyond 31 days from now
|
||||
const max = new Date(maxBookingDate.value)
|
||||
if (s > max) { errors.start = 'La date de début ne peut pas être à plus de 31 jours.'; ok = false }
|
||||
if (e > max) { errors.end = 'La date de fin ne peut pas être à plus de 31 jours.'; ok = false }
|
||||
}
|
||||
const validEmails = form.emails.map(e => e.trim()).filter(Boolean)
|
||||
if (validEmails.length === 0) { errors.emails = 'Renseignez au moins une adresse e‑mail.'; ok = false }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue