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; customerEmail: 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; selectedSegment: BookingTimelineSegment | null; isLoading: boolean; isError: boolean; errorMessage: string | null; isCreateBookingOpen: boolean; createBookingError: string | null; isCreatingBooking: boolean; bookingToolsOpen: 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) => void; closeCreateBooking: () => void; createBooking: (payload: CreateManualBookingPayload) => Promise; updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void; exportDayReport: () => void; openBookingTools: (segment?: BookingTimelineSegment) => void; closeBookingTools: () => void; } const BookingContext = createContext(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' || booking.status === 'NOSHOW' ); } 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 nowMinutes = timeToMinutes(getNowTime()); const isToday = isTodayIso(selectedDate); 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; if (isToday && slotEnd <= nowMinutes) continue; 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 totalSegments = freeSlots + reservedSlots || 1; return { totalReservations: freeSlots + reservedSlots, freeSlots, reservedSlots, freePercent: Math.round((freeSlots / totalSegments) * 100), reservedPercent: Math.round((reservedSlots / 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('all'); const [viewMode, setViewMode] = useState('panel'); const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE); const [selectedSlot, setSelectedSlot] = useState(null); const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false); const [selectedSegment, setSelectedSegment] = useState(null); const [bookingToolsOpen, setBookingToolsOpen] = 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?.sort((a, b) => a.name.localeCompare(b.name)) ?? [], [courtsQuery.data] ); const filteredCourts = useMemo(() => { if (selectedSportId === 'all') return courts; return courts.filter((court) => court.sportId === selectedSportId); }, [courts, selectedSportId]); const schedules = useMemo(() => { 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, }; 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 [now, setNow] = useState(() => getNowTime()); useEffect(() => { const interval = setInterval(() => { setNow(getNowTime()); }, 60 * 1000); return () => clearInterval(interval); }, []); const currentTime = useMemo(() => { if (!isTodayIso(selectedDate)) return null; 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, now]); 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, customerEmail: payload.customerEmail, }), 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' | 'NOSHOW' }) => apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); }, }); const openCreateBooking = useCallback( (draft?: Partial) => { setSelectedSlot({ date: draft?.date ?? selectedDate, courtId: draft?.courtId, startTime: draft?.startTime, sportId: draft?.sportId, }); setIsCreateBookingOpen(true); }, [selectedDate] ); const openBookingTools = useCallback( (segment?: BookingTimelineSegment) => { setSelectedSegment(segment ?? null); setBookingToolsOpen(true); console.log(JSON.stringify(segment, null, 2)); }, [selectedDate] ); const closeBookingTools = () => setBookingToolsOpen(false); 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' | 'NOSHOW') => { 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( () => ({ 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, bookingToolsOpen, openBookingTools, closeBookingTools, selectedSegment, }), [ 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, bookingToolsOpen, ] ); return {children}; } export function useBooking() { const context = useContext(BookingContext); if (!context) { throw new Error('useBooking must be used within BookingProvider'); } return context; } export { formatBookingDate };