236 lines
7.7 KiB
Vue
236 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||
|
||
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
|
||
|
||
const { locale } = useI18n()
|
||
|
||
const FIRST_HOUR = 7
|
||
const LAST_HOUR = 20
|
||
const HOUR_HEIGHT = 44
|
||
|
||
const weekStart = ref(startOfWeek(new Date()))
|
||
const selectedBike = ref<string>('all')
|
||
|
||
function startOfWeek(date: Date) {
|
||
const start = new Date(date)
|
||
start.setHours(0, 0, 0, 0)
|
||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||
return start
|
||
}
|
||
|
||
function shiftWeek(weeks: number) {
|
||
const next = new Date(weekStart.value)
|
||
next.setDate(next.getDate() + weeks * 7)
|
||
weekStart.value = next
|
||
}
|
||
|
||
const days = computed(() =>
|
||
Array.from({ length: 7 }, (_, i) => {
|
||
const day = new Date(weekStart.value)
|
||
day.setDate(day.getDate() + i)
|
||
return day
|
||
}),
|
||
)
|
||
|
||
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
|
||
const dayFormatter = computed(
|
||
() =>
|
||
new Intl.DateTimeFormat(intlLocale.value, {
|
||
weekday: 'short',
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
}),
|
||
)
|
||
const rangeFormatter = computed(
|
||
() =>
|
||
new Intl.DateTimeFormat(intlLocale.value, {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
}),
|
||
)
|
||
|
||
const range = computed(() => {
|
||
const end = new Date(weekStart.value)
|
||
end.setDate(end.getDate() + 6)
|
||
return `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(end)}`
|
||
})
|
||
|
||
const hours = computed(() =>
|
||
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
|
||
)
|
||
|
||
const lanes = computed(() =>
|
||
selectedBike.value === 'all'
|
||
? props.bikes
|
||
: props.bikes.filter((bike) => String(bike.id) === selectedBike.value),
|
||
)
|
||
|
||
/** Only what actually holds a bike ends up on the planning */
|
||
const booked = computed(() =>
|
||
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
||
)
|
||
|
||
const today = new Date()
|
||
function isToday(day: Date) {
|
||
return day.toDateString() === today.toDateString()
|
||
}
|
||
|
||
type Block = { id: number; top: number; height: number; label: string }
|
||
|
||
function blocksFor(day: Date, bike: Bike): Block[] {
|
||
const dayStart = new Date(day)
|
||
dayStart.setHours(FIRST_HOUR, 0, 0, 0)
|
||
const dayEnd = new Date(day)
|
||
dayEnd.setHours(LAST_HOUR, 0, 0, 0)
|
||
|
||
return booked.value
|
||
.filter((reservation) => reservation.bikes.includes(bike.id))
|
||
.flatMap((reservation) => {
|
||
const start = new Date(reservation.start_time)
|
||
const end = new Date(reservation.end_time)
|
||
const from = start > dayStart ? start : dayStart
|
||
const to = end < dayEnd ? end : dayEnd
|
||
if (to <= from) return []
|
||
|
||
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
|
||
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
|
||
return [{ id: reservation.id, top, height, label: unitLabel(reservation.unit) }]
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Card>
|
||
<CardHeader class="gap-3">
|
||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle>{{ $t('admin.calendar.title') }}</CardTitle>
|
||
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-muted-foreground text-sm">{{ $t('admin.calendar.bike') }}</span>
|
||
<Select v-model="selectedBike">
|
||
<SelectTrigger class="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">{{ $t('admin.calendar.all-bikes') }}</SelectItem>
|
||
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
|
||
{{ bike.name }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
|
||
<ChevronLeft class="size-4" />
|
||
{{ $t('admin.calendar.previous') }}
|
||
</Button>
|
||
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
|
||
{{ $t('admin.calendar.today') }}
|
||
</Button>
|
||
<Button variant="outline" size="sm" @click="shiftWeek(1)">
|
||
{{ $t('admin.calendar.next') }}
|
||
<ChevronRight class="size-4" />
|
||
</Button>
|
||
<span class="text-muted-foreground text-sm">{{ range }}</span>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent>
|
||
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
|
||
{{ $t('admin.calendar.no-bike') }}
|
||
</p>
|
||
|
||
<!-- The grid is wide: it scrolls on its own rather than the page -->
|
||
<div v-else class="overflow-x-auto">
|
||
<div class="min-w-[52rem]">
|
||
<div class="flex border-b">
|
||
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
|
||
{{ $t('admin.calendar.hour') }}
|
||
</div>
|
||
<div
|
||
v-for="day in days"
|
||
:key="day.toISOString()"
|
||
class="min-w-0 flex-1 border-l px-1 py-2"
|
||
:class="isToday(day) ? 'bg-primary/5' : ''"
|
||
>
|
||
<div class="truncate text-sm font-medium" :class="isToday(day) ? 'text-primary' : ''">
|
||
{{ dayFormatter.format(day) }}
|
||
</div>
|
||
<div class="text-muted-foreground flex gap-px text-[0.6rem]">
|
||
<span v-for="bike in lanes" :key="bike.id" class="flex-1 truncate text-center">
|
||
{{ bike.name }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex">
|
||
<!-- Hour labels -->
|
||
<div class="w-12 shrink-0">
|
||
<div
|
||
v-for="hour in hours"
|
||
:key="hour"
|
||
class="text-muted-foreground border-b text-xs"
|
||
:style="{ height: `${HOUR_HEIGHT}px` }"
|
||
>
|
||
{{ String(hour).padStart(2, '0') }}:00
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-for="day in days"
|
||
:key="day.toISOString()"
|
||
class="relative min-w-0 flex-1 border-l"
|
||
:class="isToday(day) ? 'bg-primary/5' : ''"
|
||
>
|
||
<!-- Hour lines, behind the blocks -->
|
||
<div
|
||
v-for="hour in hours"
|
||
:key="hour"
|
||
class="border-b"
|
||
:style="{ height: `${HOUR_HEIGHT}px` }"
|
||
/>
|
||
|
||
<!-- One lane per bike, blocks positioned inside -->
|
||
<div class="absolute inset-0 flex gap-px px-px">
|
||
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
|
||
<div
|
||
v-for="block in blocksFor(day, bike)"
|
||
:key="`${block.id}-${bike.id}`"
|
||
class="bg-primary text-primary-foreground absolute inset-x-0 overflow-hidden rounded-sm px-0.5 text-[0.6rem] leading-tight"
|
||
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||
:title="`#${block.id} — ${block.label}`"
|
||
>
|
||
{{ block.label }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</template>
|