304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import { ChevronLeft, ChevronRight, Dumbbell, Plus, Users } from 'lucide-react';
|
|
import { useBooking } from '../booking-provider';
|
|
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
|
|
import { timeToMinutes } from '../lib/booking-time';
|
|
import { BookingStatusLegend } from './booking-status-legend';
|
|
|
|
const courtInfoWidth = 220;
|
|
const hourWidth = 104;
|
|
|
|
export function BookingTimeline() {
|
|
const {
|
|
schedules,
|
|
timelineHours,
|
|
visibleTimeRange,
|
|
currentTime,
|
|
isLoading,
|
|
isError,
|
|
errorMessage,
|
|
} = useBooking();
|
|
|
|
const rangeStart = timeToMinutes(visibleTimeRange.start);
|
|
const rangeEnd = timeToMinutes(visibleTimeRange.end);
|
|
const totalMinutes = rangeEnd - rangeStart;
|
|
const gridWidth = Math.max((timelineHours.length - 1) * hourWidth, 760);
|
|
const timelineMarks = timelineHours.map((hour) => ({
|
|
hour,
|
|
left: ((timeToMinutes(hour) - rangeStart) / totalMinutes) * 100,
|
|
}));
|
|
|
|
return (
|
|
<section className="overflow-hidden rounded-lg border bg-card/85 shadow-sm">
|
|
<div className="flex items-center justify-between gap-4 border-b px-4 py-4">
|
|
<BookingStatusLegend />
|
|
<div className="hidden items-center gap-2 md:flex">
|
|
<Button type="button" size="icon-sm" variant="ghost">
|
|
<ChevronLeft className="size-4" />
|
|
</Button>
|
|
<Button type="button" size="icon-sm" variant="ghost">
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
|
|
)}
|
|
|
|
{isError && !isLoading && (
|
|
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && schedules.length === 0 && (
|
|
<div className="px-4 py-10 text-sm text-muted-foreground">
|
|
No hay canchas para los filtros seleccionados.
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && schedules.length > 0 && (
|
|
<div className="booking-scrollbar overflow-x-auto overflow-y-hidden">
|
|
<div className="relative min-w-full" style={{ width: courtInfoWidth + gridWidth }}>
|
|
<TimelineHeader
|
|
timelineMarks={timelineMarks}
|
|
courtInfoWidth={courtInfoWidth}
|
|
gridWidth={gridWidth}
|
|
/>
|
|
|
|
<div className="divide-y">
|
|
{schedules.map((schedule) => (
|
|
<BookingCourtRow
|
|
key={schedule.court.id}
|
|
schedule={schedule}
|
|
courtInfoWidth={courtInfoWidth}
|
|
gridWidth={gridWidth}
|
|
rangeStart={rangeStart}
|
|
totalMinutes={totalMinutes}
|
|
timelineMarks={timelineMarks}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{currentTime && (
|
|
<CurrentTimeIndicator
|
|
time={currentTime}
|
|
courtInfoWidth={courtInfoWidth}
|
|
gridWidth={gridWidth}
|
|
rangeStart={rangeStart}
|
|
totalMinutes={totalMinutes}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface TimelineHeaderProps {
|
|
timelineMarks: Array<{ hour: string; left: number }>;
|
|
courtInfoWidth: number;
|
|
gridWidth: number;
|
|
}
|
|
|
|
function TimelineHeader({ timelineMarks, courtInfoWidth, gridWidth }: TimelineHeaderProps) {
|
|
return (
|
|
<div
|
|
className="sticky top-0 z-20 grid border-b bg-card/95 z-auto"
|
|
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
|
>
|
|
<div className="sticky left-0 z-30 border-r bg-card/95 px-4 py-3 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]" />
|
|
<div className="relative h-12">
|
|
{timelineMarks.map((mark) => (
|
|
<div
|
|
key={mark.hour}
|
|
className="absolute top-0 flex h-full items-center border-l px-3 text-sm text-muted-foreground first:border-l-0"
|
|
style={{ left: `${mark.left}%` }}
|
|
>
|
|
{mark.hour}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BookingCourtRowProps {
|
|
schedule: BookingCourtSchedule;
|
|
courtInfoWidth: number;
|
|
gridWidth: number;
|
|
rangeStart: number;
|
|
totalMinutes: number;
|
|
timelineMarks: Array<{ hour: string; left: number }>;
|
|
}
|
|
|
|
function BookingCourtRow({
|
|
schedule,
|
|
courtInfoWidth,
|
|
gridWidth,
|
|
rangeStart,
|
|
totalMinutes,
|
|
timelineMarks,
|
|
}: BookingCourtRowProps) {
|
|
return (
|
|
<div
|
|
className="grid min-h-[96px]"
|
|
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
|
>
|
|
<BookingCourtInfo schedule={schedule} />
|
|
<div className="relative bg-background/20 py-4">
|
|
<div className="absolute inset-y-0 left-0 right-0 opacity-70">
|
|
{timelineMarks.map((mark) => (
|
|
<span
|
|
key={mark.hour}
|
|
className="absolute top-0 h-full border-l first:border-l-0"
|
|
style={{ left: `${mark.left}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="relative h-16">
|
|
{schedule.segments.map((segment) => (
|
|
<BookingSlotBlock
|
|
key={segment.id}
|
|
segment={segment}
|
|
schedule={schedule}
|
|
rangeStart={rangeStart}
|
|
totalMinutes={totalMinutes}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
|
return (
|
|
<div className="sticky left-0 z-10 flex items-center gap-3 border-r bg-card/95 px-4 py-4 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]">
|
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-primary/40 bg-primary/15 text-primary">
|
|
<Dumbbell className="size-6" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-base font-semibold">{schedule.court.name}</h2>
|
|
<p className="truncate text-sm text-muted-foreground">{schedule.court.sport.name}</p>
|
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="size-2 rounded-full bg-primary" />
|
|
{schedule.metrics.free}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="size-2 rounded-full bg-reserved" />
|
|
{schedule.metrics.reserved}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="size-2 rounded-full bg-maintenance" />
|
|
{schedule.metrics.maintenance}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BookingSlotBlockProps {
|
|
segment: BookingTimelineSegment;
|
|
schedule: BookingCourtSchedule;
|
|
rangeStart: number;
|
|
totalMinutes: number;
|
|
}
|
|
|
|
function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) {
|
|
const { openCreateBooking, updateBookingStatus } = useBooking();
|
|
const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100;
|
|
const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100;
|
|
const isFree = segment.status === 'free';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'absolute top-0 flex h-16 min-w-0 flex-col justify-center overflow-hidden rounded-md border px-2 text-left text-xs shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring',
|
|
segment.status === 'free' &&
|
|
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
|
segment.status === 'reserved' &&
|
|
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
|
segment.status === 'maintenance' &&
|
|
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground'
|
|
)}
|
|
style={{
|
|
left: `calc(${left}% + 4px)`,
|
|
width: `calc(${width}% - 8px)`,
|
|
}}
|
|
onClick={() => {
|
|
if (isFree) {
|
|
openCreateBooking({
|
|
courtId: schedule.court.id,
|
|
startTime: segment.startTime,
|
|
sportId: schedule.court.sportId,
|
|
});
|
|
} else {
|
|
//console.log(JSON.stringify(segment, null, 2));
|
|
setPopoverOpen(true);
|
|
}
|
|
}}
|
|
onDoubleClick={() => {
|
|
if (segment.booking?.status === 'CONFIRMED') {
|
|
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
|
}
|
|
}}
|
|
title={
|
|
segment.booking
|
|
? `${segment.booking.customerName} · ${segment.startTime} - ${segment.endTime}`
|
|
: `${segment.startTime} - ${segment.endTime}`
|
|
}
|
|
>
|
|
{isFree ? (
|
|
<span className="flex items-center justify-center gap-1 text-sm font-medium">
|
|
<Plus className="size-4" />
|
|
<span className="hidden sm:inline">Reservar</span>
|
|
</span>
|
|
) : (
|
|
<>
|
|
<span className="truncate text-sm font-medium">
|
|
{segment.startTime} - {segment.endTime}
|
|
</span>
|
|
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
|
<Users className="size-3" />
|
|
{segment.booking?.customerName ?? 'Mantenimiento'}
|
|
</span>
|
|
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface CurrentTimeIndicatorProps {
|
|
time: string;
|
|
courtInfoWidth: number;
|
|
gridWidth: number;
|
|
rangeStart: number;
|
|
totalMinutes: number;
|
|
}
|
|
|
|
function CurrentTimeIndicator({
|
|
time,
|
|
courtInfoWidth,
|
|
gridWidth,
|
|
rangeStart,
|
|
totalMinutes,
|
|
}: CurrentTimeIndicatorProps) {
|
|
const left = courtInfoWidth + ((timeToMinutes(time) - rangeStart) / totalMinutes) * gridWidth;
|
|
|
|
return (
|
|
<div className="pointer-events-none absolute bottom-0 top-12 z-[5]" style={{ left }}>
|
|
<div className="absolute left-0 top-0 -translate-x-1/2 -translate-y-1/2 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-sm">
|
|
{time}
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 top-0 w-px bg-primary" />
|
|
</div>
|
|
);
|
|
}
|