Files
Client_Fe/src/components/hexa/views/booking-calendar.tsx
T
HarithaRandunu 0d2b0d553d Facility booking-mode redesign, Classes tab, and calendar quick-book
Implements the facility-classes-booking-dashboard plan (frontend):

- Facility create/edit replaces the single Booking Mode dropdown with
  two independent checkboxes (Classes / Walk-in booking), showing both
  capacity fields when both apply.
- New Classes tab (Memberships): create/edit recurring classes
  (Daily/Weekly/Monthly/Custom), view occurrence history, mark an
  occurrence not held, and a "Manage Plan" action that creates a
  membership plan linked to that class (with an optional "also grant
  gym access" toggle). Old Sessions tab/dialog removed from Bookings,
  superseded by this.
- Booking Dashboard calendar now shows class occurrences as distinct
  blocks (capacity/booked count, muted+dashed when not held and open
  again), clicking any event opens a status/payment detail dialog, and
  dragging a time range in Day/Week view jumps to Book Now with the
  time pre-filled (snapped to 15-minute marks).
- Renamed adhoc-booking-tab.tsx to walkin-booking-tab.tsx (component
  WalkInBookingTab, tab key "book-now") and every other "AdHoc"
  reference to "WalkIn" for a clearer, simpler name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 14:27:17 +05:30

225 lines
8.8 KiB
TypeScript

"use client"
import * as React from "react"
import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import interactionPlugin from "@fullcalendar/interaction"
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<BookingStatus, string> = {
Confirmed: "#2563eb",
CheckedIn: "#16a34a",
Completed: "#6b7280",
NoShow: "#dc2626",
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-${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 },
}
}
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 (
<Dialog open={!!event} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{event?.kind === "booking" ? "Booking Details" : "Class Occurrence"}</DialogTitle>
</DialogHeader>
{event?.kind === "booking" && (
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
<div>
<p className="text-muted-foreground">Facility</p>
<p className="font-medium">{event.booking.facilityName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<StatusBadge status={event.booking.status === "Cancelled" || event.booking.status === "NoShow" ? "danger" : "success"}>
{event.booking.status}
</StatusBadge>
</div>
<div>
<p className="text-muted-foreground">Who</p>
<p className="font-medium">{event.booking.isGuest ? event.booking.guestName ?? "Guest" : "Member"}</p>
</div>
<div>
<p className="text-muted-foreground">Payment</p>
<p className="font-medium">
{event.booking.paymentStatus}
{event.booking.amountCharged > 0 && ` · ${formatCurrency(event.booking.amountCharged)}`}
</p>
</div>
</div>
)}
{event?.kind === "session" && (
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
<div>
<p className="text-muted-foreground">Class</p>
<p className="font-medium">{event.session.facilityClassName ?? event.session.facilityName ?? "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<StatusBadge status={event.session.isCancelled ? "neutral" : "success"}>
{event.session.isCancelled ? "Not held" : "Scheduled"}
</StatusBadge>
</div>
<div>
<p className="text-muted-foreground">Booked</p>
<p className="font-medium">
{event.session.bookedCount}
{!event.session.isUnlimitedCapacity && ` / ${event.session.capacity}`}
</p>
</div>
{event.session.isCancelled && event.session.notHeldReason && (
<div className="col-span-2">
<p className="text-muted-foreground">Reason</p>
<p className="font-medium">{event.session.notHeldReason}</p>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
)
}
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<EventInput[]>([])
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [viewType, setViewType] = React.useState("dayGridMonth")
const [selectedEvent, setSelectedEvent] = React.useState<SelectedEvent>(null)
async function handleDatesSet(arg: DatesSetArg) {
setViewType(arg.view.type)
setLoading(true)
setError(null)
try {
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 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 (
<div className="rounded-lg border p-3">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{loading && <span>Loading…</span>}
{error && <span className="text-destructive">{error}</span>}
{(Object.keys(STATUS_COLORS) as BookingStatus[]).map((status) => (
<span key={status} className="flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-full" style={{ backgroundColor: STATUS_COLORS[status] }} />
{status}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-full" style={{ backgroundColor: SESSION_COLOR }} />
Class
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-full border border-dashed" style={{ borderColor: NOT_HELD_COLOR }} />
Not held — open
</span>
<span className="ml-auto">Drag a time range in Day/Week view to start a booking.</span>
</div>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
height="auto"
nowIndicator
events={events}
datesSet={handleDatesSet}
eventDisplay="block"
eventClick={handleEventClick}
selectable={viewType !== "dayGridMonth"}
selectMirror
snapDuration="00:15:00"
select={handleSelect}
/>
<EventDetailDialog event={selectedEvent} onOpenChange={(open) => !open && setSelectedEvent(null)} />
</div>
)
}
export { BookingCalendar }