0d2b0d553d
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>
225 lines
8.8 KiB
TypeScript
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 }
|