507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
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;
|
|
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<BookingSlotDraft>) => void;
|
|
closeCreateBooking: () => void;
|
|
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
|
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
|
|
exportDayReport: () => void;
|
|
openBookingTools: (segment?: BookingTimelineSegment) => void;
|
|
closeBookingTools: () => 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' || 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 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 [selectedSegment, setSelectedSegment] = useState<BookingTimelineSegment | null>(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<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 [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,
|
|
}),
|
|
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<BookingSlotDraft>) => {
|
|
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<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,
|
|
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 <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 };
|