feat: admin and book page
This commit is contained in:
parent
1ed2a64854
commit
75c4785e3d
30 changed files with 1966 additions and 408 deletions
|
|
@ -31,9 +31,10 @@ app.post('/api/bookings', async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Missing fields' })
|
return res.status(400).json({ error: 'Missing fields' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email)
|
// Explicitly set status to 'pending' to avoid relying solely on DB defaults
|
||||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`
|
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email, status)
|
||||||
const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email]
|
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`
|
||||||
|
const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email, 'pending']
|
||||||
const result = await pool.query(text, values)
|
const result = await pool.query(text, values)
|
||||||
res.status(201).json({ booking: result.rows[0] })
|
res.status(201).json({ booking: result.rows[0] })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -53,6 +54,66 @@ app.get('/api/bookings', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Update booking status
|
||||||
|
app.patch('/api/bookings/:id/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' })
|
||||||
|
|
||||||
|
const { status } = req.body || {}
|
||||||
|
const allowed = ['pending', 'accepted', 'refused']
|
||||||
|
if (!allowed.includes(status)) return res.status(400).json({ error: 'Invalid status' })
|
||||||
|
|
||||||
|
// Read current status to enforce immutability once accepted
|
||||||
|
const current = await pool.query('SELECT status FROM bookings WHERE id = $1', [id])
|
||||||
|
if (current.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||||
|
const curr = current.rows[0].status
|
||||||
|
if (curr === 'accepted' && status !== 'accepted') {
|
||||||
|
return res.status(400).json({ error: 'Accepted bookings cannot change status' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query('UPDATE bookings SET status = $1 WHERE id = $2 RETURNING *', [status, id])
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||||
|
|
||||||
|
res.json({ booking: result.rows[0] })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: 'Server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update booking fields (non-status)
|
||||||
|
app.patch('/api/bookings/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' })
|
||||||
|
|
||||||
|
const allowed = ['bike_type', 'bike_types', 'start_date', 'start_time', 'end_date', 'end_time', 'name', 'email']
|
||||||
|
const entries = Object.entries(req.body || {}).filter(([k, v]) => allowed.includes(k))
|
||||||
|
|
||||||
|
if (entries.length === 0) return res.status(400).json({ error: 'No valid fields to update' })
|
||||||
|
|
||||||
|
// Build dynamic update
|
||||||
|
const sets = []
|
||||||
|
const values = []
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const [k, v] = entries[i]
|
||||||
|
sets.push(`${k} = $${i + 1}`)
|
||||||
|
values.push(v)
|
||||||
|
}
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const sql = `UPDATE bookings SET ${sets.join(', ')} WHERE id = $${values.length} RETURNING *`
|
||||||
|
const result = await pool.query(sql, values)
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||||
|
|
||||||
|
res.json({ booking: result.rows[0] })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: 'Server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Delete a booking by id
|
// Delete a booking by id
|
||||||
app.delete('/api/bookings/:id', async (req, res) => {
|
app.delete('/api/bookings/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -82,6 +143,7 @@ async function ensureTables() {
|
||||||
end_time TIME,
|
end_time TIME,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
@ -92,6 +154,12 @@ async function ensureTables() {
|
||||||
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS start_time TIME;`)
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS start_time TIME;`)
|
||||||
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS end_time TIME;`)
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS end_time TIME;`)
|
||||||
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS bike_types TEXT;`)
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS bike_types TEXT;`)
|
||||||
|
await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS status TEXT;`)
|
||||||
|
// Ensure default and not-null for status
|
||||||
|
await pool.query(`ALTER TABLE bookings ALTER COLUMN status SET DEFAULT 'pending';`)
|
||||||
|
await pool.query(`UPDATE bookings SET status = 'pending' WHERE status IS NULL;`)
|
||||||
|
await pool.query(`ALTER TABLE bookings ALTER COLUMN status SET NOT NULL;`)
|
||||||
|
// bike_type nullable for multi-type support
|
||||||
await pool.query(`ALTER TABLE bookings ALTER COLUMN bike_type DROP NOT NULL;`)
|
await pool.query(`ALTER TABLE bookings ALTER COLUMN bike_type DROP NOT NULL;`)
|
||||||
console.log('Database: bookings table is present (created/migrated if needed)')
|
console.log('Database: bookings table is present (created/migrated if needed)')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"typescript": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/assets/main.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
449
package-lock.json
generated
449
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -12,15 +12,23 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.545.0",
|
||||||
|
"reka-ui": "^2.5.1",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.2.1"
|
"vue-router": "^4.2.1",
|
||||||
|
"vue3-datepicker": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"vite": "^7.1.7",
|
|
||||||
"vite-plugin-vue-devtools": "^8.0.2",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14"
|
"tailwindcss": "^3.4.14",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--selected: 210 100% 96%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
|
@ -53,6 +55,8 @@
|
||||||
--secondary: 215 27.9% 16.9%;
|
--secondary: 215 27.9% 16.9%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--selected: 210 100% 96%;
|
||||||
|
|
||||||
--muted: 215 27.9% 16.9%;
|
--muted: 215 27.9% 16.9%;
|
||||||
--muted-foreground: 217.9 10.6% 64.9%;
|
--muted-foreground: 217.9 10.6% 64.9%;
|
||||||
|
|
||||||
|
|
@ -68,7 +72,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* App container helpers */
|
/* App container helpers */
|
||||||
.container { @apply mx-auto max-w-3xl px-4; }
|
.container { @apply mx-auto max-w-6xl px-4; }
|
||||||
.card { @apply rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-sm; }
|
.card { @apply rounded-lg border border-border bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-sm; }
|
||||||
.card-header { @apply p-6 pb-3; }
|
.card-header { @apply p-6 pb-3; }
|
||||||
.card-title { @apply text-xl font-semibold leading-none tracking-tight; }
|
.card-title { @apply text-xl font-semibold leading-none tracking-tight; }
|
||||||
|
|
@ -112,7 +116,80 @@ a,
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
/* keep a comfortable inner padding on wide screens */
|
/* align header and main containers by removing extra app padding */
|
||||||
padding: 1.5rem 2rem;
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-outline.selected {
|
||||||
|
@apply bg-[hsl(var(--selected))] dark:bg-slate-700 border-slate-300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
211
src/components/DateTimePicker.vue
Normal file
211
src/components/DateTimePicker.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
<!-- ClockDateTimePicker.vue -->
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from "vue"
|
||||||
|
import { CalendarIcon, Clock } from "lucide-vue-next"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"])
|
||||||
|
|
||||||
|
/* ---------- state ---------- */
|
||||||
|
const date = ref(props.modelValue ? new Date(props.modelValue) : undefined) // Date | undefined
|
||||||
|
const hour24 = ref(props.modelValue ? new Date(props.modelValue).getHours() : null) // 0..23 | null
|
||||||
|
const minute = ref(props.modelValue ? new Date(props.modelValue).getMinutes() : 0) // 0..59
|
||||||
|
const showMinutes = ref(false)
|
||||||
|
|
||||||
|
// store calendar library's internal date value separately to avoid type mismatch with JS Date
|
||||||
|
const calendarDay = ref(null) // DateValue (from reka-ui) | null
|
||||||
|
|
||||||
|
const dateOpen = ref(false)
|
||||||
|
const timeOpen = ref(false)
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
const two = (n) => (n < 10 ? "0" + n : String(n))
|
||||||
|
|
||||||
|
const normalizeMinute = (m) => {
|
||||||
|
// enforce quarter-hours: 0,15,30,45 using floor
|
||||||
|
const val = Math.floor(Math.max(0, Math.min(59, Number(m) || 0)) / 15) * 15
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
const toJsDate = (v) => {
|
||||||
|
if (!v) return undefined
|
||||||
|
if (v instanceof Date) return new Date(v)
|
||||||
|
// handle @internationalized/date style objects
|
||||||
|
if (typeof v === 'object' && v.year != null && v.month != null && v.day != null) {
|
||||||
|
return new Date(v.year, v.month - 1, v.day)
|
||||||
|
}
|
||||||
|
// try parsing strings or other inputs
|
||||||
|
const d = new Date(v)
|
||||||
|
if (!isNaN(d.getTime())) return d
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sync from v-model ---------- */
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => {
|
||||||
|
if (!v) {
|
||||||
|
date.value = undefined
|
||||||
|
hour24.value = null
|
||||||
|
minute.value = 0
|
||||||
|
// do not try to coerce calendarDay when clearing
|
||||||
|
calendarDay.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = new Date(v)
|
||||||
|
date.value = new Date(d) // clone for reactivity
|
||||||
|
hour24.value = d.getHours()
|
||||||
|
minute.value = normalizeMinute(d.getMinutes())
|
||||||
|
// leave calendarDay as-is; Calendar can render without controlled value
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const df = computed(() => new Intl.DateTimeFormat(props.locale, { dateStyle: "long" }))
|
||||||
|
const formattedDate = computed(() => (date.value ? df.value.format(date.value) : "Pick a date"))
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
if (hour24.value == null) return "Pick a time"
|
||||||
|
return `${two(hour24.value)}:${two(minute.value)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
/* Emit when both date & time are chosen */
|
||||||
|
const maybeEmit = () => {
|
||||||
|
if (!date.value || hour24.value == null) return
|
||||||
|
const out = new Date(date.value)
|
||||||
|
out.setHours(hour24.value, minute.value, 0, 0)
|
||||||
|
emit("update:modelValue", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- handlers ---------- */
|
||||||
|
const onDateChange = (d) => {
|
||||||
|
// d is likely a DateValue from the calendar lib; convert to JS Date
|
||||||
|
const js = toJsDate(d)
|
||||||
|
date.value = js
|
||||||
|
// If no time yet, default to 00:00 so the date "registers"
|
||||||
|
if (hour24.value == null) {
|
||||||
|
hour24.value = 0
|
||||||
|
minute.value = 0
|
||||||
|
}
|
||||||
|
maybeEmit()
|
||||||
|
dateOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectHour24 = (h) => {
|
||||||
|
hour24.value = h
|
||||||
|
showMinutes.value = true
|
||||||
|
timeOpen.value = true
|
||||||
|
maybeEmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectMinute = (m) => {
|
||||||
|
minute.value = normalizeMinute(m)
|
||||||
|
showMinutes.value = false
|
||||||
|
maybeEmit()
|
||||||
|
timeOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- rings & angles (0/00 at top) ---------- */
|
||||||
|
const ringHours = computed(() => Array.from({ length: 24 }, (_, i) => i))
|
||||||
|
// enforce quarter-hour steps only
|
||||||
|
const ringMinutes = computed(() => [0, 15, 30, 45])
|
||||||
|
|
||||||
|
const degHour = (h) => h * 30 + 270 // 360/12 = 30°
|
||||||
|
const degMinute = (m) => m * 6 + 270 // 360/60 = 6°
|
||||||
|
const outerRing = 90;
|
||||||
|
const innerRing = 65;
|
||||||
|
function transHour(h) {
|
||||||
|
if (h==0) return innerRing;
|
||||||
|
return h <= 12 ? outerRing : innerRing;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row items-center gap-3">
|
||||||
|
<!-- Date -->
|
||||||
|
<Popover v-model:open="dateOpen">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:class="cn('w-[280px] justify-start text-left font-normal', !date && 'text-muted-foreground')"
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||||
|
{{ formattedDate }}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="z-50 w-auto p-0">
|
||||||
|
<Calendar
|
||||||
|
v-model="calendarDay"
|
||||||
|
mode="single"
|
||||||
|
:key="date?.toDateString?.() ?? 'empty'"
|
||||||
|
@update:modelValue="onDateChange"
|
||||||
|
initial-focus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<Popover v-model:open="timeOpen">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:class="cn('w-[280px] justify-start text-left font-normal', hour24 === null && 'text-muted-foreground')"
|
||||||
|
>
|
||||||
|
<Clock class="mr-2 h-4 w-4" />
|
||||||
|
{{ formattedTime }}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent class="z-50 w-[300px] flex flex-col items-center p-4">
|
||||||
|
<!-- Hour ring (0..23) with 00 at top -->
|
||||||
|
<div v-if="!showMinutes" class="relative w-[220px] h-[220px] flex items-center justify-center":style="{border: 'solid',borderRadius: '50%'}">
|
||||||
|
<div class="absolute text-lg font-semibold">
|
||||||
|
{{ hour24 ?? 'HH' }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="h in ringHours"
|
||||||
|
:key="h"
|
||||||
|
@click="selectHour24(h)"
|
||||||
|
class="absolute flex items-center justify-center w-10 h-10 rounded-full cursor-pointer hover:bg-accent/50"
|
||||||
|
:class="h===hour24 && 'bg-accent text-accent-foreground'"
|
||||||
|
:style="{
|
||||||
|
transform: `rotate(${degHour(h)}deg) translate(${transHour(h)}px) rotate(-${degHour(h)}deg)`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ h }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Minute ring (00/15/30/45, 00 at top) -->
|
||||||
|
<div v-else class="relative w-[220px] h-[220px] flex items-center justify-center">
|
||||||
|
<div class="absolute text-lg font-semibold">{{ two(minute) }}</div>
|
||||||
|
<div
|
||||||
|
v-for="m in ringMinutes"
|
||||||
|
:key="m"
|
||||||
|
@click="selectMinute(m)"
|
||||||
|
class="absolute flex items-center justify-center w-10 h-10 rounded-full cursor-pointer hover:bg-accent/50"
|
||||||
|
:class="m===minute && 'bg-accent text-accent-foreground'"
|
||||||
|
:style="{
|
||||||
|
transform: `rotate(${degMinute(m)}deg) translate(${innerRing}px) rotate(-${degMinute(m)}deg)`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ two(m) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
23
src/components/ui/button/Button.vue
Normal file
23
src/components/ui/button/Button.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup>
|
||||||
|
import { Primitive } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
variant: { type: null, required: false },
|
||||||
|
size: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false, default: "button" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
36
src/components/ui/button/index.js
Normal file
36
src/components/ui/button/index.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export { default as Button } from "./Button.vue";
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
xs: "h-7 rounded px-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
95
src/components/ui/calendar/Calendar.vue
Normal file
95
src/components/ui/calendar/Calendar.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarRoot, useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
CalendarCell,
|
||||||
|
CalendarCellTrigger,
|
||||||
|
CalendarGrid,
|
||||||
|
CalendarGridBody,
|
||||||
|
CalendarGridHead,
|
||||||
|
CalendarGridRow,
|
||||||
|
CalendarHeadCell,
|
||||||
|
CalendarHeader,
|
||||||
|
CalendarHeading,
|
||||||
|
CalendarNextButton,
|
||||||
|
CalendarPrevButton,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
defaultValue: { type: null, required: false },
|
||||||
|
defaultPlaceholder: { type: null, required: false },
|
||||||
|
placeholder: { type: null, required: false },
|
||||||
|
pagedNavigation: { type: Boolean, required: false },
|
||||||
|
preventDeselect: { type: Boolean, required: false },
|
||||||
|
weekStartsOn: { type: Number, required: false },
|
||||||
|
weekdayFormat: { type: String, required: false },
|
||||||
|
calendarLabel: { type: String, required: false },
|
||||||
|
fixedWeeks: { type: Boolean, required: false },
|
||||||
|
maxValue: { type: null, required: false },
|
||||||
|
minValue: { type: null, required: false },
|
||||||
|
locale: { type: String, required: false },
|
||||||
|
numberOfMonths: { type: Number, required: false },
|
||||||
|
disabled: { type: Boolean, required: false },
|
||||||
|
readonly: { type: Boolean, required: false },
|
||||||
|
initialFocus: { type: Boolean, required: false },
|
||||||
|
isDateDisabled: { type: Function, required: false },
|
||||||
|
isDateUnavailable: { type: Function, required: false },
|
||||||
|
dir: { type: String, required: false },
|
||||||
|
nextPage: { type: Function, required: false },
|
||||||
|
prevPage: { type: Function, required: false },
|
||||||
|
modelValue: { type: null, required: false },
|
||||||
|
multiple: { type: Boolean, required: false },
|
||||||
|
disableDaysOutsideCurrentView: { type: Boolean, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["update:modelValue", "update:placeholder"]);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarRoot
|
||||||
|
v-slot="{ grid, weekDays }"
|
||||||
|
:class="cn('p-3', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<CalendarHeader>
|
||||||
|
<CalendarPrevButton />
|
||||||
|
<CalendarHeading />
|
||||||
|
<CalendarNextButton />
|
||||||
|
</CalendarHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||||
|
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||||
|
<CalendarGridHead>
|
||||||
|
<CalendarGridRow>
|
||||||
|
<CalendarHeadCell v-for="day in weekDays" :key="day">
|
||||||
|
{{ day }}
|
||||||
|
</CalendarHeadCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridHead>
|
||||||
|
<CalendarGridBody>
|
||||||
|
<CalendarGridRow
|
||||||
|
v-for="(weekDates, index) in month.rows"
|
||||||
|
:key="`weekDate-${index}`"
|
||||||
|
class="mt-2 w-full"
|
||||||
|
>
|
||||||
|
<CalendarCell
|
||||||
|
v-for="weekDate in weekDates"
|
||||||
|
:key="weekDate.toString()"
|
||||||
|
:date="weekDate"
|
||||||
|
>
|
||||||
|
<CalendarCellTrigger :day="weekDate" :month="month.value" />
|
||||||
|
</CalendarCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridBody>
|
||||||
|
</CalendarGrid>
|
||||||
|
</div>
|
||||||
|
</CalendarRoot>
|
||||||
|
</template>
|
||||||
30
src/components/ui/calendar/CalendarCell.vue
Normal file
30
src/components/ui/calendar/CalendarCell.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarCell, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
date: { type: null, required: true },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarCell>
|
||||||
|
</template>
|
||||||
42
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
42
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarCellTrigger, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
day: { type: null, required: true },
|
||||||
|
month: { type: null, required: true },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarCellTrigger
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-8 w-8 p-0 font-normal',
|
||||||
|
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
// Selected
|
||||||
|
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||||
|
// Disabled
|
||||||
|
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||||
|
// Unavailable
|
||||||
|
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||||
|
// Outside months
|
||||||
|
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarCellTrigger>
|
||||||
|
</template>
|
||||||
24
src/components/ui/calendar/CalendarGrid.vue
Normal file
24
src/components/ui/calendar/CalendarGrid.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarGrid, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGrid
|
||||||
|
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarGrid>
|
||||||
|
</template>
|
||||||
14
src/components/ui/calendar/CalendarGridBody.vue
Normal file
14
src/components/ui/calendar/CalendarGridBody.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup>
|
||||||
|
import { CalendarGridBody } from "reka-ui";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridBody v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridBody>
|
||||||
|
</template>
|
||||||
15
src/components/ui/calendar/CalendarGridHead.vue
Normal file
15
src/components/ui/calendar/CalendarGridHead.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup>
|
||||||
|
import { CalendarGridHead } from "reka-ui";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridHead v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridHead>
|
||||||
|
</template>
|
||||||
21
src/components/ui/calendar/CalendarGridRow.vue
Normal file
21
src/components/ui/calendar/CalendarGridRow.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarGridRow, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</CalendarGridRow>
|
||||||
|
</template>
|
||||||
29
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
29
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarHeadCell, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeadCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarHeadCell>
|
||||||
|
</template>
|
||||||
26
src/components/ui/calendar/CalendarHeader.vue
Normal file
26
src/components/ui/calendar/CalendarHeader.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarHeader, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeader
|
||||||
|
:class="
|
||||||
|
cn('relative flex w-full items-center justify-between pt-1', props.class)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</CalendarHeader>
|
||||||
|
</template>
|
||||||
29
src/components/ui/calendar/CalendarHeading.vue
Normal file
29
src/components/ui/calendar/CalendarHeading.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { CalendarHeading, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineSlots();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarHeading
|
||||||
|
v-slot="{ headingValue }"
|
||||||
|
:class="cn('text-sm font-medium', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot :heading-value>
|
||||||
|
{{ headingValue }}
|
||||||
|
</slot>
|
||||||
|
</CalendarHeading>
|
||||||
|
</template>
|
||||||
35
src/components/ui/calendar/CalendarNextButton.vue
Normal file
35
src/components/ui/calendar/CalendarNextButton.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { ChevronRight } from "lucide-vue-next";
|
||||||
|
import { CalendarNext, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nextPage: { type: Function, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarNext
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CalendarNext>
|
||||||
|
</template>
|
||||||
35
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
35
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { ChevronLeft } from "lucide-vue-next";
|
||||||
|
import { CalendarPrev, useForwardProps } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
prevPage: { type: Function, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CalendarPrev
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</CalendarPrev>
|
||||||
|
</template>
|
||||||
12
src/components/ui/calendar/index.js
Normal file
12
src/components/ui/calendar/index.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export { default as Calendar } from "./Calendar.vue";
|
||||||
|
export { default as CalendarCell } from "./CalendarCell.vue";
|
||||||
|
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue";
|
||||||
|
export { default as CalendarGrid } from "./CalendarGrid.vue";
|
||||||
|
export { default as CalendarGridBody } from "./CalendarGridBody.vue";
|
||||||
|
export { default as CalendarGridHead } from "./CalendarGridHead.vue";
|
||||||
|
export { default as CalendarGridRow } from "./CalendarGridRow.vue";
|
||||||
|
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue";
|
||||||
|
export { default as CalendarHeader } from "./CalendarHeader.vue";
|
||||||
|
export { default as CalendarHeading } from "./CalendarHeading.vue";
|
||||||
|
export { default as CalendarNextButton } from "./CalendarNextButton.vue";
|
||||||
|
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue";
|
||||||
18
src/components/ui/popover/Popover.vue
Normal file
18
src/components/ui/popover/Popover.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup>
|
||||||
|
import { PopoverRoot, useForwardPropsEmits } from "reka-ui";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
defaultOpen: { type: Boolean, required: false },
|
||||||
|
open: { type: Boolean, required: false },
|
||||||
|
modal: { type: Boolean, required: false },
|
||||||
|
});
|
||||||
|
const emits = defineEmits(["update:open"]);
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</PopoverRoot>
|
||||||
|
</template>
|
||||||
62
src/components/ui/popover/PopoverContent.vue
Normal file
62
src/components/ui/popover/PopoverContent.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
forceMount: { type: Boolean, required: false },
|
||||||
|
side: { type: null, required: false },
|
||||||
|
sideOffset: { type: Number, required: false, default: 4 },
|
||||||
|
sideFlip: { type: Boolean, required: false },
|
||||||
|
align: { type: null, required: false, default: "center" },
|
||||||
|
alignOffset: { type: Number, required: false },
|
||||||
|
alignFlip: { type: Boolean, required: false },
|
||||||
|
avoidCollisions: { type: Boolean, required: false },
|
||||||
|
collisionBoundary: { type: null, required: false },
|
||||||
|
collisionPadding: { type: [Number, Object], required: false },
|
||||||
|
arrowPadding: { type: Number, required: false },
|
||||||
|
sticky: { type: String, required: false },
|
||||||
|
hideWhenDetached: { type: Boolean, required: false },
|
||||||
|
positionStrategy: { type: String, required: false },
|
||||||
|
updatePositionStrategy: { type: String, required: false },
|
||||||
|
disableUpdateOnLayoutShift: { type: Boolean, required: false },
|
||||||
|
prioritizePosition: { type: Boolean, required: false },
|
||||||
|
reference: { type: null, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
const emits = defineEmits([
|
||||||
|
"escapeKeyDown",
|
||||||
|
"pointerDownOutside",
|
||||||
|
"focusOutside",
|
||||||
|
"interactOutside",
|
||||||
|
"openAutoFocus",
|
||||||
|
"closeAutoFocus",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverPortal>
|
||||||
|
<PopoverContent
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</template>
|
||||||
14
src/components/ui/popover/PopoverTrigger.vue
Normal file
14
src/components/ui/popover/PopoverTrigger.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup>
|
||||||
|
import { PopoverTrigger } from "reka-ui";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</PopoverTrigger>
|
||||||
|
</template>
|
||||||
4
src/components/ui/popover/index.js
Normal file
4
src/components/ui/popover/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as Popover } from "./Popover.vue";
|
||||||
|
export { default as PopoverContent } from "./PopoverContent.vue";
|
||||||
|
export { default as PopoverTrigger } from "./PopoverTrigger.vue";
|
||||||
|
export { PopoverAnchor } from "reka-ui";
|
||||||
13
src/lib/utils.js
Normal file
13
src/lib/utils.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valueUpdater(updaterOrValue, ref) {
|
||||||
|
ref.value =
|
||||||
|
typeof updaterOrValue === "function"
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue;
|
||||||
|
}
|
||||||
|
|
@ -1,123 +1,472 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="grid gap-6">
|
<section class="grid gap-6">
|
||||||
|
<!-- Header -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header flex items-center justify-between">
|
<div class="card-header">
|
||||||
<div>
|
<h1 class="card-title">Administration des réservations</h1>
|
||||||
<h1 class="card-title">Administration</h1>
|
<p class="text-sm text-slate-500">Validez, refusez, modifiez les réservations et visualisez les plannings par cargobike.</p>
|
||||||
<p class="text-sm text-slate-500">Gérer les réservations et les cargos.</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 class="flex gap-2">
|
</div>
|
||||||
<button class="btn btn-secondary" @click="load">Rafraîchir</button>
|
</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>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div v-if="error" class="text-sm text-red-700 mb-3">{{ error }}</div>
|
<div v-if="bookings.length === 0" class="text-sm text-slate-500">Aucune réservation pour le moment.</div>
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
<div v-else class="grid gap-3">
|
||||||
<thead class="text-left text-slate-500">
|
<div v-for="b in filtered" :key="b.id" class="rounded-lg border border-slate-200 p-3 grid gap-2">
|
||||||
<tr>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<th class="py-2 pr-4">#</th>
|
<div class="grid gap-1">
|
||||||
<th class="py-2 pr-4">Association</th>
|
<div class="flex items-center gap-2">
|
||||||
<th class="py-2 pr-4">Taille(s)</th>
|
<span class="font-medium">#{{ b.id }}</span>
|
||||||
<th class="py-2 pr-4">Période</th>
|
<StatusBadge :booking="b" :now="now" />
|
||||||
<th class="py-2 pr-4">Emails</th>
|
</div>
|
||||||
<th class="py-2 pr-4">Créé</th>
|
<div class="text-sm">
|
||||||
<th class="py-2 pr-0 text-right">Actions</th>
|
<span class="font-medium">Association:</span>
|
||||||
</tr>
|
<span class="ml-1">{{ b.name }}</span>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="text-xs text-slate-500">Email(s): {{ b.email }}</div>
|
||||||
<tr v-for="b in bookings" :key="b.id" class="border-t border-border">
|
<div class="text-xs text-slate-600">
|
||||||
<td class="py-2 pr-4">{{ b.id }}</td>
|
<span class="font-medium">Période:</span>
|
||||||
<td class="py-2 pr-4">{{ b.name }}</td>
|
<span class="ml-1">{{ formatRange(b) }}</span>
|
||||||
<td class="py-2 pr-4">
|
</div>
|
||||||
<template v-if="sizes(b).length">
|
<div class="text-xs text-slate-600">
|
||||||
<span v-for="(sz, i) in sizes(b)" :key="i" class="inline-flex items-center rounded border border-border px-2 py-0.5 mr-1 mb-1">
|
<span class="font-medium">Cargobike(s):</span>
|
||||||
{{ sz }}
|
<span class="ml-1">{{ formatBikes(b) }}</span>
|
||||||
</span>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<template v-else>
|
|
||||||
<span class="text-slate-500">—</span>
|
<div class="grid gap-2 justify-items-end min-w-[220px]">
|
||||||
</template>
|
<div class="flex flex-wrap gap-2 justify-end">
|
||||||
</td>
|
<button class="btn btn-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
|
||||||
<td class="py-2 pr-4">
|
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="accept(b)" v-if="b.status!=='accepted'">Accepter</button>
|
||||||
<span>{{ fmtDate(b.start_date) }}<span v-if="b.start_time"> {{ b.start_time.slice(0,5) }}</span></span>
|
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="refuse(b)" v-if="b.status!=='accepted'">Refuser</button>
|
||||||
<span class="mx-1">→</span>
|
<button class="btn btn-outline h-8 px-3" v-if="b.status==='refused'" @click="restore(b)">Restaurer</button>
|
||||||
<span>{{ fmtDate(b.end_date) }}<span v-if="b.end_time"> {{ b.end_time.slice(0,5) }}</span></span>
|
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
|
||||||
</td>
|
</div>
|
||||||
<td class="py-2 pr-4">
|
<div class="text-xs text-slate-500">Créée le {{ formatDateTime(new Date(b.created_at)) }}</div>
|
||||||
<span v-for="(em, i) in splitEmails(b.email)" :key="i" class="inline-flex items-center rounded border border-border px-2 py-0.5 mr-1 mb-1">
|
</div>
|
||||||
{{ em }}
|
</div>
|
||||||
</span>
|
|
||||||
</td>
|
<!-- Inline edit row -->
|
||||||
<td class="py-2 pr-4">{{ fmtDateTime(b.created_at) }}</td>
|
<div v-if="editingId === b.id" class="rounded-md bg-slate-50 p-3 border border-slate-200">
|
||||||
<td class="py-2 pr-0 text-right">
|
<div class="grid sm:grid-cols-[1fr_auto] gap-3 items-end">
|
||||||
<button class="btn btn-outline h-8 px-3" @click="remove(b.id)" :disabled="busyIds.has(b.id)">Supprimer</button>
|
<div class="grid gap-2">
|
||||||
</td>
|
<label class="label">Attribuer un cargobike</label>
|
||||||
</tr>
|
<select v-model="editForm.bike_type" class="input h-9">
|
||||||
<tr v-if="!loading && bookings.length === 0">
|
<option :value="null">— Non défini —</option>
|
||||||
<td colspan="7" class="py-4 text-center text-slate-500">Aucune réservation pour le moment.</td>
|
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option>
|
||||||
</tr>
|
</select>
|
||||||
</tbody>
|
<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>
|
||||||
</table>
|
</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>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { api } from '@/services/api'
|
import { api } from '@/services/api'
|
||||||
|
|
||||||
const bookings = ref([])
|
const bookings = ref([])
|
||||||
|
const bikes = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const busyIds = ref(new Set())
|
const lastLoaded = ref(null)
|
||||||
|
const now = ref(new Date())
|
||||||
|
|
||||||
function fmtDate(d) {
|
// Filter for the list
|
||||||
if (!d) return ''
|
const filter = ref('all')
|
||||||
const s = String(d).slice(0, 10) // YYYY-MM-DD
|
|
||||||
const [y, m, dd] = s.split('-')
|
// Editing state
|
||||||
return `${dd}/${m}/${y}`
|
const editingId = ref(null)
|
||||||
}
|
const editForm = reactive({ bike_type: null })
|
||||||
function fmtDateTime(dt) {
|
|
||||||
if (!dt) return ''
|
// Calendar state
|
||||||
const s = String(dt).replace('T', ' ').slice(0, 16) // YYYY-MM-DD HH:MM
|
const selectedBike = ref('all')
|
||||||
const [date, hm] = s.split(' ')
|
const calendarAnchor = ref(new Date())
|
||||||
return `${fmtDate(date)} ${hm}`
|
|
||||||
}
|
onMounted(async () => {
|
||||||
function splitEmails(s) { return s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : [] }
|
await Promise.all([loadBikes(), loadBookings()])
|
||||||
function sizes(b) {
|
if (!selectedBike.value && bikes.value.length) selectedBike.value = bikes.value[0]
|
||||||
const multi = b.bike_types ? String(b.bike_types).split(',').map(x => x.trim()).filter(Boolean) : []
|
// Tick 'now' every minute for live status
|
||||||
if (multi.length) return multi
|
setInterval(() => { now.value = new Date() }, 60 * 1000)
|
||||||
return b.bike_type != null ? [String(b.bike_type)] : []
|
})
|
||||||
|
|
||||||
|
async function loadBikes() {
|
||||||
|
try {
|
||||||
|
bikes.value = await api.getBikes()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Erreur chargement vélos: ${e?.message || e}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function loadBookings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
bookings.value = await api.listBookings()
|
bookings.value = await api.listBookings()
|
||||||
|
lastLoaded.value = new Date()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = 'Impossible de charger les réservations.'
|
error.value = `Impossible de charger les réservations. ${e?.message || ''}`
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
function refresh() { return loadBookings() }
|
||||||
if (!confirm('Supprimer cette réservation ?')) return
|
|
||||||
busyIds.value.add(id)
|
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 {
|
try {
|
||||||
await api.deleteBooking(id)
|
const payload = { bike_type: editForm.bike_type, bike_types: null }
|
||||||
bookings.value = bookings.value.filter(b => b.id !== id)
|
await api.updateBooking(b.id, payload)
|
||||||
|
editingId.value = null
|
||||||
|
await loadBookings()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Échec de la suppression')
|
alert('Échec de la mise à jour.')
|
||||||
} finally {
|
|
||||||
busyIds.value.delete(id)
|
|
||||||
busyIds.value = new Set(busyIds.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
// 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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,15 @@
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
|
||||||
<div class="grid gap-2 self-start">
|
<div class="grid gap-2 self-start">
|
||||||
<div class="text-xs text-slate-500">Grands cargos</div>
|
<div class="text-xs text-slate-500">Grands cargos</div>
|
||||||
<button v-for="b in bigBikes" :key="b" type="button"
|
<button
|
||||||
class="btn-outline h-10 rounded-md w-full text-left px-4"
|
v-for="b in bigBikes"
|
||||||
:class="buttonClass(b)"
|
:key="b"
|
||||||
:aria-pressed="isSelected(b)"
|
type="button"
|
||||||
@click="toggleBike(b)">
|
class="btn-outline h-10 rounded-md w-full text-left px-4"
|
||||||
|
:class="[buttonClass(b), { selected: isSelected(b) }]"
|
||||||
|
:aria-pressed="isSelected(b)"
|
||||||
|
@click="toggleBike(b)"
|
||||||
|
>
|
||||||
{{ b }}
|
{{ b }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,7 +36,7 @@
|
||||||
<div class="text-xs text-slate-500">Petits cargos</div>
|
<div class="text-xs text-slate-500">Petits cargos</div>
|
||||||
<button v-for="b in smallBikes" :key="b" type="button"
|
<button v-for="b in smallBikes" :key="b" type="button"
|
||||||
class="btn-outline h-10 rounded-md w-full text-left px-4"
|
class="btn-outline h-10 rounded-md w-full text-left px-4"
|
||||||
:class="buttonClass(b)"
|
:class="[buttonClass(b), { selected: isSelected(b) }]"
|
||||||
:aria-pressed="isSelected(b)"
|
:aria-pressed="isSelected(b)"
|
||||||
@click="toggleBike(b)">
|
@click="toggleBike(b)">
|
||||||
{{ b }}
|
{{ b }}
|
||||||
|
|
@ -43,77 +47,18 @@
|
||||||
<p v-if="errors.bikeTypes" class="helper text-red-600">{{ errors.bikeTypes }}</p>
|
<p v-if="errors.bikeTypes" class="helper text-red-600">{{ errors.bikeTypes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Period (separate date + 24h time selects) -->
|
<!-- Period: start -->
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-2">
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<span class="label">Début de la réservation</span>
|
||||||
<div class="grid gap-2">
|
<DateTimePicker v-model="startPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
|
||||||
<label class="label">Date de début (jj/mm/aaaa)</label>
|
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
|
||||||
<div class="flex gap-2">
|
</div>
|
||||||
<select v-model="form.startDay" class="select" aria-label="Jour de début">
|
|
||||||
<option value="" disabled>JJ</option>
|
<!-- Period: end -->
|
||||||
<option v-for="d in startDays" :key="`sd-${d}`" :value="d">{{ d }}</option>
|
<div class="grid gap-2">
|
||||||
</select>
|
<span class="label">Fin de la réservation</span>
|
||||||
<select v-model="form.startMonth" class="select" aria-label="Mois de début">
|
<DateTimePicker v-model="endPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
|
||||||
<option value="" disabled>MM</option>
|
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
|
||||||
<option v-for="m in months" :key="`sm-${m}`" :value="m">{{ m }}</option>
|
|
||||||
</select>
|
|
||||||
<select v-model="form.startYear" class="select" aria-label="Année de début">
|
|
||||||
<option value="" disabled>AAAA</option>
|
|
||||||
<option v-for="y in years" :key="`sy-${y}`" :value="String(y)">{{ y }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors.startDate" class="helper text-red-600">{{ errors.startDate }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<label class="label">Heure de début</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select v-model="form.startHour" class="select" aria-label="Heure de début (heures)">
|
|
||||||
<option value="" disabled>HH</option>
|
|
||||||
<option v-for="h in hours" :key="h" :value="h">{{ h }}</option>
|
|
||||||
</select>
|
|
||||||
<span class="self-center">:</span>
|
|
||||||
<select v-model="form.startMinute" class="select" aria-label="Heure de début (minutes)">
|
|
||||||
<option value="" disabled>MM</option>
|
|
||||||
<option v-for="m in minutes" :key="m" :value="m">{{ m }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<label class="label">Date de fin (jj/mm/aaaa)</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select v-model="form.endDay" class="select" aria-label="Jour de fin">
|
|
||||||
<option value="" disabled>JJ</option>
|
|
||||||
<option v-for="d in endDays" :key="`ed-${d}`" :value="d">{{ d }}</option>
|
|
||||||
</select>
|
|
||||||
<select v-model="form.endMonth" class="select" aria-label="Mois de fin">
|
|
||||||
<option value="" disabled>MM</option>
|
|
||||||
<option v-for="m in months" :key="`em-${m}`" :value="m">{{ m }}</option>
|
|
||||||
</select>
|
|
||||||
<select v-model="form.endYear" class="select" aria-label="Année de fin">
|
|
||||||
<option value="" disabled>AAAA</option>
|
|
||||||
<option v-for="y in years" :key="`ey-${y}`" :value="String(y)">{{ y }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors.endDate" class="helper text-red-600">{{ errors.endDate }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<label class="label">Heure de fin</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select v-model="form.endHour" class="select" aria-label="Heure de fin (heures)">
|
|
||||||
<option value="" disabled>HH</option>
|
|
||||||
<option v-for="h in hours" :key="`eh-${h}`" :value="h">{{ h }}</option>
|
|
||||||
</select>
|
|
||||||
<span class="self-center">:</span>
|
|
||||||
<select v-model="form.endMinute" class="select" aria-label="Heure de fin (minutes)">
|
|
||||||
<option value="" disabled>MM</option>
|
|
||||||
<option v-for="m in minutes" :key="`em-${m}`" :value="m">{{ m }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p v-if="errors.time" class="helper text-red-600">{{ errors.time }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Linka Go emails (multiple) -->
|
<!-- Linka Go emails (multiple) -->
|
||||||
|
|
@ -149,11 +94,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, computed } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { api } from '@/services/api'
|
import { api } from '@/services/api'
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker.vue'
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
|
||||||
const minutes = ['00', '15', '30', '45']
|
|
||||||
|
|
||||||
const bigBikes = [1000, 2000]
|
const bigBikes = [1000, 2000]
|
||||||
const smallBikes = [3000, 4000, 5000]
|
const smallBikes = [3000, 4000, 5000]
|
||||||
|
|
@ -164,52 +107,62 @@ const status = reactive({ success: false, message: '', error: '' })
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
association: '',
|
association: '',
|
||||||
bikeTypes: [],
|
bikeTypes: [],
|
||||||
startDate: '',
|
startDate: null, // Date|null
|
||||||
startHour: '',
|
startTime: '', // 'HH:mm'
|
||||||
startMinute: '',
|
endDate: null, // Date|null
|
||||||
endDate: '',
|
endTime: '', // 'HH:mm'
|
||||||
endHour: '',
|
|
||||||
endMinute: '',
|
|
||||||
emails: [''],
|
emails: [''],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// New: picked DateTime values bound to the DateTimePicker components
|
||||||
|
const startPicked = ref(null) // Date|null
|
||||||
|
const endPicked = ref(null) // Date|null
|
||||||
|
|
||||||
|
function formatHHmm(d) {
|
||||||
|
if (!(d instanceof Date)) return ''
|
||||||
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync picked DateTime to existing form fields
|
||||||
|
watch(startPicked, (d) => {
|
||||||
|
if (d instanceof Date) {
|
||||||
|
form.startDate = new Date(d)
|
||||||
|
form.startTime = formatHHmm(d)
|
||||||
|
} else {
|
||||||
|
form.startDate = null
|
||||||
|
form.startTime = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(endPicked, (d) => {
|
||||||
|
if (d instanceof Date) {
|
||||||
|
form.endDate = new Date(d)
|
||||||
|
form.endTime = formatHHmm(d)
|
||||||
|
} else {
|
||||||
|
form.endDate = null
|
||||||
|
form.endTime = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
association: '',
|
association: '',
|
||||||
bikeTypes: '',
|
bikeTypes: '',
|
||||||
startDate: '',
|
start: '',
|
||||||
endDate: '',
|
end: '',
|
||||||
time: '',
|
|
||||||
emails: '',
|
emails: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const years = computed(() => {
|
|
||||||
const y = new Date().getFullYear()
|
|
||||||
return [y, y + 1, y + 2]
|
|
||||||
})
|
|
||||||
const months = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
|
|
||||||
function daysInMonth(year, month) {
|
|
||||||
if (!year || !month) return 31
|
|
||||||
return new Date(Number(year), Number(month), 0).getDate()
|
|
||||||
}
|
|
||||||
const startDays = computed(() => {
|
|
||||||
const n = daysInMonth(form.startYear, form.startMonth)
|
|
||||||
return Array.from({ length: n }, (_, i) => String(i + 1).padStart(2, '0'))
|
|
||||||
})
|
|
||||||
const endDays = computed(() => {
|
|
||||||
const n = daysInMonth(form.endYear, form.endMonth)
|
|
||||||
return Array.from({ length: n }, (_, i) => String(i + 1).padStart(2, '0'))
|
|
||||||
})
|
|
||||||
|
|
||||||
function isSelected(b) { return form.bikeTypes.includes(b) }
|
function isSelected(b) { return form.bikeTypes.includes(b) }
|
||||||
function toggleBike(b) {
|
function toggleBike(b) {
|
||||||
const i = form.bikeTypes.indexOf(b)
|
const i = form.bikeTypes.indexOf(b)
|
||||||
if (i >= 0) form.bikeTypes.splice(i, 1)
|
if (i >= 0) form.bikeTypes.splice(i, 1)
|
||||||
else form.bikeTypes.push(b)
|
else form.bikeTypes.push(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonClass(b) {
|
function buttonClass(b) {
|
||||||
return isSelected(b)
|
return isSelected(b)
|
||||||
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] border-transparent ring-2 ring-[hsl(var(--ring))]'
|
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] border-transparent'
|
||||||
: 'hover:bg-[hsl(var(--muted))]'
|
: 'hover:bg-[hsl(var(--muted))]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,40 +171,41 @@ function removeEmail(idx) { if (form.emails.length > 1) form.emails.splice(idx,
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
association: '', bikeTypes: [], startDate: '', startHour: '', startMinute: '', endDate: '', endHour: '', endMinute: '', emails: ['']
|
association: '', bikeTypes: [], startDate: null, startTime: '', endDate: null, endTime: '', emails: ['']
|
||||||
})
|
})
|
||||||
|
// Also reset pickers
|
||||||
|
startPicked.value = null
|
||||||
|
endPicked.value = null
|
||||||
Object.keys(errors).forEach(k => errors[k] = '')
|
Object.keys(errors).forEach(k => errors[k] = '')
|
||||||
Object.assign(status, { success: false, message: '', error: '' })
|
Object.assign(status, { success: false, message: '', error: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function asISO(y, m, d) {
|
function toISODate(d) {
|
||||||
if (!y || !m || !d) return ''
|
if (!(d instanceof Date)) return ''
|
||||||
return `${y}-${m}-${d}`
|
const yyyy = d.getFullYear()
|
||||||
}
|
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
function isDateValid(y, m, d) {
|
const dd = String(d.getDate()).padStart(2, '0')
|
||||||
if (!y || !m || !d) return false
|
return `${yyyy}-${mm}-${dd}`
|
||||||
const dd = Number(d), mm = Number(m), yy = Number(y)
|
|
||||||
const dt = new Date(yy, mm - 1, dd)
|
|
||||||
return dt.getFullYear() === yy && dt.getMonth() === mm - 1 && dt.getDate() === dd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
Object.keys(errors).forEach(k => errors[k] = '')
|
Object.keys(errors).forEach(k => errors[k] = '')
|
||||||
let ok = true
|
let ok = true
|
||||||
if (!form.association || form.association.length < 2) { errors.association = 'Renseignez le nom de l’association.'; ok = false }
|
if (!form.association || form.association.length < 2) { errors.association = "Renseignez le nom de l’association."; ok = false }
|
||||||
if (!form.bikeTypes.length) { errors.bikeTypes = 'Sélectionnez au moins un vélo.'; ok = false }
|
if (!form.bikeTypes.length) { errors.bikeTypes = 'Sélectionnez au moins un vélo.'; ok = false }
|
||||||
if (!isDateValid(form.startYear, form.startMonth, form.startDay)) { errors.startDate = 'Sélectionnez une date de début valide (jj/mm/aaaa).'; ok = false }
|
if (!form.startDate) { errors.start = 'Sélectionnez une date de début.'; ok = false }
|
||||||
if (!isDateValid(form.endYear, form.endMonth, form.endDay)) { errors.endDate = 'Sélectionnez une date de fin valide (jj/mm/aaaa).'; ok = false }
|
if (!form.endDate) { errors.end = 'Sélectionnez une date de fin.'; ok = false }
|
||||||
if (!form.startHour || !form.startMinute || !form.endHour || !form.endMinute) { errors.time = 'Renseignez les heures de début et de fin.'; ok = false }
|
const [sh, sm] = (form.startTime || '').split(':')
|
||||||
|
const [eh, em] = (form.endTime || '').split(':')
|
||||||
|
if (!sh || !sm) { errors.start = 'Renseignez l’heure de début (24h).'; ok = false }
|
||||||
|
if (!eh || !em) { errors.end = 'Renseignez l’heure de fin (24h).'; ok = false }
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
const startISO = asISO(form.startYear, form.startMonth, form.startDay)
|
const s = new Date(form.startDate)
|
||||||
const endISO = asISO(form.endYear, form.endMonth, form.endDay)
|
s.setHours(Number(sh), Number(sm), 0, 0)
|
||||||
if (endISO < startISO) { errors.endDate = 'La date de fin doit être après la date de début.'; ok = false }
|
const e = new Date(form.endDate)
|
||||||
if (endISO === startISO) {
|
e.setHours(Number(eh), Number(em), 0, 0)
|
||||||
const s = Number(form.startHour) * 60 + Number(form.startMinute)
|
if (e <= s) { errors.end = 'La fin doit être après le début.'; ok = false }
|
||||||
const e = Number(form.endHour) * 60 + Number(form.endMinute)
|
|
||||||
if (e <= s) { errors.time = 'Heure de fin après l’heure de début requise.'; ok = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const validEmails = form.emails.map(e => e.trim()).filter(Boolean)
|
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 }
|
if (validEmails.length === 0) { errors.emails = 'Renseignez au moins une adresse e‑mail.'; ok = false }
|
||||||
|
|
@ -266,15 +220,16 @@ async function onSubmit() {
|
||||||
status.error = ''
|
status.error = ''
|
||||||
status.message = ''
|
status.message = ''
|
||||||
try {
|
try {
|
||||||
const emailStr = form.emails.map(e => e.trim()).filter(Boolean).join(',')
|
const [sh, sm] = form.startTime.split(':')
|
||||||
|
const [eh, em] = form.endTime.split(':')
|
||||||
const payload = {
|
const payload = {
|
||||||
bike_types: [...form.bikeTypes],
|
bike_types: [...form.bikeTypes],
|
||||||
start_date: asISO(form.startYear, form.startMonth, form.startDay),
|
start_date: toISODate(form.startDate),
|
||||||
start_time: `${form.startHour}:${form.startMinute}`,
|
start_time: `${sh}:${sm}`,
|
||||||
end_date: asISO(form.endYear, form.endMonth, form.endDay),
|
end_date: toISODate(form.endDate),
|
||||||
end_time: `${form.endHour}:${form.endMinute}`,
|
end_time: `${eh}:${em}`,
|
||||||
name: form.association,
|
name: form.association,
|
||||||
email: emailStr,
|
email: form.emails.map(e => e.trim()).filter(Boolean).join(','),
|
||||||
}
|
}
|
||||||
const booking = await api.createBooking(payload)
|
const booking = await api.createBooking(payload)
|
||||||
status.success = true
|
status.success = true
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,62 @@
|
||||||
|
const API_BASE = typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_BASE ? import.meta.env.VITE_API_BASE : ''
|
||||||
|
|
||||||
|
async function doFetch(path, init) {
|
||||||
|
const primaryUrl = API_BASE + path
|
||||||
|
try {
|
||||||
|
const res = await fetch(primaryUrl, init)
|
||||||
|
if (!res.ok) throw Object.assign(new Error(await safeText(res)), { status: res.status })
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
// Localhost/LAN fallback for preview when no API_BASE is set
|
||||||
|
if (!API_BASE && typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
|
const host = window.location.hostname
|
||||||
|
const fallbackUrl = `${proto}//${host}:3000${path}`
|
||||||
|
const res2 = await fetch(fallbackUrl, init)
|
||||||
|
if (!res2.ok) throw Object.assign(new Error(await safeText(res2)), { status: res2.status })
|
||||||
|
return res2
|
||||||
|
} catch (e2) {
|
||||||
|
// As a last attempt, try localhost explicitly (useful when host is a raw IP)
|
||||||
|
try {
|
||||||
|
const proto2 = window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
|
const res3 = await fetch(`${proto2}//localhost:3000${path}`, init)
|
||||||
|
if (!res3.ok) throw Object.assign(new Error(await safeText(res3)), { status: res3.status })
|
||||||
|
return res3
|
||||||
|
} catch (e3) {
|
||||||
|
throw e3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiGet(path) {
|
export async function apiGet(path) {
|
||||||
const res = await fetch(path)
|
const res = await doFetch(path, {})
|
||||||
if (!res.ok) throw new Error(await safeText(res))
|
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost(path, body) {
|
export async function apiPost(path, body) {
|
||||||
const res = await fetch(path, {
|
const res = await doFetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(await safeText(res))
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch(path, body) {
|
||||||
|
const res = await doFetch(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path) {
|
export async function apiDelete(path) {
|
||||||
const res = await fetch(path, { method: 'DELETE' })
|
const res = await doFetch(path, { method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error(await safeText(res))
|
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +79,13 @@ export const api = {
|
||||||
},
|
},
|
||||||
async deleteBooking(id) {
|
async deleteBooking(id) {
|
||||||
return apiDelete(`/api/bookings/${id}`)
|
return apiDelete(`/api/bookings/${id}`)
|
||||||
|
},
|
||||||
|
async updateBookingStatus(id, status) {
|
||||||
|
const { booking } = await apiPatch(`/api/bookings/${id}/status`, { status })
|
||||||
|
return booking
|
||||||
|
},
|
||||||
|
async updateBooking(id, payload) {
|
||||||
|
const { booking } = await apiPatch(`/api/bookings/${id}`, payload)
|
||||||
|
return booking
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,83 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
'./index.html',
|
'./index.html',
|
||||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--ring))',
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))',
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
},
|
},
|
||||||
},
|
chart: {
|
||||||
borderRadius: {
|
'1': 'hsl(var(--chart-1))',
|
||||||
lg: 'var(--radius)',
|
'2': 'hsl(var(--chart-2))',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
'3': 'hsl(var(--chart-3))',
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
'4': 'hsl(var(--chart-4))',
|
||||||
},
|
'5': 'hsl(var(--chart-5))'
|
||||||
keyframes: {
|
}
|
||||||
'accordion-down': {
|
},
|
||||||
from: { height: 0 },
|
borderRadius: {
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
lg: 'var(--radius)',
|
||||||
},
|
md: 'calc(var(--radius) - 2px)',
|
||||||
'accordion-up': {
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
},
|
||||||
to: { height: 0 },
|
keyframes: {
|
||||||
},
|
'accordion-down': {
|
||||||
},
|
from: {
|
||||||
animation: {
|
height: 0
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
},
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
to: {
|
||||||
},
|
height: 'var(--radix-accordion-content-height)'
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: {
|
||||||
|
height: 'var(--radix-accordion-content-height)'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue