Files
app-padel/apps/web/components/bookings/booking-calendar.tsx
Ivan cdf6e8ebe6 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 <noreply@anthropic.com>
2026-02-01 06:50:22 +00:00

387 lines
11 KiB
TypeScript

"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<Date>(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
});
const [courts, setCourts] = useState<Court[]>([]);
const [availability, setAvailability] = useState<Map<string, CourtAvailability>>(
new Map()
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, CourtAvailability>();
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<string>();
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 (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
<p>{error}</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
setError(null);
fetchCourts();
}}
>
Reintentar
</Button>
</div>
</CardContent>
</Card>
);
}
const timeSlots = getAllTimeSlots();
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Calendario</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={goToPrevDay}>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Button>
<Button
variant={isToday() ? "default" : "outline"}
size="sm"
onClick={goToToday}
>
Hoy
</Button>
<Button variant="outline" size="sm" onClick={goToNextDay}>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Button>
</div>
</div>
<p className="text-sm text-primary-600 mt-1">{formatDate(date)}</p>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center p-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
<p className="text-sm text-primary-500">Cargando disponibilidad...</p>
</div>
</div>
) : courts.length === 0 ? (
<div className="p-6 text-center text-primary-500">
<p>No hay canchas disponibles.</p>
</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-full">
{/* Header with court names */}
<div
className={cn(
"grid border-b border-primary-200 bg-primary-50",
courts.length === 1 && "grid-cols-1",
courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3",
courts.length === 4 && "grid-cols-4",
courts.length >= 5 && "grid-cols-5"
)}
>
{courts.map((court) => (
<div
key={court.id}
className="border-r border-primary-200 last:border-r-0 p-4 text-center"
>
<h3 className="font-semibold text-primary-800">
{court.name}
</h3>
<p className="text-xs text-primary-500 mt-1">
{court.type === "INDOOR" ? "Interior" : "Exterior"}
</p>
</div>
))}
</div>
{/* Time slots grid */}
<div className="divide-y divide-primary-100">
{timeSlots.map((time) => (
<div
key={time}
className={cn(
"grid",
courts.length === 1 && "grid-cols-1",
courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3",
courts.length === 4 && "grid-cols-4",
courts.length >= 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 (
<div
key={court.id}
className="border-r border-primary-200 last:border-r-0 p-2"
>
<div className="rounded-md border border-primary-200 bg-primary-50 p-3 text-center text-xs text-primary-400">
No disponible
</div>
</div>
);
}
return (
<div
key={court.id}
className="border-r border-primary-200 last:border-r-0 p-2"
>
<TimeSlot
time={slot.time}
available={slot.available}
price={slot.price}
clientName={slot.clientName}
onClick={() => handleSlotClick(court, slot)}
/>
</div>
);
})}
</div>
))}
{timeSlots.length === 0 && (
<div className="p-6 text-center text-primary-500">
<p>No hay horarios disponibles para este día.</p>
</div>
)}
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}