feat: admin and book page

This commit is contained in:
Antoine Pelletier 2025-10-13 05:27:04 +02:00
parent 1ed2a64854
commit 75c4785e3d
30 changed files with 1966 additions and 408 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

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

View 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",
},
},
);

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

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

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

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

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

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

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

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

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

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

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

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

View 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";

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

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

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

View 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
View 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;
}

View file

@ -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">Aujourdhui</button>
<div class="text-sm text-slate-600 ml-2">Semaine du {{ formatDate(weekStart) }} au {{ formatDate(weekEnd) }}</div>
</div>
<!-- Week grid -->
<div class="overflow-auto border border-slate-200 rounded-md">
<div class="min-w-[900px]">
<!-- Header row: days -->
<div class="grid" :style="gridTemplateDays">
<div class="sticky left-0 bg-white z-10 border-b border-slate-200 p-2 text-xs text-slate-500">Heure</div>
<div v-for="(d, idx) in weekDays" :key="idx" class="border-b border-l border-slate-200 p-2 text-xs font-medium text-slate-700">
{{ weekdayShort(d) }} {{ d.getDate() }}/{{ (d.getMonth()+1).toString().padStart(2,'0') }}
</div>
</div>
<!-- Body: hours + day columns -->
<div class="grid" :style="gridTemplateDays">
<!-- Hours rail -->
<div class="sticky left-0 bg-white z-10 border-r border-slate-200">
<div v-for="h in hours" :key="h" class="h-12 border-b border-slate-100 px-2 text-[11px] text-slate-500 flex items-start pt-1">
{{ h.toString().padStart(2,'0') }}:00
</div>
</div>
<!-- Day columns -->
<div v-for="(d, dayIdx) in weekDays" :key="dayIdx" class="relative border-l border-slate-200" :data-date="d.toISOString()">
<!-- Hour lines -->
<div v-for="h in hours" :key="h" class="h-12 border-b border-slate-100"></div>
<!-- Events -->
<div v-for="(ev, idx) in dayEvents[dayIdx]" :key="idx" class="absolute rounded-md text-[11px] leading-tight px-2 py-1 text-white shadow"
:class="ev.status === 'pending' ? 'bg-amber-500' : 'bg-blue-600'"
:style="eventStyle(ev)">
<div class="font-medium truncate">{{ ev.name }}</div>
<div class="opacity-90 truncate">{{ ev.timeLabel }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-xs text-slate-500">Les réservations refusées ne sont pas affichées dans le calendrier.</div>
</div>
</div>
</section> </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>

View file

@ -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 lassociation.'; ok = false } if (!form.association || form.association.length < 2) { errors.association = "Renseignez le nom de lassociation."; 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 lheure de début (24h).'; ok = false }
if (!eh || !em) { errors.end = 'Renseignez lheure 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 lheure 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 email.'; ok = false } if (validEmails.length === 0) { errors.emails = 'Renseignez au moins une adresse email.'; 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

View file

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

View file

@ -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")],
} }