cargagep-v2/frontend/src/components/admin/BikeCalendar.vue
2026-08-24 11:09:59 +02:00

236 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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