New booking panel
This commit is contained in:
472
apps/frontend/src/features/booking/booking-provider.tsx
Normal file
472
apps/frontend/src/features/booking/booking-provider.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user