feat: add conditions

This commit is contained in:
Antoine Pelletier 2025-10-23 15:49:20 +02:00
parent 795eb30b0e
commit deb21fbf42
5 changed files with 315 additions and 107 deletions

View file

@ -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 = []

View file

@ -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>

View file

@ -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)

View file

@ -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,37 +244,64 @@
</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>
<div class="grid gap-3">
<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" />
<span>{{ t }}</span>
</label>
<!-- 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">
<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">
<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>
<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>
</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>
@ -280,8 +309,64 @@
</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>

View file

@ -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 lassociation" />
<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 email</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 = 'Lheure 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 email.'; ok = false }