diff --git a/src/components/hexa/views/booking-calendar.tsx b/src/components/hexa/views/booking-calendar.tsx index 866f42a..63ac4f4 100644 --- a/src/components/hexa/views/booking-calendar.tsx +++ b/src/components/hexa/views/booking-calendar.tsx @@ -5,11 +5,21 @@ import FullCalendar from "@fullcalendar/react" import dayGridPlugin from "@fullcalendar/daygrid" import timeGridPlugin from "@fullcalendar/timegrid" import interactionPlugin from "@fullcalendar/interaction" -import type { DatesSetArg, EventInput } from "@fullcalendar/core" +import type { DateSelectArg, DatesSetArg, EventClickArg, EventInput } from "@fullcalendar/core" import { getBookings } from "@/lib/api/bookings" +import { getFacilitySessions } from "@/lib/api/facilities" import { ApiError } from "@/lib/api/core-http" import type { Booking, BookingStatus } from "@/lib/models/booking" +import type { FacilitySession } from "@/lib/models/facility" +import { formatCurrency } from "@/lib/format" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { StatusBadge } from "@/components/hexa/status-badge" const STATUS_COLORS: Record = { Confirmed: "#2563eb", @@ -19,41 +29,157 @@ const STATUS_COLORS: Record = { Cancelled: "#dc2626", } +const SESSION_COLOR = "#7c3aed" +const NOT_HELD_COLOR = "#9ca3af" + function bookingToEvent(booking: Booking): EventInput { const color = STATUS_COLORS[booking.status] ?? "#2563eb" return { - id: booking.bookingId, + id: `booking-${booking.bookingId}`, title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`, start: booking.startTime, end: booking.endTime, backgroundColor: color, borderColor: color, + extendedProps: { kind: "booking", booking }, } } -/** Read-only calendar view of bookings — reuses the existing roster's getBookings query, - * refetched for whatever range FullCalendar currently has visible. */ -function BookingCalendar() { +function sessionToEvent(session: FacilitySession): EventInput { + const notHeld = session.isCancelled + const label = session.facilityClassName ?? session.facilityName ?? "Session" + const capacityLabel = session.isUnlimitedCapacity ? `${session.bookedCount} booked` : `${session.bookedCount}/${session.capacity} booked` + + return { + id: `session-${session.facilitySessionId}`, + title: notHeld ? `${label} — Not held` : `${label} — ${capacityLabel}`, + start: session.startTime, + end: session.endTime, + backgroundColor: notHeld ? "transparent" : SESSION_COLOR, + borderColor: notHeld ? NOT_HELD_COLOR : SESSION_COLOR, + textColor: notHeld ? NOT_HELD_COLOR : undefined, + classNames: notHeld ? ["opacity-70", "border-dashed"] : [], + extendedProps: { kind: "session", session }, + } +} + +type SelectedEvent = { kind: "booking"; booking: Booking } | { kind: "session"; session: FacilitySession } | null + +function EventDetailDialog({ event, onOpenChange }: { event: SelectedEvent; onOpenChange: (open: boolean) => void }) { + return ( + + + + {event?.kind === "booking" ? "Booking Details" : "Class Occurrence"} + + + {event?.kind === "booking" && ( +
+
+

Facility

+

{event.booking.facilityName ?? "—"}

+
+
+

Status

+ + {event.booking.status} + +
+
+

Who

+

{event.booking.isGuest ? event.booking.guestName ?? "Guest" : "Member"}

+
+
+

Payment

+

+ {event.booking.paymentStatus} + {event.booking.amountCharged > 0 && ` · ${formatCurrency(event.booking.amountCharged)}`} +

+
+
+ )} + + {event?.kind === "session" && ( +
+
+

Class

+

{event.session.facilityClassName ?? event.session.facilityName ?? "—"}

+
+
+

Status

+ + {event.session.isCancelled ? "Not held" : "Scheduled"} + +
+
+

Booked

+

+ {event.session.bookedCount} + {!event.session.isUnlimitedCapacity && ` / ${event.session.capacity}`} +

+
+ {event.session.isCancelled && event.session.notHeldReason && ( +
+

Reason

+

{event.session.notHeldReason}

+
+ )} +
+ )} +
+
+ ) +} + +interface BookingCalendarProps { + /** Fired when the user drags a time range in day/week view -- the parent switches to Book Now + * and pre-fills the dragged time. */ + onQuickBook?: (draft: { startTime: string; endTime: string }) => void +} + +/** Calendar view of bookings and class occurrences -- one merged view, no per-facility split; + * the facility is still chosen in Book Now, same as today. Refetches both for whatever range + * FullCalendar currently has visible. */ +function BookingCalendar({ onQuickBook }: BookingCalendarProps) { const [events, setEvents] = React.useState([]) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) + const [viewType, setViewType] = React.useState("dayGridMonth") + const [selectedEvent, setSelectedEvent] = React.useState(null) async function handleDatesSet(arg: DatesSetArg) { + setViewType(arg.view.type) setLoading(true) setError(null) try { - const bookings = await getBookings({ - fromDate: arg.start.toISOString(), - toDate: arg.end.toISOString(), - }) - setEvents(bookings.map(bookingToEvent)) + const [bookings, sessions] = await Promise.all([ + getBookings({ + fromDate: arg.start.toISOString(), + toDate: arg.end.toISOString(), + }), + getFacilitySessions({ + fromDate: arg.start.toISOString(), + toDate: arg.end.toISOString(), + includeCancelled: true, + }), + ]) + setEvents([...bookings.map(bookingToEvent), ...sessions.map(sessionToEvent)]) } catch (err) { - setError(err instanceof ApiError ? err.message : "Couldn't load bookings for this range.") + setError(err instanceof ApiError ? err.message : "Couldn't load the calendar for this range.") } finally { setLoading(false) } } + function handleEventClick(arg: EventClickArg) { + const props = arg.event.extendedProps as { kind: "booking"; booking: Booking } | { kind: "session"; session: FacilitySession } + setSelectedEvent(props) + } + + function handleSelect(arg: DateSelectArg) { + onQuickBook?.({ startTime: arg.start.toISOString(), endTime: arg.end.toISOString() }) + } + return (
@@ -65,17 +191,32 @@ function BookingCalendar() { {status} ))} + + + Class + + + + Not held — open + + Drag a time range in Day/Week view to start a booking.
+ !open && setSelectedEvent(null)} />
) } diff --git a/src/components/hexa/views/booking-dashboard-tab.tsx b/src/components/hexa/views/booking-dashboard-tab.tsx index 870921e..732af20 100644 --- a/src/components/hexa/views/booking-dashboard-tab.tsx +++ b/src/components/hexa/views/booking-dashboard-tab.tsx @@ -20,14 +20,18 @@ const BookingCalendar = dynamic( { ssr: false, loading: () => ( -
+
), } ) -function BookingDashboardTab() { +interface BookingDashboardTabProps { + onQuickBook?: (draft: { startTime: string; endTime: string }) => void +} + +function BookingDashboardTab({ onQuickBook }: BookingDashboardTabProps) { const summaryFetch = useFetch(() => getBookingDashboardSummary()) const summary = summaryFetch.data @@ -68,7 +72,7 @@ function BookingDashboardTab() { />
- + ) } diff --git a/src/components/hexa/views/booking-roster-tab.tsx b/src/components/hexa/views/booking-roster-tab.tsx index 4cd4f4b..6d3c1c7 100644 --- a/src/components/hexa/views/booking-roster-tab.tsx +++ b/src/components/hexa/views/booking-roster-tab.tsx @@ -118,7 +118,7 @@ interface BookingRosterTabProps { } function BookingRosterTab({ facilities }: BookingRosterTabProps) { - const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled" && f.isActive) + const scheduledFacilities = facilities.filter((f) => f.supportsClasses && f.isActive) const [facilityFilter, setFacilityFilter] = React.useState("all") const [statusFilter, setStatusFilter] = React.useState("all") @@ -1012,7 +1012,7 @@ interface RescheduleDialogProps { } function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) { - const isAdhoc = booking?.bookingMode === "AdHoc" + const isWalkIn = booking?.bookingMode === "WalkIn" const [startTime, setStartTime] = React.useState("") const [duration, setDuration] = React.useState("60") @@ -1030,7 +1030,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr } const sessionsFetch = useFetch(() => - booking && !isAdhoc ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([]) + booking && !isWalkIn ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([]) ) const sessionOptions = (sessionsFetch.data ?? []) .filter( @@ -1039,7 +1039,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr ) .map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId })) - const canSubmit = isAdhoc + const canSubmit = isWalkIn ? !!booking && !!startTime && Number(duration) >= 1 && !busy : !!booking && !!sessionId && !busy @@ -1050,7 +1050,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr await run(() => rescheduleBooking( booking.bookingId, - isAdhoc + isWalkIn ? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) } : { facilitySessionId: sessionId } ) @@ -1072,7 +1072,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr Only a free slot can be chosen — same rule as creating a new booking. - {isAdhoc ? ( + {isWalkIn ? (