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>
This commit is contained in:
@@ -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<BookingStatus, string> = {
|
||||
Confirmed: "#2563eb",
|
||||
@@ -19,41 +29,157 @@ const STATUS_COLORS: Record<BookingStatus, string> = {
|
||||
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 (
|
||||
<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 = 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 (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
@@ -65,17 +191,32 @@ function BookingCalendar() {
|
||||
{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="timeGridWeek"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user