From cdf6e8ebe6ffca9133915a4ca1649cca126b69e2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 06:50:22 +0000 Subject: [PATCH] feat(bookings): add calendar UI with booking creation - Add TimeSlot component with available/booked visual states - Add BookingCalendar component with date navigation and court columns - Add BookingDialog for creating bookings and viewing/cancelling existing ones - Add bookings page with calendar integration - Fix sidebar navigation paths for route group structure Co-Authored-By: Claude Opus 4.5 --- apps/web/app/(admin)/bookings/page.tsx | 52 ++ .../components/bookings/booking-calendar.tsx | 386 +++++++++++++ .../components/bookings/booking-dialog.tsx | 506 ++++++++++++++++++ apps/web/components/bookings/index.ts | 3 + apps/web/components/bookings/time-slot.tsx | 74 +++ apps/web/components/layout/sidebar.tsx | 16 +- 6 files changed, 1029 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/(admin)/bookings/page.tsx create mode 100644 apps/web/components/bookings/booking-calendar.tsx create mode 100644 apps/web/components/bookings/booking-dialog.tsx create mode 100644 apps/web/components/bookings/index.ts create mode 100644 apps/web/components/bookings/time-slot.tsx diff --git a/apps/web/app/(admin)/bookings/page.tsx b/apps/web/app/(admin)/bookings/page.tsx new file mode 100644 index 0000000..3dd386c --- /dev/null +++ b/apps/web/app/(admin)/bookings/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { BookingCalendar, SelectedSlot } from "@/components/bookings/booking-calendar"; +import { BookingDialog } from "@/components/bookings/booking-dialog"; + +export default function BookingsPage() { + const [selectedSlot, setSelectedSlot] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + const handleSlotClick = useCallback((slot: SelectedSlot) => { + setSelectedSlot(slot); + }, []); + + const handleCloseDialog = useCallback(() => { + setSelectedSlot(null); + }, []); + + const handleBookingCreated = useCallback(() => { + // Refresh the calendar after booking creation + setRefreshKey((prev) => prev + 1); + }, []); + + const handleBookingCancelled = useCallback(() => { + // Refresh the calendar after booking cancellation + setRefreshKey((prev) => prev + 1); + }, []); + + return ( +
+
+

Reservas

+

+ Gestiona las reservas de canchas. Selecciona un horario para crear o ver una reserva. +

+
+ + + + {selectedSlot && ( + + )} +
+ ); +} diff --git a/apps/web/components/bookings/booking-calendar.tsx b/apps/web/components/bookings/booking-calendar.tsx new file mode 100644 index 0000000..27d8679 --- /dev/null +++ b/apps/web/components/bookings/booking-calendar.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { TimeSlot } from "./time-slot"; +import { cn, formatDate } from "@/lib/utils"; + +// Types for API responses +interface Site { + id: string; + name: string; + slug: string; + openTime: string; + closeTime: string; + timezone: string; +} + +interface Court { + id: string; + name: string; + type: string; + status: string; + pricePerHour: number; + isActive: boolean; + site: Site; +} + +interface Slot { + time: string; + available: boolean; + price: number; + bookingId?: string; + clientName?: string; +} + +interface CourtAvailability { + court: { + id: string; + name: string; + type: string; + status: string; + isActive: boolean; + site: Site; + }; + date: string; + slots: Slot[]; +} + +export interface SelectedSlot { + courtId: string; + courtName: string; + date: string; + time: string; + available: boolean; + price: number; + bookingId?: string; +} + +interface BookingCalendarProps { + siteId?: string; + onSlotClick?: (slot: SelectedSlot) => void; +} + +export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) { + const [date, setDate] = useState(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; + }); + const [courts, setCourts] = useState([]); + const [availability, setAvailability] = useState>( + new Map() + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Format date as YYYY-MM-DD for API + const formatDateForApi = (d: Date): string => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + // Fetch courts + const fetchCourts = useCallback(async () => { + try { + const url = siteId ? `/api/courts?siteId=${siteId}` : "/api/courts"; + const response = await fetch(url); + if (!response.ok) { + throw new Error("Error al cargar las canchas"); + } + const data = await response.json(); + setCourts(data); + return data as Court[]; + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + return []; + } + }, [siteId]); + + // Fetch availability for a court + const fetchCourtAvailability = useCallback( + async (courtId: string, dateStr: string) => { + try { + const response = await fetch( + `/api/courts/${courtId}/availability?date=${dateStr}` + ); + if (!response.ok) { + throw new Error(`Error al cargar disponibilidad`); + } + return (await response.json()) as CourtAvailability; + } catch (err) { + console.error(`Error fetching availability for court ${courtId}:`, err); + return null; + } + }, + [] + ); + + // Fetch all availability + const fetchAllAvailability = useCallback( + async (courtsList: Court[], dateStr: string) => { + setLoading(true); + const newAvailability = new Map(); + + const promises = courtsList.map(async (court) => { + const avail = await fetchCourtAvailability(court.id, dateStr); + if (avail) { + newAvailability.set(court.id, avail); + } + }); + + await Promise.all(promises); + setAvailability(newAvailability); + setLoading(false); + }, + [fetchCourtAvailability] + ); + + // Initial load + useEffect(() => { + const load = async () => { + setLoading(true); + const courtsList = await fetchCourts(); + if (courtsList.length > 0) { + await fetchAllAvailability(courtsList, formatDateForApi(date)); + } else { + setLoading(false); + } + }; + load(); + }, [fetchCourts, fetchAllAvailability, date]); + + // Navigation functions + const goToPrevDay = () => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() - 1); + setDate(newDate); + }; + + const goToToday = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + setDate(today); + }; + + const goToNextDay = () => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() + 1); + setDate(newDate); + }; + + // Handle slot click + const handleSlotClick = (court: Court, slot: Slot) => { + if (onSlotClick) { + onSlotClick({ + courtId: court.id, + courtName: court.name, + date: formatDateForApi(date), + time: slot.time, + available: slot.available, + price: slot.price, + bookingId: slot.bookingId, + }); + } + }; + + // Get all unique time slots across all courts + const getAllTimeSlots = (): string[] => { + const times = new Set(); + availability.forEach((avail) => { + avail.slots.forEach((slot) => { + times.add(slot.time); + }); + }); + return Array.from(times).sort(); + }; + + // Check if current date is today + const isToday = (): boolean => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date.getTime() === today.getTime(); + }; + + if (error) { + return ( + + +
+

{error}

+ +
+
+
+ ); + } + + const timeSlots = getAllTimeSlots(); + + return ( + + +
+ Calendario +
+ + + +
+
+

{formatDate(date)}

+
+ + {loading ? ( +
+
+
+

Cargando disponibilidad...

+
+
+ ) : courts.length === 0 ? ( +
+

No hay canchas disponibles.

+
+ ) : ( +
+
+ {/* Header with court names */} +
= 5 && "grid-cols-5" + )} + > + {courts.map((court) => ( +
+

+ {court.name} +

+

+ {court.type === "INDOOR" ? "Interior" : "Exterior"} +

+
+ ))} +
+ + {/* Time slots grid */} +
+ {timeSlots.map((time) => ( +
= 5 && "grid-cols-5" + )} + > + {courts.map((court) => { + const courtAvail = availability.get(court.id); + const slot = courtAvail?.slots.find((s) => s.time === time); + + if (!slot) { + return ( +
+
+ No disponible +
+
+ ); + } + + return ( +
+ handleSlotClick(court, slot)} + /> +
+ ); + })} +
+ ))} + + {timeSlots.length === 0 && ( +
+

No hay horarios disponibles para este día.

+
+ )} +
+
+
+ )} + + + ); +} diff --git a/apps/web/components/bookings/booking-dialog.tsx b/apps/web/components/bookings/booking-dialog.tsx new file mode 100644 index 0000000..0baf44a --- /dev/null +++ b/apps/web/components/bookings/booking-dialog.tsx @@ -0,0 +1,506 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardFooter, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { cn, formatTime, formatCurrency, formatDate } from "@/lib/utils"; +import type { SelectedSlot } from "./booking-calendar"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + level: string | null; + memberships: Array<{ + id: string; + status: string; + remainingHours: number | null; + plan: { + id: string; + name: string; + discountPercent: number | null; + }; + }>; +} + +interface ClientsResponse { + data: Client[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +interface BookingInfo { + id: string; + startTime: string; + endTime: string; + status: string; + totalPrice: number; + paymentType: string; + client: { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + }; + court: { + id: string; + name: string; + type: string; + }; +} + +interface BookingDialogProps { + courtId: string; + date: string; + slot: SelectedSlot; + onClose: () => void; + onBookingCreated?: () => void; + onBookingCancelled?: () => void; +} + +export function BookingDialog({ + courtId, + date, + slot, + onClose, + onBookingCreated, + onBookingCancelled, +}: BookingDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [clients, setClients] = useState([]); + const [selectedClient, setSelectedClient] = useState(null); + const [loadingClients, setLoadingClients] = useState(false); + const [loadingBooking, setLoadingBooking] = useState(false); + const [booking, setBooking] = useState(null); + const [loadingBookingInfo, setLoadingBookingInfo] = useState(false); + const [error, setError] = useState(null); + const [creatingBooking, setCreatingBooking] = useState(false); + const [cancellingBooking, setCancellingBooking] = useState(false); + + // Parse slot time to Date for display + const [hours, minutes] = slot.time.split(":").map(Number); + const slotDate = new Date(date); + slotDate.setHours(hours, minutes, 0, 0); + + // Fetch booking info if slot is not available + const fetchBookingInfo = useCallback(async () => { + if (!slot.bookingId) return; + + setLoadingBookingInfo(true); + try { + const response = await fetch(`/api/bookings/${slot.bookingId}`); + if (!response.ok) { + throw new Error("Error al cargar la reserva"); + } + const data = await response.json(); + setBooking(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoadingBookingInfo(false); + } + }, [slot.bookingId]); + + // Fetch clients based on search + const fetchClients = useCallback(async (search: string) => { + if (!search || search.trim().length < 2) { + setClients([]); + return; + } + + setLoadingClients(true); + try { + const response = await fetch( + `/api/clients?search=${encodeURIComponent(search)}&limit=10` + ); + if (!response.ok) { + throw new Error("Error al buscar clientes"); + } + const data: ClientsResponse = await response.json(); + setClients(data.data); + } catch (err) { + console.error("Error fetching clients:", err); + setClients([]); + } finally { + setLoadingClients(false); + } + }, []); + + // Load booking info on mount if slot is booked + useEffect(() => { + if (!slot.available && slot.bookingId) { + fetchBookingInfo(); + } + }, [slot.available, slot.bookingId, fetchBookingInfo]); + + // Debounce client search + useEffect(() => { + const timer = setTimeout(() => { + fetchClients(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, fetchClients]); + + // Handle booking creation + const handleCreateBooking = async () => { + if (!selectedClient) return; + + setCreatingBooking(true); + setError(null); + + try { + // Calculate end time (1 hour later) + const endDate = new Date(slotDate); + endDate.setHours(endDate.getHours() + 1); + + const response = await fetch("/api/bookings", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + courtId, + clientId: selectedClient.id, + startTime: slotDate.toISOString(), + endTime: endDate.toISOString(), + paymentType: "CASH", + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al crear la reserva"); + } + + onBookingCreated?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al crear la reserva"); + } finally { + setCreatingBooking(false); + } + }; + + // Handle booking cancellation + const handleCancelBooking = async () => { + if (!slot.bookingId) return; + + setCancellingBooking(true); + setError(null); + + try { + const response = await fetch(`/api/bookings/${slot.bookingId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + cancelReason: "Cancelada por el administrador", + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al cancelar la reserva"); + } + + onBookingCancelled?.(); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Error al cancelar la reserva" + ); + } finally { + setCancellingBooking(false); + } + }; + + // Handle click outside to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+ + +
+ + {slot.available ? "Nueva Reserva" : "Detalle de Reserva"} + + +
+
+

+ Cancha: {slot.courtName} +

+

+ Fecha: {formatDate(date)} +

+

+ Hora: {formatTime(slotDate)} +

+

+ Precio:{" "} + {formatCurrency(slot.price)} +

+
+
+ + + {error && ( +
+ {error} +
+ )} + + {/* Available slot - show client search */} + {slot.available && ( +
+
+ + setSearchQuery(e.target.value)} + autoFocus + /> +
+ + {/* Client search results */} +
+ {loadingClients && ( +
+
+
+ )} + + {!loadingClients && searchQuery.length >= 2 && clients.length === 0 && ( +

+ No se encontraron clientes. +

+ )} + + {!loadingClients && + clients.map((client) => ( + + ))} +
+ + {/* Selected client summary */} + {selectedClient && ( +
+

+ Cliente seleccionado: +

+

+ {selectedClient.firstName} {selectedClient.lastName} +

+ {selectedClient.memberships.length > 0 && ( +

+ Membresia: {selectedClient.memberships[0].plan.name} + {selectedClient.memberships[0].remainingHours !== null && + selectedClient.memberships[0].remainingHours > 0 && ( + + ({selectedClient.memberships[0].remainingHours}h restantes) + + )} +

+ )} +
+ )} +
+ )} + + {/* Booked slot - show booking info */} + {!slot.available && ( +
+ {loadingBookingInfo && ( +
+
+
+ )} + + {!loadingBookingInfo && booking && ( +
+
+
+

Cliente

+

+ {booking.client.firstName} {booking.client.lastName} +

+ {booking.client.phone && ( +

+ {booking.client.phone} +

+ )} + {booking.client.email && ( +

+ {booking.client.email} +

+ )} +
+ +
+
+

Estado

+

+ {booking.status === "CONFIRMED" && "Confirmada"} + {booking.status === "PENDING" && "Pendiente"} + {booking.status === "CANCELLED" && "Cancelada"} + {booking.status === "COMPLETED" && "Completada"} + {booking.status === "NO_SHOW" && "No asistio"} +

+
+
+

Tipo de Pago

+

+ {booking.paymentType === "CASH" && "Efectivo"} + {booking.paymentType === "CARD" && "Tarjeta"} + {booking.paymentType === "TRANSFER" && "Transferencia"} + {booking.paymentType === "MEMBERSHIP" && "Membresia"} + {booking.paymentType === "FREE" && "Gratuito"} +

+
+
+ +
+

Total

+

+ {formatCurrency(Number(booking.totalPrice))} +

+
+
+
+ )} + + {!loadingBookingInfo && !booking && ( +
+

No se pudo cargar la informacion de la reserva.

+
+ )} +
+ )} + + + +
+ + + {slot.available && ( + + )} + + {!slot.available && booking && booking.status !== "CANCELLED" && ( + + )} +
+
+ +
+ ); +} diff --git a/apps/web/components/bookings/index.ts b/apps/web/components/bookings/index.ts new file mode 100644 index 0000000..abb4951 --- /dev/null +++ b/apps/web/components/bookings/index.ts @@ -0,0 +1,3 @@ +export { TimeSlot } from "./time-slot"; +export { BookingCalendar, type SelectedSlot } from "./booking-calendar"; +export { BookingDialog } from "./booking-dialog"; diff --git a/apps/web/components/bookings/time-slot.tsx b/apps/web/components/bookings/time-slot.tsx new file mode 100644 index 0000000..6eaa998 --- /dev/null +++ b/apps/web/components/bookings/time-slot.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { cn, formatTime, formatCurrency } from "@/lib/utils"; + +interface TimeSlotProps { + time: string; // HH:MM format + available: boolean; + price: number; + clientName?: string; + onClick?: () => void; +} + +export function TimeSlot({ + time, + available, + price, + clientName, + onClick, +}: TimeSlotProps) { + // Convert HH:MM string to a Date object for formatting + const [hours, minutes] = time.split(":").map(Number); + const timeDate = new Date(); + timeDate.setHours(hours, minutes, 0, 0); + + return ( + + ); +} diff --git a/apps/web/components/layout/sidebar.tsx b/apps/web/components/layout/sidebar.tsx index 69d2c9b..e074d7f 100644 --- a/apps/web/components/layout/sidebar.tsx +++ b/apps/web/components/layout/sidebar.tsx @@ -21,14 +21,14 @@ interface NavItem { } const navItems: NavItem[] = [ - { label: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, - { label: 'Reservas', href: '/admin/bookings', icon: Calendar }, - { label: 'Torneos', href: '/admin/tournaments', icon: Trophy }, - { label: 'Ventas', href: '/admin/pos', icon: ShoppingCart }, - { label: 'Clientes', href: '/admin/clients', icon: Users }, - { label: 'Membresías', href: '/admin/memberships', icon: CreditCard }, - { label: 'Reportes', href: '/admin/reports', icon: BarChart3 }, - { label: 'Configuración', href: '/admin/settings', icon: Settings }, + { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { label: 'Reservas', href: '/bookings', icon: Calendar }, + { label: 'Torneos', href: '/tournaments', icon: Trophy }, + { label: 'Ventas', href: '/pos', icon: ShoppingCart }, + { label: 'Clientes', href: '/clients', icon: Users }, + { label: 'Membresías', href: '/memberships', icon: CreditCard }, + { label: 'Reportes', href: '/reports', icon: BarChart3 }, + { label: 'Configuración', href: '/settings', icon: Settings }, ]; export function Sidebar() {