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' })
|
||||
}
|
||||
|
||||
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`
|
||||
const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email]
|
||||
// Explicitly set status to 'pending' to avoid relying solely on DB defaults
|
||||
const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email, status)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`
|
||||
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)
|
||||
res.status(201).json({ booking: result.rows[0] })
|
||||
} 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
|
||||
app.delete('/api/bookings/:id', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -82,6 +143,7 @@ async function ensureTables() {
|
|||
end_time TIME,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
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 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 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;`)
|
||||
console.log('Database: bookings table is present (created/migrated if needed)')
|
||||
} 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"
|
||||
},
|
||||
"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-router": "^4.2.1"
|
||||
"vue-router": "^4.2.1",
|
||||
"vue3-datepicker": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-vue-devtools": "^8.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"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-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--selected: 210 100% 96%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
|
|
@ -53,6 +55,8 @@
|
|||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--selected: 210 100% 96%;
|
||||
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
|
||||
|
|
@ -68,7 +72,7 @@
|
|||
}
|
||||
|
||||
/* 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-header { @apply p-6 pb-3; }
|
||||
.card-title { @apply text-xl font-semibold leading-none tracking-tight; }
|
||||
|
|
@ -112,7 +116,80 @@ a,
|
|||
}
|
||||
|
||||
#app {
|
||||
/* keep a comfortable inner padding on wide screens */
|
||||
padding: 1.5rem 2rem;
|
||||
/* align header and main containers by removing extra app padding */
|
||||
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>
|
||||
<section class="grid gap-6">
|
||||
<!-- Header -->
|
||||
<div class="card">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="card-title">Administration</h1>
|
||||
<p class="text-sm text-slate-500">Gérer les réservations et les cargos.</p>
|
||||
<div class="card-header">
|
||||
<h1 class="card-title">Administration des réservations</h1>
|
||||
<p class="text-sm text-slate-500">Validez, refusez, modifiez les réservations et visualisez les plannings par cargobike.</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button class="btn h-9 px-3" @click="refresh" :disabled="loading">{{ loading ? 'Rafraîchissement…' : 'Rafraîchir' }}</button>
|
||||
<span class="text-xs text-slate-500" v-if="lastLoaded">Dernier chargement: {{ formatDateTime(lastLoaded) }}</span>
|
||||
<span class="text-xs text-red-600" v-if="error">{{ error }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" @click="load">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List of bookings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="card-title">Réservations</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500">Filtrer</label>
|
||||
<select v-model="filter" class="input h-9 w-[160px]">
|
||||
<option value="all">Toutes</option>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="accepted">Acceptées</option>
|
||||
<option value="ongoing">En cours</option>
|
||||
<option value="archived">Archivées</option>
|
||||
<option value="refused">Refusées</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div v-if="error" class="text-sm text-red-700 mb-3">{{ error }}</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="py-2 pr-4">#</th>
|
||||
<th class="py-2 pr-4">Association</th>
|
||||
<th class="py-2 pr-4">Taille(s)</th>
|
||||
<th class="py-2 pr-4">Période</th>
|
||||
<th class="py-2 pr-4">Emails</th>
|
||||
<th class="py-2 pr-4">Créé</th>
|
||||
<th class="py-2 pr-0 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in bookings" :key="b.id" class="border-t border-border">
|
||||
<td class="py-2 pr-4">{{ b.id }}</td>
|
||||
<td class="py-2 pr-4">{{ b.name }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<template v-if="sizes(b).length">
|
||||
<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">
|
||||
{{ sz }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-slate-500">—</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span>{{ fmtDate(b.start_date) }}<span v-if="b.start_time"> {{ b.start_time.slice(0,5) }}</span></span>
|
||||
<span class="mx-1">→</span>
|
||||
<span>{{ fmtDate(b.end_date) }}<span v-if="b.end_time"> {{ b.end_time.slice(0,5) }}</span></span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<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">
|
||||
{{ em }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ fmtDateTime(b.created_at) }}</td>
|
||||
<td class="py-2 pr-0 text-right">
|
||||
<button class="btn btn-outline h-8 px-3" @click="remove(b.id)" :disabled="busyIds.has(b.id)">Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && bookings.length === 0">
|
||||
<td colspan="7" class="py-4 text-center text-slate-500">Aucune réservation pour le moment.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="bookings.length === 0" class="text-sm text-slate-500">Aucune réservation pour le moment.</div>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
<div v-for="b in filtered" :key="b.id" class="rounded-lg border border-slate-200 p-3 grid gap-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">#{{ b.id }}</span>
|
||||
<StatusBadge :booking="b" :now="now" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Association:</span>
|
||||
<span class="ml-1">{{ b.name }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Email(s): {{ b.email }}</div>
|
||||
<div class="text-xs text-slate-600">
|
||||
<span class="font-medium">Période:</span>
|
||||
<span class="ml-1">{{ formatRange(b) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-600">
|
||||
<span class="font-medium">Cargobike(s):</span>
|
||||
<span class="ml-1">{{ formatBikes(b) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 justify-items-end min-w-[220px]">
|
||||
<div class="flex flex-wrap gap-2 justify-end">
|
||||
<button class="btn btn-secondary h-8 px-3" @click="startEdit(b)">Modifier</button>
|
||||
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="accept(b)" v-if="b.status!=='accepted'">Accepter</button>
|
||||
<button class="btn btn-outline h-8 px-3" :disabled="b.status==='accepted'" @click="refuse(b)" v-if="b.status!=='accepted'">Refuser</button>
|
||||
<button class="btn btn-outline h-8 px-3" v-if="b.status==='refused'" @click="restore(b)">Restaurer</button>
|
||||
<button class="btn btn-outline h-8 px-3" @click="remove(b)">Supprimer</button>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Créée le {{ formatDateTime(new Date(b.created_at)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline edit row -->
|
||||
<div v-if="editingId === b.id" class="rounded-md bg-slate-50 p-3 border border-slate-200">
|
||||
<div class="grid sm:grid-cols-[1fr_auto] gap-3 items-end">
|
||||
<div class="grid gap-2">
|
||||
<label class="label">Attribuer un cargobike</label>
|
||||
<select v-model="editForm.bike_type" class="input h-9">
|
||||
<option :value="null">— Non défini —</option>
|
||||
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<p class="helper text-xs text-slate-500">Astuce: pour un choix multiple, utilisez la page de réservation; ici on affecte un seul vélo.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn h-9 px-3" @click="saveEdit(b)">Enregistrer</button>
|
||||
<button class="btn btn-secondary h-9 px-3" @click="cancelEdit">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar per cargobike -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex flex-wrap items-center gap-3 justify-between">
|
||||
<h2 class="card-title">Calendrier par cargobike</h2>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-xs text-slate-500">Cargobike</label>
|
||||
<select v-model="selectedBike" class="input h-9 w-[160px]">
|
||||
<option value="all">Tous</option>
|
||||
<option v-for="t in bikes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content grid gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="btn btn-outline h-8 px-3" @click="prevWeek">Semaine -</button>
|
||||
<button class="btn btn-outline h-8 px-3" @click="nextWeek">Semaine +</button>
|
||||
<button class="btn btn-secondary h-8 px-3" @click="goToday">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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
const bookings = ref([])
|
||||
const bikes = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const busyIds = ref(new Set())
|
||||
const lastLoaded = ref(null)
|
||||
const now = ref(new Date())
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return ''
|
||||
const s = String(d).slice(0, 10) // YYYY-MM-DD
|
||||
const [y, m, dd] = s.split('-')
|
||||
return `${dd}/${m}/${y}`
|
||||
}
|
||||
function fmtDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
const s = String(dt).replace('T', ' ').slice(0, 16) // YYYY-MM-DD HH:MM
|
||||
const [date, hm] = s.split(' ')
|
||||
return `${fmtDate(date)} ${hm}`
|
||||
}
|
||||
function splitEmails(s) { return s ? String(s).split(',').map(x => x.trim()).filter(Boolean) : [] }
|
||||
function sizes(b) {
|
||||
const multi = b.bike_types ? String(b.bike_types).split(',').map(x => x.trim()).filter(Boolean) : []
|
||||
if (multi.length) return multi
|
||||
return b.bike_type != null ? [String(b.bike_type)] : []
|
||||
// Filter for the list
|
||||
const filter = ref('all')
|
||||
|
||||
// Editing state
|
||||
const editingId = ref(null)
|
||||
const editForm = reactive({ bike_type: null })
|
||||
|
||||
// Calendar state
|
||||
const selectedBike = ref('all')
|
||||
const calendarAnchor = ref(new Date())
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadBikes(), loadBookings()])
|
||||
if (!selectedBike.value && bikes.value.length) selectedBike.value = bikes.value[0]
|
||||
// Tick 'now' every minute for live status
|
||||
setInterval(() => { now.value = new Date() }, 60 * 1000)
|
||||
})
|
||||
|
||||
async function loadBikes() {
|
||||
try {
|
||||
bikes.value = await api.getBikes()
|
||||
} catch (e) {
|
||||
error.value = `Erreur chargement vélos: ${e?.message || e}`
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function loadBookings() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
bookings.value = await api.listBookings()
|
||||
lastLoaded.value = new Date()
|
||||
} catch (e) {
|
||||
error.value = 'Impossible de charger les réservations.'
|
||||
error.value = `Impossible de charger les réservations. ${e?.message || ''}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm('Supprimer cette réservation ?')) return
|
||||
busyIds.value.add(id)
|
||||
function refresh() { return loadBookings() }
|
||||
|
||||
function getSingleBikeType(b) {
|
||||
const val = b?.bike_type
|
||||
return typeof val === 'number' && Number.isFinite(val) ? val : null
|
||||
}
|
||||
|
||||
function getMultiBikeTypes(b) {
|
||||
if (typeof b?.bike_types === 'string' && b.bike_types.trim()) {
|
||||
return b.bike_types.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function includesBike(b, bike) {
|
||||
if (bike === 'all') return true
|
||||
const single = getSingleBikeType(b)
|
||||
const multi = getMultiBikeTypes(b)
|
||||
if (single !== null) return single === bike
|
||||
if (multi.length) return multi.includes(bike)
|
||||
return false
|
||||
}
|
||||
|
||||
function safeHHmm(timeStr) {
|
||||
if (!timeStr || typeof timeStr !== 'string') return ''
|
||||
const [h = '00', m = '00'] = timeStr.split(':')
|
||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function formatRange(b) {
|
||||
// Use raw times to avoid TZ issues; still compute if same day
|
||||
const s = parseDateTime(b.start_date, b.start_time)
|
||||
const e = parseDateTime(b.end_date, b.end_time, true)
|
||||
const sameDay = s.toDateString() === e.toDateString()
|
||||
const st = safeHHmm(b.start_time)
|
||||
const et = safeHHmm(b.end_time)
|
||||
if (sameDay) return `${formatDate(s)} ${st} → ${et}`
|
||||
return `${formatDate(s)} ${st} → ${formatDate(e)} ${et}`
|
||||
}
|
||||
|
||||
function formatBikes(b) {
|
||||
const parts = []
|
||||
const single = getSingleBikeType(b)
|
||||
if (single !== null) parts.push(String(single))
|
||||
const multi = getMultiBikeTypes(b).map(n => String(n))
|
||||
parts.push(...multi)
|
||||
return parts.length ? parts.join(', ') : '—'
|
||||
}
|
||||
|
||||
function parseDateTime(dateStr, timeStr, end = false) {
|
||||
// dateStr = 'YYYY-MM-DD', timeStr = 'HH:MM' or null
|
||||
const [y, m, d] = (dateStr || '').split('-').map(Number)
|
||||
let h = 0, min = 0
|
||||
if (timeStr && timeStr.includes(':')) {
|
||||
const [hh, mm] = timeStr.split(':').map(Number)
|
||||
h = hh; min = mm
|
||||
} else if (end) {
|
||||
h = 23; min = 59
|
||||
}
|
||||
return new Date(y, (m || 1) - 1, d || 1, h, min, 0, 0)
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const yyyy = d.getFullYear()
|
||||
return `${dd}/${mm}/${yyyy}`
|
||||
}
|
||||
function formatTime(d) {
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
function formatDateTime(d) { return `${formatDate(d)} ${formatTime(d)}` }
|
||||
|
||||
function startEdit(b) {
|
||||
editingId.value = b.id
|
||||
const bt = getSingleBikeType(b)
|
||||
editForm.bike_type = bt !== null ? bt : null
|
||||
}
|
||||
function cancelEdit() { editingId.value = null }
|
||||
async function saveEdit(b) {
|
||||
try {
|
||||
await api.deleteBooking(id)
|
||||
bookings.value = bookings.value.filter(b => b.id !== id)
|
||||
const payload = { bike_type: editForm.bike_type, bike_types: null }
|
||||
await api.updateBooking(b.id, payload)
|
||||
editingId.value = null
|
||||
await loadBookings()
|
||||
} catch (e) {
|
||||
alert('Échec de la suppression')
|
||||
} finally {
|
||||
busyIds.value.delete(id)
|
||||
busyIds.value = new Set(busyIds.value)
|
||||
alert('Échec de la mise à jour.')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- 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 gap-2 self-start">
|
||||
<div class="text-xs text-slate-500">Grands cargos</div>
|
||||
<button v-for="b in bigBikes" :key="b" type="button"
|
||||
class="btn-outline h-10 rounded-md w-full text-left px-4"
|
||||
:class="buttonClass(b)"
|
||||
:aria-pressed="isSelected(b)"
|
||||
@click="toggleBike(b)">
|
||||
<button
|
||||
v-for="b in bigBikes"
|
||||
:key="b"
|
||||
type="button"
|
||||
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 }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -32,7 +36,7 @@
|
|||
<div class="text-xs text-slate-500">Petits cargos</div>
|
||||
<button v-for="b in smallBikes" :key="b" type="button"
|
||||
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)"
|
||||
@click="toggleBike(b)">
|
||||
{{ b }}
|
||||
|
|
@ -43,77 +47,18 @@
|
|||
<p v-if="errors.bikeTypes" class="helper text-red-600">{{ errors.bikeTypes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Period (separate date + 24h time selects) -->
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<label class="label">Date de début (jj/mm/aaaa)</label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="form.startDay" class="select" aria-label="Jour de début">
|
||||
<option value="" disabled>JJ</option>
|
||||
<option v-for="d in startDays" :key="`sd-${d}`" :value="d">{{ d }}</option>
|
||||
</select>
|
||||
<select v-model="form.startMonth" class="select" aria-label="Mois de début">
|
||||
<option value="" disabled>MM</option>
|
||||
<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>
|
||||
<!-- Period: start -->
|
||||
<div class="grid gap-2">
|
||||
<span class="label">Début de la réservation</span>
|
||||
<DateTimePicker v-model="startPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
|
||||
<p v-if="errors.start" class="helper text-red-600">{{ errors.start }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Period: end -->
|
||||
<div class="grid gap-2">
|
||||
<span class="label">Fin de la réservation</span>
|
||||
<DateTimePicker v-model="endPicked" :use24h="true" :minute-step="5" locale="fr-FR" />
|
||||
<p v-if="errors.end" class="helper text-red-600">{{ errors.end }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Linka Go emails (multiple) -->
|
||||
|
|
@ -149,11 +94,9 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const minutes = ['00', '15', '30', '45']
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue'
|
||||
|
||||
const bigBikes = [1000, 2000]
|
||||
const smallBikes = [3000, 4000, 5000]
|
||||
|
|
@ -164,52 +107,62 @@ const status = reactive({ success: false, message: '', error: '' })
|
|||
const form = reactive({
|
||||
association: '',
|
||||
bikeTypes: [],
|
||||
startDate: '',
|
||||
startHour: '',
|
||||
startMinute: '',
|
||||
endDate: '',
|
||||
endHour: '',
|
||||
endMinute: '',
|
||||
startDate: null, // Date|null
|
||||
startTime: '', // 'HH:mm'
|
||||
endDate: null, // Date|null
|
||||
endTime: '', // 'HH:mm'
|
||||
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({
|
||||
association: '',
|
||||
bikeTypes: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
time: '',
|
||||
start: '',
|
||||
end: '',
|
||||
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 toggleBike(b) {
|
||||
const i = form.bikeTypes.indexOf(b)
|
||||
if (i >= 0) form.bikeTypes.splice(i, 1)
|
||||
else form.bikeTypes.push(b)
|
||||
}
|
||||
|
||||
function buttonClass(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))]'
|
||||
}
|
||||
|
||||
|
|
@ -218,40 +171,41 @@ function removeEmail(idx) { if (form.emails.length > 1) form.emails.splice(idx,
|
|||
|
||||
function resetForm() {
|
||||
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.assign(status, { success: false, message: '', error: '' })
|
||||
}
|
||||
|
||||
function asISO(y, m, d) {
|
||||
if (!y || !m || !d) return ''
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
function isDateValid(y, m, d) {
|
||||
if (!y || !m || !d) return false
|
||||
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 toISODate(d) {
|
||||
if (!(d instanceof Date)) return ''
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function validate() {
|
||||
Object.keys(errors).forEach(k => errors[k] = '')
|
||||
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 (!isDateValid(form.startYear, form.startMonth, form.startDay)) { errors.startDate = 'Sélectionnez une date de début valide (jj/mm/aaaa).'; 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.startHour || !form.startMinute || !form.endHour || !form.endMinute) { errors.time = 'Renseignez les heures de début et de fin.'; ok = false }
|
||||
if (!form.startDate) { errors.start = 'Sélectionnez une date de début.'; ok = false }
|
||||
if (!form.endDate) { errors.end = 'Sélectionnez une date 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) {
|
||||
const startISO = asISO(form.startYear, form.startMonth, form.startDay)
|
||||
const endISO = asISO(form.endYear, form.endMonth, form.endDay)
|
||||
if (endISO < startISO) { errors.endDate = 'La date de fin doit être après la date de début.'; ok = false }
|
||||
if (endISO === startISO) {
|
||||
const s = Number(form.startHour) * 60 + Number(form.startMinute)
|
||||
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 s = new Date(form.startDate)
|
||||
s.setHours(Number(sh), Number(sm), 0, 0)
|
||||
const e = new Date(form.endDate)
|
||||
e.setHours(Number(eh), Number(em), 0, 0)
|
||||
if (e <= s) { errors.end = 'La fin doit être après le début.'; ok = false }
|
||||
}
|
||||
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 }
|
||||
|
|
@ -266,15 +220,16 @@ async function onSubmit() {
|
|||
status.error = ''
|
||||
status.message = ''
|
||||
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 = {
|
||||
bike_types: [...form.bikeTypes],
|
||||
start_date: asISO(form.startYear, form.startMonth, form.startDay),
|
||||
start_time: `${form.startHour}:${form.startMinute}`,
|
||||
end_date: asISO(form.endYear, form.endMonth, form.endDay),
|
||||
end_time: `${form.endHour}:${form.endMinute}`,
|
||||
start_date: toISODate(form.startDate),
|
||||
start_time: `${sh}:${sm}`,
|
||||
end_date: toISODate(form.endDate),
|
||||
end_time: `${eh}:${em}`,
|
||||
name: form.association,
|
||||
email: emailStr,
|
||||
email: form.emails.map(e => e.trim()).filter(Boolean).join(','),
|
||||
}
|
||||
const booking = await api.createBooking(payload)
|
||||
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) {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) throw new Error(await safeText(res))
|
||||
const res = await doFetch(path, {})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function apiPost(path, body) {
|
||||
const res = await fetch(path, {
|
||||
const res = await doFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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()
|
||||
}
|
||||
|
||||
export async function apiDelete(path) {
|
||||
const res = await fetch(path, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await safeText(res))
|
||||
const res = await doFetch(path, { method: 'DELETE' })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +79,13 @@ export const api = {
|
|||
},
|
||||
async deleteBooking(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} */
|
||||
export default {
|
||||
content: [
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
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',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: 0
|
||||
},
|
||||
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