New booking panel

This commit is contained in:
Jose Selesan
2026-05-05 10:16:38 -03:00
parent 546a020003
commit c089215835
19 changed files with 1991 additions and 1072 deletions

View File

@@ -0,0 +1,472 @@
import { ApiClientError, apiClient } from '@/lib/api-client';
import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type {
BookingCourtSchedule,
BookingSlotDraft,
BookingStatusFilter,
BookingSummary,
BookingTimeRange,
BookingTimelineSegment,
BookingViewMode,
} from './booking.types';
import {
addDaysIso,
formatBookingDate,
getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime,
timeToMinutes,
toIsoDateLocal,
} from './lib/booking-time';
interface BookingProviderProps {
children: ReactNode;
complex: ComplexWithRole;
}
interface CreateManualBookingPayload {
courtId: string;
date: string;
startTime: string;
customerName: string;
customerPhone: string;
}
interface BookingContextValue {
complex: ComplexWithRole;
courts: Court[];
bookings: AdminBooking[];
schedules: BookingCourtSchedule[];
selectedDate: string;
selectedSportId: string;
selectedStatus: BookingStatusFilter;
viewMode: BookingViewMode;
visibleTimeRange: BookingTimeRange;
timelineHours: string[];
summary: BookingSummary;
currentTime: string | null;
selectedSlot: BookingSlotDraft | null;
isLoading: boolean;
isError: boolean;
errorMessage: string | null;
isCreateBookingOpen: boolean;
createBookingError: string | null;
isCreatingBooking: boolean;
setSelectedDate: (date: string) => void;
moveSelectedDate: (amount: number) => void;
setSelectedSportId: (sportId: string) => void;
setSelectedStatus: (status: BookingStatusFilter) => void;
setViewMode: (mode: BookingViewMode) => void;
setVisibleTimeRange: (range: BookingTimeRange) => void;
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
closeCreateBooking: () => void;
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED') => void;
exportDayReport: () => void;
}
const BookingContext = createContext<BookingContextValue | null>(null);
const DEFAULT_TIME_RANGE: BookingTimeRange = {
start: '08:00',
end: '22:00',
};
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message || fallback;
}
return fallback;
}
function makeTimelineHours(range: BookingTimeRange) {
const start = timeToMinutes(range.start);
const end = timeToMinutes(range.end);
const hours: string[] = [];
for (let minute = start; minute <= end; minute += 60) {
hours.push(minutesToTime(minute));
}
return hours;
}
function isActiveBooking(booking: AdminBooking) {
return booking.status === 'CONFIRMED' || booking.status === 'COMPLETED';
}
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
return bookings
.filter(
(booking) =>
booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking)
)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
}
function overlapsBooking(start: number, end: number, booking: AdminBooking) {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
return start < bookingEnd && end > bookingStart;
}
function buildSegmentsForCourt(
court: Court,
bookings: AdminBooking[],
selectedDate: string,
visibleRange: BookingTimeRange
) {
const dayOfWeek = getDayOfWeek(selectedDate);
const rangeStart = timeToMinutes(visibleRange.start);
const rangeEnd = timeToMinutes(visibleRange.end);
const courtBookings = getCourtBookings(court, bookings, selectedDate);
const segments: BookingTimelineSegment[] = [];
const slotDuration = court.slotDurationMinutes;
const availabilityRanges = court.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.map((range) => ({
start: Math.max(timeToMinutes(range.startTime), rangeStart),
end: Math.min(timeToMinutes(range.endTime), rangeEnd),
}))
.filter((range) => range.start < range.end)
.sort((a, b) => a.start - b.start);
for (const booking of courtBookings) {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
if (bookingEnd > rangeStart && bookingStart < rangeEnd) {
segments.push({
id: booking.id,
courtId: court.id,
status: 'reserved',
startTime: booking.startTime,
endTime: booking.endTime,
startMinutes: Math.max(bookingStart, rangeStart),
endMinutes: Math.min(bookingEnd, rangeEnd),
booking,
});
}
}
for (const availability of availabilityRanges) {
for (
let slotStart = availability.start;
slotStart + slotDuration <= availability.end;
slotStart += slotDuration
) {
const slotEnd = slotStart + slotDuration;
const isBooked = courtBookings.some((booking) =>
overlapsBooking(slotStart, slotEnd, booking)
);
if (isBooked) continue;
const slotStartTime = minutesToTime(slotStart);
const slotEndTime = minutesToTime(slotEnd);
segments.push({
id: `${court.id}-free-${slotStartTime}-${slotEndTime}`,
courtId: court.id,
status: 'free',
startTime: slotStartTime,
endTime: slotEndTime,
startMinutes: slotStart,
endMinutes: slotEnd,
});
}
}
segments.sort((a, b) => a.startMinutes - b.startMinutes);
return segments;
}
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
const maintenanceSlots = schedules.reduce(
(total, schedule) => total + schedule.metrics.maintenance,
0
);
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
return {
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
freeSlots,
reservedSlots,
maintenanceSlots,
freePercent: Math.round((freeSlots / totalSegments) * 100),
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
};
}
export function BookingProvider({ children, complex }: BookingProviderProps) {
const queryClient = useQueryClient();
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
const [selectedDate, setSelectedDate] = useState(todayIso);
const [selectedSportId, setSelectedSportId] = useState('all');
const [selectedStatus, setSelectedStatus] = useState<BookingStatusFilter>('all');
const [viewMode, setViewMode] = useState<BookingViewMode>('panel');
const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE);
const [selectedSlot, setSelectedSlot] = useState<BookingSlotDraft | null>(null);
const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false);
const courtsQuery = useQuery({
queryKey: ['courts', complex.id],
queryFn: () => apiClient.courts.listByComplex(complex.id),
});
const bookingsQuery = useQuery({
queryKey: ['admin-bookings', complex.id, selectedDate],
queryFn: () =>
apiClient.adminBookings.listByComplex(complex.id, {
fromDate: selectedDate,
}),
});
const bookings = useMemo(
() => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate),
[bookingsQuery.data?.bookings, selectedDate]
);
const courts = useMemo(() => courtsQuery.data ?? [], [courtsQuery.data]);
const filteredCourts = useMemo(() => {
if (selectedSportId === 'all') return courts;
return courts.filter((court) => court.sportId === selectedSportId);
}, [courts, selectedSportId]);
const schedules = useMemo<BookingCourtSchedule[]>(() => {
return filteredCourts
.map((court) => {
const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange);
const metrics = {
free: segments.filter((segment) => segment.status === 'free').length,
reserved: segments.filter((segment) => segment.status === 'reserved').length,
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
};
return { court, segments, metrics };
})
.filter((schedule) => {
if (selectedStatus === 'all') return true;
return schedule.segments.some((segment) => segment.status === selectedStatus);
});
}, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]);
const summary = useMemo(() => getSummary(schedules), [schedules]);
const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]);
const currentTime = useMemo(() => {
if (!isTodayIso(selectedDate)) return null;
const now = getNowTime();
const nowMinutes = timeToMinutes(now);
const start = timeToMinutes(visibleTimeRange.start);
const end = timeToMinutes(visibleTimeRange.end);
if (nowMinutes < start || nowMinutes > end) return null;
return now;
}, [selectedDate, visibleTimeRange]);
const createBookingMutation = useMutation({
mutationFn: (payload: CreateManualBookingPayload) =>
apiClient.adminBookings.create(complex.id, {
date: payload.date,
courtId: payload.courtId,
startTime: payload.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] });
setIsCreateBookingOpen(false);
setSelectedSlot(null);
},
});
const updateStatusMutation = useMutation({
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
},
});
const openCreateBooking = useCallback(
(draft?: Partial<BookingSlotDraft>) => {
setSelectedSlot({
date: draft?.date ?? selectedDate,
courtId: draft?.courtId,
startTime: draft?.startTime,
sportId: draft?.sportId,
});
setIsCreateBookingOpen(true);
},
[selectedDate]
);
useEffect(() => {
const handleOpenCreateBooking = () => {
openCreateBooking();
};
window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking);
return () => {
window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking);
};
}, [openCreateBooking]);
const closeCreateBooking = useCallback(() => {
setIsCreateBookingOpen(false);
setSelectedSlot(null);
createBookingMutation.reset();
}, [createBookingMutation]);
const createBooking = useCallback(
async (payload: CreateManualBookingPayload) => {
await createBookingMutation.mutateAsync(payload);
},
[createBookingMutation]
);
const updateBookingStatus = useCallback(
(bookingId: string, status: 'CANCELLED' | 'COMPLETED') => {
updateStatusMutation.mutate({ bookingId, status });
},
[updateStatusMutation]
);
const exportDayReport = useCallback(() => {
const rows = [
['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'],
...bookings.map((booking) => [
booking.date,
booking.courtName,
booking.sport.name,
booking.startTime,
booking.endTime,
booking.customerName,
booking.customerPhone,
booking.status,
]),
];
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`;
link.click();
URL.revokeObjectURL(url);
}, [bookings, complex.complexSlug, selectedDate]);
const value = useMemo<BookingContextValue>(
() => ({
complex,
courts,
bookings,
schedules,
selectedDate,
selectedSportId,
selectedStatus,
viewMode,
visibleTimeRange,
timelineHours,
summary,
currentTime,
selectedSlot,
isLoading: courtsQuery.isLoading || bookingsQuery.isLoading,
isError: courtsQuery.isError || bookingsQuery.isError,
errorMessage:
courtsQuery.isError || bookingsQuery.isError
? extractMessage(
courtsQuery.error ?? bookingsQuery.error,
'No pudimos cargar el panel de reservas.'
)
: null,
isCreateBookingOpen,
createBookingError: createBookingMutation.isError
? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.')
: null,
isCreatingBooking: createBookingMutation.isPending,
setSelectedDate,
moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)),
setSelectedSportId,
setSelectedStatus,
setViewMode,
setVisibleTimeRange,
openCreateBooking,
closeCreateBooking,
createBooking,
updateBookingStatus,
exportDayReport,
}),
[
bookings,
bookingsQuery.error,
bookingsQuery.isError,
bookingsQuery.isLoading,
closeCreateBooking,
complex,
courts,
courtsQuery.error,
courtsQuery.isError,
courtsQuery.isLoading,
createBooking,
createBookingMutation.error,
createBookingMutation.isError,
createBookingMutation.isPending,
currentTime,
exportDayReport,
isCreateBookingOpen,
openCreateBooking,
schedules,
selectedDate,
selectedSlot,
selectedSportId,
selectedStatus,
summary,
timelineHours,
updateBookingStatus,
viewMode,
visibleTimeRange,
]
);
return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;
}
export function useBooking() {
const context = useContext(BookingContext);
if (!context) {
throw new Error('useBooking must be used within BookingProvider');
}
return context;
}
export { formatBookingDate };

View File

@@ -0,0 +1,28 @@
import type { ComplexWithRole } from '@repo/api-contract';
import { BookingProvider } from './booking-provider';
import { BookingCreateDialog } from './components/booking-create-dialog';
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
import { BookingHeader } from './components/booking-header';
import { BookingTimeline } from './components/booking-timeline';
import { BookingToolbar } from './components/booking-toolbar';
interface BookingProps {
complex: ComplexWithRole;
}
export function Booking({ complex }: BookingProps) {
return (
<BookingProvider complex={complex}>
<div className="flex flex-col gap-5">
<BookingHeader />
<BookingToolbar />
<BookingTimeline />
<div className="grid gap-5 xl:grid-cols-4">
<BookingDaySummary />
<BookingQuickActions />
</div>
</div>
<BookingCreateDialog />
</BookingProvider>
);
}

View File

@@ -0,0 +1,50 @@
import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status';
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
export interface BookingTimeRange {
start: string;
end: string;
}
export interface BookingSummary {
totalReservations: number;
freeSlots: number;
reservedSlots: number;
maintenanceSlots: number;
freePercent: number;
reservedPercent: number;
maintenancePercent: number;
}
export interface BookingCourtMetrics {
free: number;
reserved: number;
maintenance: number;
}
export interface BookingTimelineSegment {
id: string;
courtId: string;
status: BookingSegmentStatus;
startTime: string;
endTime: string;
startMinutes: number;
endMinutes: number;
booking?: AdminBooking;
}
export interface BookingCourtSchedule {
court: Court;
segments: BookingTimelineSegment[];
metrics: BookingCourtMetrics;
}
export interface BookingSlotDraft {
courtId?: string;
date: string;
startTime?: string;
sportId?: string;
}

View File

@@ -0,0 +1,285 @@
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useBooking } from '../booking-provider';
import {
fromIsoDateLocal,
getDayOfWeek,
minutesToTime,
timeToMinutes,
toIsoDateLocal,
} from '../lib/booking-time';
const bookingFormSchema = z.object({
customerName: z
.string()
.trim()
.min(2, 'Ingresa un nombre válido.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
});
type BookingForm = z.infer<typeof bookingFormSchema>;
export function BookingCreateDialog() {
const {
courts,
bookings,
selectedDate,
selectedSlot,
isCreateBookingOpen,
createBookingError,
isCreatingBooking,
closeCreateBooking,
createBooking,
} = useBooking();
const [date, setDate] = useState(selectedSlot?.date ?? selectedDate);
const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all');
const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? '');
const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? '');
const {
register,
handleSubmit,
reset,
formState: { errors, isValid },
} = useForm<BookingForm>({
resolver: zodResolver(bookingFormSchema),
mode: 'onChange',
defaultValues: {
customerName: '',
customerPhone: '',
},
});
useEffect(() => {
if (!isCreateBookingOpen) return;
setDate(selectedSlot?.date ?? selectedDate);
setSportId(selectedSlot?.sportId ?? 'all');
setCourtId(selectedSlot?.courtId ?? '');
setStartTime(selectedSlot?.startTime ?? '');
reset();
}, [isCreateBookingOpen, reset, selectedDate, selectedSlot]);
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
const filteredCourts = useMemo(() => {
if (sportId === 'all') return courts;
return courts.filter((court) => court.sportId === sportId);
}, [courts, sportId]);
const selectedCourt = courts.find((court) => court.id === courtId);
const availableStartTimes = useMemo(() => {
if (!selectedCourt) return [];
const times: string[] = [];
const dayOfWeek = getDayOfWeek(date);
const dayAvailability = selectedCourt.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
const courtBookings = bookings.filter(
(booking) =>
booking.date === date &&
booking.courtId === selectedCourt.id &&
booking.status !== 'CANCELLED'
);
for (const range of dayAvailability) {
const start = timeToMinutes(range.startTime);
const end = timeToMinutes(range.endTime);
for (
let minute = start;
minute + selectedCourt.slotDurationMinutes <= end;
minute += selectedCourt.slotDurationMinutes
) {
const slotEnd = minute + selectedCourt.slotDurationMinutes;
const isBooked = courtBookings.some((booking) => {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
return minute < bookingEnd && slotEnd > bookingStart;
});
if (!isBooked) {
times.push(minutesToTime(minute));
}
}
}
return times;
}, [bookings, date, selectedCourt]);
useEffect(() => {
if (startTime && !availableStartTimes.includes(startTime)) {
setStartTime('');
}
}, [availableStartTimes, startTime]);
const onSubmit = async (values: BookingForm) => {
await createBooking({
courtId,
date,
startTime,
customerName: values.customerName,
customerPhone: values.customerPhone,
});
};
return (
<ResponsiveDialog
open={isCreateBookingOpen}
onOpenChange={(open) => !open && closeCreateBooking()}
>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Crea un turno para atención telefónica o mostrador.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-3 md:grid-cols-2">
<Field>
<FieldLabel>Fecha</FieldLabel>
<DatePicker
value={fromIsoDateLocal(date)}
onChange={(nextDate) => {
setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate);
}}
/>
</Field>
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={sportId}
onValueChange={(value) => {
setSportId(value);
setCourtId('');
setStartTime('');
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Cancha</FieldLabel>
<Select
value={courtId}
onValueChange={(value) => {
setCourtId(value);
setStartTime('');
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una cancha" />
</SelectTrigger>
<SelectContent>
{filteredCourts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name} · {court.sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Horario</FieldLabel>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un horario" />
</SelectTrigger>
<SelectContent>
{availableStartTimes.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
</form>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>
<Button
type="submit"
form="booking-create-form"
disabled={!isValid || isCreatingBooking || !courtId || !startTime}
>
{isCreatingBooking ? 'Guardando...' : 'Crear reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,118 @@
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
export function BookingDaySummary() {
const { selectedDate, summary } = useBooking();
return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm xl:col-span-3">
<div className="mb-4">
<h2 className="text-base font-semibold">Resumen del día</h2>
<p className="mt-1 text-sm capitalize text-muted-foreground">
{formatBookingDate(selectedDate, { year: 'numeric' })}
</p>
</div>
<div className="grid gap-3 md:grid-cols-4">
<SummaryCard
label="Total de bloques"
value={summary.totalReservations}
icon={CalendarDays}
className="border-border bg-background/45"
/>
<SummaryCard
label="Libres"
value={summary.freeSlots}
detail={`${summary.freePercent}% del total`}
icon={UsersRound}
className="border-primary/30 bg-primary/10 text-primary"
/>
<SummaryCard
label="Reservados"
value={summary.reservedSlots}
detail={`${summary.reservedPercent}% del total`}
icon={ShieldCheck}
className="border-reserved/35 bg-reserved/10 text-reserved"
/>
<SummaryCard
label="Mantenimiento"
value={summary.maintenanceSlots}
detail={`${summary.maintenancePercent}% del total`}
icon={Drill}
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
/>
</div>
</section>
);
}
interface SummaryCardProps {
label: string;
value: number;
detail?: string;
icon: typeof CalendarDays;
className: string;
}
function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) {
return (
<article className={`rounded-lg border p-4 ${className}`}>
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-transparent">
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold leading-tight">{value}</p>
{detail && <p className="mt-1 truncate text-xs text-muted-foreground">{detail}</p>}
</div>
</div>
</article>
);
}
export function BookingQuickActions() {
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
useBooking();
const todayIso = toIsoDateLocal(new Date());
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
const isViewingMaintenance = selectedStatus === 'maintenance';
return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
<h2 className="text-base font-semibold">Acciones rápidas</h2>
<div className="mt-3 grid gap-2">
<button
type="button"
onClick={() => {
setSelectedDate(todayIso);
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
}}
aria-pressed={isViewingTodayReservations}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
>
<CalendarDays className="size-4 text-muted-foreground" />
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
</button>
<button
type="button"
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
aria-pressed={isViewingMaintenance}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
>
<Drill className="size-4 text-maintenance" />
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
</button>
<button
type="button"
onClick={exportDayReport}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
>
<Download className="size-4 text-primary" />
Exportar reporte
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,18 @@
import { useBooking } from '../booking-provider';
export function BookingHeader() {
const { complex } = useBooking();
return (
<section>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Panel de Reservas
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Gestiona las canchas de {complex.complexName}
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,18 @@
const items = [
{ label: 'Libre', className: 'bg-primary' },
{ label: 'Reservado', className: 'bg-reserved' },
{ label: 'Mantenimiento', className: 'bg-maintenance' },
];
export function BookingStatusLegend() {
return (
<div className="flex flex-wrap items-center gap-5 text-sm text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-2">
<span className={`size-3 rounded-full ${item.className}`} />
{item.label}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,299 @@
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"
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,
});
}
}}
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-20" 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>
);
}

View File

@@ -0,0 +1,144 @@
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { Field, FieldLabel } from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
import { useBooking } from '../booking-provider';
import type { BookingStatusFilter } from '../booking.types';
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
const timeRangeOptions = [
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
{ label: '06:00 - 00:00', start: '06:00', end: '23:59' },
{ label: '08:00 - 14:00', start: '08:00', end: '14:00' },
{ label: '14:00 - 22:00', start: '14:00', end: '22:00' },
];
const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'free', label: 'Libre' },
{ value: 'reserved', label: 'Reservado' },
{ value: 'maintenance', label: 'Mantenimiento' },
];
export function BookingToolbar() {
const {
courts,
selectedDate,
selectedSportId,
selectedStatus,
visibleTimeRange,
setSelectedDate,
moveSelectedDate,
setSelectedSportId,
setSelectedStatus,
setVisibleTimeRange,
} = useBooking();
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`;
return (
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Estado</FieldLabel>
<Select
value={selectedStatus}
onValueChange={(value) => setSelectedStatus(value as BookingStatusFilter)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Fecha</FieldLabel>
<div className="grid grid-cols-[auto_1fr_auto] gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => moveSelectedDate(-1)}
>
<ChevronLeft className="size-4" />
</Button>
<DatePicker
value={fromIsoDateLocal(selectedDate)}
onChange={(date) =>
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
}
/>
<Button type="button" variant="outline" size="icon" onClick={() => moveSelectedDate(1)}>
<ChevronRight className="size-4" />
</Button>
</div>
</Field>
<Field>
<FieldLabel>Hora</FieldLabel>
<Select
value={activeRangeValue}
onValueChange={(value) => {
const option = timeRangeOptions.find((item) => `${item.start}-${item.end}` === value);
if (option) {
setVisibleTimeRange({ start: option.start, end: option.end });
}
}}
>
<SelectTrigger>
<Clock className="mr-2 size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeRangeOptions.map((option) => (
<SelectItem
key={`${option.start}-${option.end}`}
value={`${option.start}-${option.end}`}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Button variant="outline" className="xl:self-end">
<SlidersHorizontal className="size-4" />
Filtros avanzados
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,66 @@
import type { DayOfWeek } from '@repo/api-contract';
export function toIsoDateLocal(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function fromIsoDateLocal(dateIso: string): Date | undefined {
const [year, month, day] = dateIso.split('-').map(Number);
if (!year || !month || !day) return undefined;
return new Date(year, month - 1, day);
}
export function timeToMinutes(time: string) {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
export function minutesToTime(minutes: number) {
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
}
export function addDaysIso(dateIso: string, amount: number) {
const date = fromIsoDateLocal(dateIso) ?? new Date();
date.setDate(date.getDate() + amount);
return toIsoDateLocal(date);
}
export function isTodayIso(dateIso: string) {
return dateIso === toIsoDateLocal(new Date());
}
export function formatBookingDate(dateIso: string, options?: Intl.DateTimeFormatOptions) {
const date = fromIsoDateLocal(dateIso);
if (!date) return dateIso;
return new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: options?.year,
...options,
}).format(date);
}
export function getDayOfWeek(dateIso: string): DayOfWeek {
const date = fromIsoDateLocal(dateIso) ?? new Date();
const day = date.getDay();
if (day === 0) return 'SUNDAY';
if (day === 1) return 'MONDAY';
if (day === 2) return 'TUESDAY';
if (day === 3) return 'WEDNESDAY';
if (day === 4) return 'THURSDAY';
if (day === 5) return 'FRIDAY';
return 'SATURDAY';
}
export function getNowTime() {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}