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:
2026-10-09 14:27:17 +05:30
parent 6263c99767
commit 0d2b0d553d
15 changed files with 1282 additions and 504 deletions
+153 -12
View File
@@ -5,11 +5,21 @@ import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid" import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid" import timeGridPlugin from "@fullcalendar/timegrid"
import interactionPlugin from "@fullcalendar/interaction" 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 { getBookings } from "@/lib/api/bookings"
import { getFacilitySessions } from "@/lib/api/facilities"
import { ApiError } from "@/lib/api/core-http" import { ApiError } from "@/lib/api/core-http"
import type { Booking, BookingStatus } from "@/lib/models/booking" 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> = { const STATUS_COLORS: Record<BookingStatus, string> = {
Confirmed: "#2563eb", Confirmed: "#2563eb",
@@ -19,41 +29,157 @@ const STATUS_COLORS: Record<BookingStatus, string> = {
Cancelled: "#dc2626", Cancelled: "#dc2626",
} }
const SESSION_COLOR = "#7c3aed"
const NOT_HELD_COLOR = "#9ca3af"
function bookingToEvent(booking: Booking): EventInput { function bookingToEvent(booking: Booking): EventInput {
const color = STATUS_COLORS[booking.status] ?? "#2563eb" const color = STATUS_COLORS[booking.status] ?? "#2563eb"
return { return {
id: booking.bookingId, id: `booking-${booking.bookingId}`,
title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`, title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`,
start: booking.startTime, start: booking.startTime,
end: booking.endTime, end: booking.endTime,
backgroundColor: color, backgroundColor: color,
borderColor: color, borderColor: color,
extendedProps: { kind: "booking", booking },
} }
} }
/** Read-only calendar view of bookings — reuses the existing roster's getBookings query, function sessionToEvent(session: FacilitySession): EventInput {
* refetched for whatever range FullCalendar currently has visible. */ const notHeld = session.isCancelled
function BookingCalendar() { 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 [events, setEvents] = React.useState<EventInput[]>([])
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null) 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) { async function handleDatesSet(arg: DatesSetArg) {
setViewType(arg.view.type)
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const bookings = await getBookings({ const [bookings, sessions] = await Promise.all([
fromDate: arg.start.toISOString(), getBookings({
toDate: arg.end.toISOString(), fromDate: arg.start.toISOString(),
}) toDate: arg.end.toISOString(),
setEvents(bookings.map(bookingToEvent)) }),
getFacilitySessions({
fromDate: arg.start.toISOString(),
toDate: arg.end.toISOString(),
includeCancelled: true,
}),
])
setEvents([...bookings.map(bookingToEvent), ...sessions.map(sessionToEvent)])
} catch (err) { } 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 { } finally {
setLoading(false) 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 ( return (
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> <div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
@@ -65,17 +191,32 @@ function BookingCalendar() {
{status} {status}
</span> </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> </div>
<FullCalendar <FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek" initialView="dayGridMonth"
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }} headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
height="auto" height="auto"
nowIndicator nowIndicator
events={events} events={events}
datesSet={handleDatesSet} datesSet={handleDatesSet}
eventDisplay="block" eventDisplay="block"
eventClick={handleEventClick}
selectable={viewType !== "dayGridMonth"}
selectMirror
snapDuration="00:15:00"
select={handleSelect}
/> />
<EventDetailDialog event={selectedEvent} onOpenChange={(open) => !open && setSelectedEvent(null)} />
</div> </div>
) )
} }
@@ -20,14 +20,18 @@ const BookingCalendar = dynamic(
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex h-[500px] items-center justify-center rounded-lg border"> <div className="flex h-125 items-center justify-center rounded-lg border">
<Spinner className="size-6" /> <Spinner className="size-6" />
</div> </div>
), ),
} }
) )
function BookingDashboardTab() { interface BookingDashboardTabProps {
onQuickBook?: (draft: { startTime: string; endTime: string }) => void
}
function BookingDashboardTab({ onQuickBook }: BookingDashboardTabProps) {
const summaryFetch = useFetch(() => getBookingDashboardSummary()) const summaryFetch = useFetch(() => getBookingDashboardSummary())
const summary = summaryFetch.data const summary = summaryFetch.data
@@ -68,7 +72,7 @@ function BookingDashboardTab() {
/> />
</div> </div>
<BookingCalendar /> <BookingCalendar onQuickBook={onQuickBook} />
</div> </div>
) )
} }
@@ -118,7 +118,7 @@ interface BookingRosterTabProps {
} }
function BookingRosterTab({ facilities }: 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 [facilityFilter, setFacilityFilter] = React.useState("all")
const [statusFilter, setStatusFilter] = React.useState("all") const [statusFilter, setStatusFilter] = React.useState("all")
@@ -1012,7 +1012,7 @@ interface RescheduleDialogProps {
} }
function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) { function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) {
const isAdhoc = booking?.bookingMode === "AdHoc" const isWalkIn = booking?.bookingMode === "WalkIn"
const [startTime, setStartTime] = React.useState("") const [startTime, setStartTime] = React.useState("")
const [duration, setDuration] = React.useState("60") const [duration, setDuration] = React.useState("60")
@@ -1030,7 +1030,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
} }
const sessionsFetch = useFetch(() => const sessionsFetch = useFetch(() =>
booking && !isAdhoc ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([]) booking && !isWalkIn ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([])
) )
const sessionOptions = (sessionsFetch.data ?? []) const sessionOptions = (sessionsFetch.data ?? [])
.filter( .filter(
@@ -1039,7 +1039,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
) )
.map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId })) .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 && !!startTime && Number(duration) >= 1 && !busy
: !!booking && !!sessionId && !busy : !!booking && !!sessionId && !busy
@@ -1050,7 +1050,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
await run(() => await run(() =>
rescheduleBooking( rescheduleBooking(
booking.bookingId, booking.bookingId,
isAdhoc isWalkIn
? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) } ? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) }
: { facilitySessionId: sessionId } : { facilitySessionId: sessionId }
) )
@@ -1072,7 +1072,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
<DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription> <DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription>
</DialogHeader> </DialogHeader>
{isAdhoc ? ( {isWalkIn ? (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="reschedule-start"> <Label htmlFor="reschedule-start">
+16 -379
View File
@@ -1,79 +1,31 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
import { toast } from "sonner"
import { import { getFacilities } from "@/lib/api/facilities"
cancelFacilitySession,
createFacilitySession,
getFacilities,
getFacilitySessions,
} from "@/lib/api/facilities"
import type { Facility, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch" import { useFetch } from "@/lib/use-fetch"
import { useMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header" import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton" import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge" import { WalkInBookingTab } from "@/components/hexa/views/walkin-booking-tab"
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
import { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab" import { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab"
import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab" import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab"
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab" import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab" import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab" import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
}
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
function toLocalInputValue(date: Date) {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function BookingsView() { function BookingsView() {
const facilitiesFetch = useFetch(() => getFacilities(true)) const facilitiesFetch = useFetch(() => getFacilities(true))
const facilities = facilitiesFetch.data ?? [] const facilities = facilitiesFetch.data ?? []
const [activeTab, setActiveTab] = React.useState("dashboard")
const [quickBookDraft, setQuickBookDraft] = React.useState<{ startTime: string; endTime: string } | null>(null)
function handleQuickBook(draft: { startTime: string; endTime: string }) {
setQuickBookDraft(draft)
setActiveTab("book-now")
}
if (facilitiesFetch.loading) { if (facilitiesFetch.loading) {
return <PageSkeleton variant="stats-table" /> return <PageSkeleton variant="stats-table" />
} }
@@ -82,14 +34,13 @@ function BookingsView() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Bookings" title="Bookings"
description="Dashboard, scheduled sessions, ad-hoc reservations, the booking roster, invoices, RFID tags and time-bank cards" description="Dashboard, walk-in bookings, the booking roster, invoices, RFID tags and time-bank cards"
/> />
<Tabs defaultValue="dashboard" className="gap-4"> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v ?? "dashboard")} className="gap-4">
<TabsList> <TabsList>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger> <TabsTrigger value="dashboard">Dashboard</TabsTrigger>
<TabsTrigger value="sessions">Sessions</TabsTrigger> <TabsTrigger value="book-now">Book Now</TabsTrigger>
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
<TabsTrigger value="roster">Roster &amp; Check-in</TabsTrigger> <TabsTrigger value="roster">Roster &amp; Check-in</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger> <TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger> <TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
@@ -97,13 +48,10 @@ function BookingsView() {
</TabsList> </TabsList>
<TabsContent value="dashboard"> <TabsContent value="dashboard">
<BookingDashboardTab /> <BookingDashboardTab onQuickBook={handleQuickBook} />
</TabsContent> </TabsContent>
<TabsContent value="sessions"> <TabsContent value="book-now">
<SessionsTab facilities={facilities} /> <WalkInBookingTab facilities={facilities} prefill={quickBookDraft} onPrefillConsumed={() => setQuickBookDraft(null)} />
</TabsContent>
<TabsContent value="adhoc">
<AdhocBookingTab facilities={facilities} />
</TabsContent> </TabsContent>
<TabsContent value="roster"> <TabsContent value="roster">
<BookingRosterTab facilities={facilities} /> <BookingRosterTab facilities={facilities} />
@@ -122,315 +70,4 @@ function BookingsView() {
) )
} }
// ---------- Sessions ----------
interface SessionsTabProps {
facilities: Facility[]
}
function SessionsTab({ facilities }: SessionsTabProps) {
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled")
const [facilityFilter, setFacilityFilter] = React.useState("all")
const sessionsFetch = useFetch(() =>
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
)
const sessions = sessionsFetch.data ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [cancelTarget, setCancelTarget] = React.useState<FacilitySession | null>(null)
const { busy: cancelling, run: runCancel } = useMutation()
const filterOptions = [
{ label: "All facilities", value: "all" },
...scheduledFacilities.map((f) => ({ label: f.name, value: f.facilityId })),
]
async function confirmCancel() {
if (!cancelTarget) return
const target = cancelTarget
setCancelTarget(null)
try {
await runCancel(() => cancelFacilitySession(target.facilitySessionId))
toast.success("Session cancelled")
sessionsFetch.reload()
} catch {
// run() already toasted the real error.
}
}
const columns: DataTableColumn<FacilitySession>[] = [
{ key: "facility", header: "Facility", cell: (row) => row.facilityName ?? "-" },
{ key: "start", header: "Start", cell: (row) => formatDateTime(row.startTime) },
{ key: "end", header: "End", cell: (row) => formatDateTime(row.endTime) },
{
key: "capacity",
header: "Booked / Capacity",
cell: (row) => `${row.bookedCount} / ${row.capacity}`,
},
{
key: "status",
header: "Status",
cell: (row) =>
row.isCancelled ? (
<StatusBadge status="danger">Cancelled</StatusBadge>
) : (
<StatusBadge status="success">Scheduled</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) =>
row.isCancelled ? null : (
<div className="flex justify-center">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Cancel session"
onClick={() => setCancelTarget(row)}
>
<Trash2Icon />
</Button>
}
/>
<TooltipContent>Cancel session</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Label>Facility</Label>
<Select items={filterOptions} value={facilityFilter} onValueChange={(v) => setFacilityFilter(v ?? "all")}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All facilities" />
</SelectTrigger>
<SelectContent>
{filterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
<CalendarPlusIcon />
Schedule Session
</Button>
</div>
{sessionsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={sessions}
rowKey={(row) => row.facilitySessionId}
emptyMessage="No sessions scheduled yet."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{row.facilityName ?? "-"}</p>
<p className="text-sm text-muted-foreground">
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
</p>
</div>
{row.isCancelled ? (
<StatusBadge status="danger">Cancelled</StatusBadge>
) : (
<StatusBadge status="success">Scheduled</StatusBadge>
)}
</div>
<div className="flex items-center justify-between border-t pt-2">
<span className="text-sm text-muted-foreground">
{row.bookedCount} / {row.capacity} booked
</span>
{!row.isCancelled && (
<Button variant="outline" size="icon-sm" aria-label="Cancel session" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
)}
</div>
</div>
)}
/>
)}
<ScheduleSessionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
facilities={scheduledFacilities}
onScheduled={sessionsFetch.reload}
/>
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this session?</AlertDialogTitle>
<AlertDialogDescription>
Existing bookings for this session will need to be handled separately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Session</AlertDialogCancel>
<AlertDialogAction onClick={confirmCancel} disabled={cancelling}>
Cancel Session
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface ScheduleSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
facilities: Facility[]
onScheduled: () => void
}
function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }: ScheduleSessionDialogProps) {
const [facilityId, setFacilityId] = React.useState("")
const [startTime, setStartTime] = React.useState("")
const [endTime, setEndTime] = React.useState("")
const [capacity, setCapacity] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(now.getHours() + 1)
const later = new Date(now.getTime() + 60 * 60 * 1000)
setFacilityId(facilities[0]?.facilityId ?? "")
setStartTime(toLocalInputValue(now))
setEndTime(toLocalInputValue(later))
setCapacity("")
}
}
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
const canSubmit = !!facilityId && !!startTime && !!endTime && endTime > startTime && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() =>
createFacilitySession({
facilityId,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
capacity: capacity ? Number(capacity) : undefined,
})
)
toast.success("Session scheduled")
onOpenChange(false)
onScheduled()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Schedule Session</DialogTitle>
<DialogDescription>Open a bookable time slot for a Scheduled-mode facility.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label>
Facility <span className="text-destructive">*</span>
</Label>
<Select
items={facilityOptions}
value={facilityId}
onValueChange={(v) => setFacilityId(v ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select facility" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-start">
Start <span className="text-destructive">*</span>
</Label>
<Input
id="session-start"
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-end">
End <span className="text-destructive">*</span>
</Label>
<Input
id="session-end"
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="session-capacity">Capacity override</Label>
<Input
id="session-capacity"
type="number"
min={1}
placeholder="Uses the facility's default capacity if left blank"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Schedule Session
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { BookingsView } export { BookingsView }
+780
View File
@@ -0,0 +1,780 @@
"use client"
import * as React from "react"
import { CalendarIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { getFacilities } from "@/lib/api/facilities"
import {
createFacilityClass,
getClassOccurrences,
getFacilityClasses,
getSchedulingConflicts,
markOccurrenceNotHeld,
updateFacilityClass,
} from "@/lib/api/facility-classes"
import { createGymPlan } from "@/lib/api/gym-plans"
import type { ClassOccurrence, CreateFacilityClassInput, FacilityClass, RecurrenceType } from "@/lib/models/facility-class"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
const RECURRENCE_OPTIONS: { label: string; value: RecurrenceType }[] = [
{ label: "Daily", value: "Daily" },
{ label: "Weekly", value: "Weekly" },
{ label: "Monthly", value: "Monthly" },
{ label: "Custom (explicit dates)", value: "Custom" },
]
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function recurrenceSummary(cls: FacilityClass) {
if (cls.recurrenceType === "Custom") return "Custom dates"
if (cls.recurrenceType === "Daily") return `Daily @ ${cls.startTimeOfDay}`
if (cls.recurrenceType === "Monthly") return `Monthly (${cls.monthDays ?? "—"}) @ ${cls.startTimeOfDay}`
const days = WEEKDAYS.filter((_, i) => (cls.weekDaysMask & (1 << i)) !== 0).join(", ")
return `Weekly (${days || "—"}) @ ${cls.startTimeOfDay}`
}
/** `datetime-local` input value in local time. */
function toLocalInputValue(date: Date) {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
interface ClassFormValues {
facilityId: string
name: string
description: string
recurrenceType: RecurrenceType
startTimeOfDay: string
durationMinutes: string
weekDaysMask: number
monthDays: string
seriesStartDate: string
seriesEndDate: string
capacity: string
isUnlimitedCapacity: boolean
}
const EMPTY_VALUES: ClassFormValues = {
facilityId: "",
name: "",
description: "",
recurrenceType: "Weekly",
startTimeOfDay: "18:00",
durationMinutes: "60",
weekDaysMask: 0,
monthDays: "",
seriesStartDate: new Date().toISOString().slice(0, 10),
seriesEndDate: "",
capacity: "20",
isUnlimitedCapacity: false,
}
interface CustomOccurrenceRow {
localDateTime: string
durationMinutes: string
}
interface ClassDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
facilities: { facilityId: string; name: string }[]
onCreated: () => void
}
function ClassDialog({ open, onOpenChange, facilities, onCreated }: ClassDialogProps) {
const [values, setValues] = React.useState<ClassFormValues>(EMPTY_VALUES)
const [customRows, setCustomRows] = React.useState<CustomOccurrenceRow[]>([])
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setValues({ ...EMPTY_VALUES, facilityId: facilities[0]?.facilityId ?? "" })
setCustomRows([])
}
}
function set<K extends keyof ClassFormValues>(key: K, value: ClassFormValues[K]) {
setValues((v) => ({ ...v, [key]: value }))
}
function toggleWeekday(bit: number) {
setValues((v) => ({ ...v, weekDaysMask: v.weekDaysMask ^ (1 << bit) }))
}
const isCustom = values.recurrenceType === "Custom"
const canSubmit =
!!values.facilityId &&
!!values.name.trim() &&
(isCustom
? customRows.length > 0 && customRows.every((r) => !!r.localDateTime && Number(r.durationMinutes) >= 1)
: !!values.startTimeOfDay &&
Number(values.durationMinutes) >= 1 &&
(values.recurrenceType !== "Weekly" || values.weekDaysMask !== 0) &&
(values.recurrenceType !== "Monthly" || values.monthDays.trim() !== "")) &&
!busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(async () => {
const input: CreateFacilityClassInput = {
facilityId: values.facilityId,
name: values.name.trim(),
description: values.description.trim() || undefined,
recurrenceType: values.recurrenceType,
durationMinutes: Number(values.durationMinutes) || 60,
capacity: values.isUnlimitedCapacity ? undefined : Number(values.capacity) || 1,
isUnlimitedCapacity: values.isUnlimitedCapacity,
}
if (isCustom) {
input.customOccurrences = customRows.map((r) => ({
startTime: new Date(r.localDateTime).toISOString(),
durationMinutes: Number(r.durationMinutes) || 60,
}))
} else {
input.startTimeOfDay = values.startTimeOfDay
input.weekDaysMask = values.weekDaysMask
input.monthDays = values.monthDays.trim() || undefined
input.seriesStartDate = values.seriesStartDate || undefined
input.seriesEndDate = values.seriesEndDate || undefined
}
await createFacilityClass(input)
toast.success("Class created", { description: `"${input.name}" was added.` })
onCreated()
})
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Add Class</DialogTitle>
<DialogDescription>
A recurring class (Zumba, Yoga) that generates its own scheduled sessions. Members
book it like any other session; sell access to it from here via &quot;Manage
Plan&quot; afterward.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label>
Facility <span className="text-destructive">*</span>
</Label>
<Select items={facilities.map((f) => ({ label: f.name, value: f.facilityId }))} value={values.facilityId} onValueChange={(v) => set("facilityId", v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select facility" />
</SelectTrigger>
<SelectContent>
{facilities.map((f) => (
<SelectItem key={f.facilityId} value={f.facilityId}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
{facilities.length === 0 && (
<p className="text-xs text-destructive">
No facility supports classes yet -- check &quot;Classes&quot; on one under Facilities.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-name">
Name <span className="text-destructive">*</span>
</Label>
<Input id="class-name" placeholder="e.g. Zumba" value={values.name} onChange={(e) => set("name", e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-description">Description</Label>
<Textarea id="class-description" maxLength={300} value={values.description} onChange={(e) => set("description", e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Recurrence</Label>
<Select items={RECURRENCE_OPTIONS} value={values.recurrenceType} onValueChange={(v) => set("recurrenceType", (v as RecurrenceType) ?? "Weekly")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECURRENCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!isCustom && (
<>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-time">
Time <span className="text-destructive">*</span>
</Label>
<Input id="class-time" type="time" value={values.startTimeOfDay} onChange={(e) => set("startTimeOfDay", e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-duration">
Duration (min) <span className="text-destructive">*</span>
</Label>
<Input id="class-duration" type="number" min={1} value={values.durationMinutes} onChange={(e) => set("durationMinutes", e.target.value)} required />
</div>
</div>
{values.recurrenceType === "Weekly" && (
<div className="flex flex-col gap-1.5">
<Label>
Days <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
{WEEKDAYS.map((day, i) => (
<label key={day} className="flex flex-col items-center gap-1 text-xs">
<Checkbox checked={(values.weekDaysMask & (1 << i)) !== 0} onCheckedChange={() => toggleWeekday(i)} />
{day}
</label>
))}
</div>
</div>
)}
{values.recurrenceType === "Monthly" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-monthdays">
Days of month <span className="text-destructive">*</span>
</Label>
<Input id="class-monthdays" placeholder="e.g. 1,15" value={values.monthDays} onChange={(e) => set("monthDays", e.target.value)} required />
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-start">Starts</Label>
<Input id="class-start" type="date" value={values.seriesStartDate} onChange={(e) => set("seriesStartDate", e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-end">Ends (optional)</Label>
<Input id="class-end" type="date" value={values.seriesEndDate} onChange={(e) => set("seriesEndDate", e.target.value)} />
</div>
</div>
</>
)}
{isCustom && (
<div className="flex flex-col gap-2">
<Label>
Occurrences <span className="text-destructive">*</span>
</Label>
{customRows.map((row, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
type="datetime-local"
aria-label="Occurrence start"
value={row.localDateTime}
onChange={(e) =>
setCustomRows((rows) => rows.map((r, i) => (i === idx ? { ...r, localDateTime: e.target.value } : r)))
}
required
/>
<Input
type="number"
min={1}
aria-label="Duration (minutes)"
className="w-24"
value={row.durationMinutes}
onChange={(e) =>
setCustomRows((rows) => rows.map((r, i) => (i === idx ? { ...r, durationMinutes: e.target.value } : r)))
}
required
/>
<Button type="button" variant="outline" size="icon-sm" aria-label="Remove occurrence" onClick={() => setCustomRows((rows) => rows.filter((_, i) => i !== idx))}>
<Trash2Icon />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => setCustomRows((rows) => [...rows, { localDateTime: toLocalInputValue(new Date()), durationMinutes: "60" }])}
>
<PlusIcon />
Add occurrence
</Button>
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={values.isUnlimitedCapacity} onCheckedChange={(v) => set("isUnlimitedCapacity", v === true)} />
Unlimited capacity
</label>
{!values.isUnlimitedCapacity && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="class-capacity">Capacity</Label>
<Input id="class-capacity" type="number" min={1} value={values.capacity} onChange={(e) => set("capacity", e.target.value)} />
</div>
)}
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Add Class
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface ManagePlanDialogProps {
facilityClass: FacilityClass | null
onOpenChange: (open: boolean) => void
}
function ManagePlanDialog({ facilityClass, onOpenChange }: ManagePlanDialogProps) {
const [planName, setPlanName] = React.useState("")
const [planPrice, setPlanPrice] = React.useState("0")
const [durationInDays, setDurationInDays] = React.useState("30")
const [sessionsPerCycle, setSessionsPerCycle] = React.useState("")
const [grantsGymAccess, setGrantsGymAccess] = React.useState(false)
const { busy, run } = useMutation()
const [prevClass, setPrevClass] = React.useState(facilityClass)
if (facilityClass !== prevClass) {
setPrevClass(facilityClass)
if (facilityClass) {
setPlanName(`${facilityClass.name} Membership`)
setPlanPrice("0")
setDurationInDays("30")
setSessionsPerCycle("")
setGrantsGymAccess(false)
}
}
const canSubmit = !!facilityClass && !!planName.trim() && Number(planPrice) >= 0 && Number(durationInDays) >= 1 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit || !facilityClass) return
try {
await run(async () => {
await createGymPlan({
planName: planName.trim(),
planPrice: Number(planPrice) || 0,
durationInDays: Number(durationInDays) || 30,
sessionsPerCycle: sessionsPerCycle ? Number(sessionsPerCycle) : undefined,
facilityClassId: facilityClass.facilityClassId,
grantsGymAccess,
})
toast.success("Plan created", { description: `Members can now subscribe to "${facilityClass.name}".` })
})
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={!!facilityClass} onOpenChange={(open) => !open && onOpenChange(false)} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Manage Plan — {facilityClass?.name}</DialogTitle>
<DialogDescription>
Creates a new membership plan sold against this class. Price, duration and other
details can be adjusted afterward from the Plans tab.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="manage-plan-name">
Plan Name <span className="text-destructive">*</span>
</Label>
<Input id="manage-plan-name" value={planName} onChange={(e) => setPlanName(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="manage-plan-price">
Price (Rs.) <span className="text-destructive">*</span>
</Label>
<Input id="manage-plan-price" type="number" min={0} step="0.01" value={planPrice} onChange={(e) => setPlanPrice(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="manage-plan-duration">
Duration (days) <span className="text-destructive">*</span>
</Label>
<Input id="manage-plan-duration" type="number" min={1} value={durationInDays} onChange={(e) => setDurationInDays(e.target.value)} required />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="manage-plan-sessions">Sessions per cycle</Label>
<Input
id="manage-plan-sessions"
type="number"
min={1}
placeholder="Leave blank for unlimited"
value={sessionsPerCycle}
onChange={(e) => setSessionsPerCycle(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={grantsGymAccess} onCheckedChange={(v) => setGrantsGymAccess(v === true)} />
Also grant general gym access (not just this class)
</label>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Create Plan
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface NotHeldDialogProps {
occurrence: ClassOccurrence | null
classId: string
onOpenChange: (open: boolean) => void
onDone: () => void
}
function NotHeldDialog({ occurrence, classId, onOpenChange, onDone }: NotHeldDialogProps) {
const [reason, setReason] = React.useState("")
const { busy, run } = useMutation()
async function handleConfirm() {
if (!occurrence) return
try {
await run(async () => {
await markOccurrenceNotHeld(classId, occurrence.facilitySessionId, reason.trim() || undefined)
toast.success("Marked not held", { description: "Affected bookings were cancelled and refunded; the slot is open again." })
onDone()
})
onOpenChange(false)
setReason("")
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={!!occurrence} onOpenChange={(open) => !open && onOpenChange(false)} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Mark Not Held</DialogTitle>
<DialogDescription>
{occurrence && `${formatDateTime(occurrence.startTime)} won't run.`} Any confirmed
bookings on it are cancelled and refunded, and the slot opens up for walk-in booking.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="not-held-reason">Reason (optional)</Label>
<Textarea id="not-held-reason" maxLength={300} value={reason} onChange={(e) => setReason(e.target.value)} placeholder="e.g. Instructor unavailable" />
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={handleConfirm} disabled={busy}>
{busy && <Spinner className="size-4" />}
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface OccurrencesDialogProps {
facilityClass: FacilityClass | null
onOpenChange: (open: boolean) => void
}
function OccurrencesDialog({ facilityClass, onOpenChange }: OccurrencesDialogProps) {
const occurrencesFetch = useFetch(() =>
facilityClass ? getClassOccurrences(facilityClass.facilityClassId) : Promise.resolve([])
)
const occurrences = occurrencesFetch.data ?? []
const [notHeldTarget, setNotHeldTarget] = React.useState<ClassOccurrence | null>(null)
React.useEffect(() => {
if (facilityClass) occurrencesFetch.reload()
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the class changing
}, [facilityClass, occurrencesFetch.reload])
return (
<Dialog open={!!facilityClass} onOpenChange={(open) => !open && onOpenChange(false)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Occurrences — {facilityClass?.name}</DialogTitle>
<DialogDescription>Generated up to 8 weeks ahead. Past and booked ones can&apos;t be deleted by editing the recurrence.</DialogDescription>
</DialogHeader>
{occurrencesFetch.loading ? (
<div className="flex justify-center py-8">
<Spinner className="size-6" />
</div>
) : occurrences.length === 0 ? (
<p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">No occurrences yet.</p>
) : (
<div className="flex flex-col divide-y rounded-lg border">
{occurrences.map((occ) => (
<div key={occ.facilitySessionId} className="flex items-center justify-between gap-3 p-2.5 text-sm">
<div>
<p className="font-medium">{formatDateTime(occ.startTime)}</p>
<p className="text-xs text-muted-foreground">
{occ.isUnlimitedCapacity ? "Unlimited" : `Capacity ${occ.capacity}`}
{occ.isCancelled && occ.notHeldReason && ` · ${occ.notHeldReason}`}
</p>
</div>
{occ.isCancelled ? (
<Badge variant="outline">Not held</Badge>
) : (
<Button variant="outline" size="sm" onClick={() => setNotHeldTarget(occ)}>
Mark not held
</Button>
)}
</div>
))}
</div>
)}
{facilityClass && (
<NotHeldDialog
occurrence={notHeldTarget}
classId={facilityClass.facilityClassId}
onOpenChange={() => setNotHeldTarget(null)}
onDone={occurrencesFetch.reload}
/>
)}
</DialogContent>
</Dialog>
)
}
function ClassesView() {
const classesFetch = useFetch(() => getFacilityClasses(true))
const classes = classesFetch.data ?? []
const facilitiesFetch = useFetch(() => getFacilities(true))
const classFacilities = (facilitiesFetch.data ?? []).filter((f) => f.supportsClasses && f.isActive)
const conflictsFetch = useFetch(() => getSchedulingConflicts())
const conflicts = conflictsFetch.data ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [planTarget, setPlanTarget] = React.useState<FacilityClass | null>(null)
const [occurrencesTarget, setOccurrencesTarget] = React.useState<FacilityClass | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
async function toggleStatus(cls: FacilityClass) {
setStatusPendingId(cls.facilityClassId)
try {
await runMutation(() => updateFacilityClass(cls.facilityClassId, { isActive: !cls.isActive }))
toast.success(cls.isActive ? "Class deactivated" : "Class activated")
classesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
const columns: DataTableColumn<FacilityClass>[] = [
{
key: "name",
header: "Class",
cell: (row) => (
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CalendarIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.facilityName}</p>
</div>
</div>
),
},
{ key: "recurrence", header: "Recurrence", cell: (row) => recurrenceSummary(row) },
{ key: "capacity", header: "Capacity", cell: (row) => (row.isUnlimitedCapacity ? "Unlimited" : row.capacity ?? "—") },
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={row.isActive ? "success" : "neutral"}>{row.isActive ? "Active" : "Inactive"}</StatusBadge>,
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="View occurrences" onClick={() => setOccurrencesTarget(row)}>
<EyeIcon />
</Button>
}
/>
<TooltipContent>View occurrences</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="sm" aria-label="Manage plan" onClick={() => setPlanTarget(row)}>
Manage Plan
</Button>
}
/>
<TooltipContent>Create a membership plan for this class</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate class" : "Activate class"}
disabled={statusPendingId === row.facilityClassId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.facilityClassId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate class" : "Activate class"}</TooltipContent>
</Tooltip>
</div>
),
},
]
if (classesFetch.loading) {
return <PageSkeleton />
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Classes</h2>
<p className="text-sm text-muted-foreground">Recurring sessions (Zumba, Yoga) that generate their own schedule automatically.</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<PlusIcon />
Add Class
</Button>
</div>
{conflicts.length > 0 && (
<InfoBanner>
{conflicts.length} walk-in booking{conflicts.length > 1 ? "s" : ""} now overlap a scheduled class. Move
{conflicts.length > 1 ? " them" : " it"} from the Roster tab&apos;s Reschedule action:{" "}
{conflicts.map((c) => `${c.facilityName ?? "Facility"} ${formatDateTime(c.startTime)}`).join("; ")}.
</InfoBanner>
)}
<DataTable
columns={columns}
rows={classes}
rowKey={(row) => row.facilityClassId}
emptyMessage="No classes yet. Add one to start generating its schedule."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CalendarIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<p className="text-xs text-muted-foreground">{recurrenceSummary(row)}</p>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>{row.isActive ? "Active" : "Inactive"}</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">{row.isUnlimitedCapacity ? "Unlimited" : `Capacity ${row.capacity}`}</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="View occurrences" onClick={() => setOccurrencesTarget(row)}>
<EyeIcon />
</Button>
<Button variant="outline" size="sm" onClick={() => setPlanTarget(row)}>
Manage Plan
</Button>
</div>
</div>
</div>
)}
/>
<ClassDialog open={dialogOpen} onOpenChange={setDialogOpen} facilities={classFacilities} onCreated={classesFetch.reload} />
<ManagePlanDialog facilityClass={planTarget} onOpenChange={() => setPlanTarget(null)} />
<OccurrencesDialog facilityClass={occurrencesTarget} onOpenChange={() => setOccurrencesTarget(null)} />
</div>
)
}
export { ClassesView }
+77 -50
View File
@@ -25,6 +25,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -42,14 +43,10 @@ import { InfoBanner } from "@/components/hexa/info-banner"
import { PageSkeleton } from "@/components/hexa/page-skeleton" import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge" import { StatusBadge } from "@/components/hexa/status-badge"
const BOOKING_MODE_OPTIONS: { label: string; value: FacilityInput["bookingMode"] }[] = [
{ label: "Scheduled (classes — shared capacity per session)", value: "Scheduled" },
{ label: "Ad-hoc (courts/pool — independently bookable units)", value: "AdHoc" },
]
interface FacilityFormValues { interface FacilityFormValues {
name: string name: string
bookingMode: FacilityInput["bookingMode"] supportsClasses: boolean
supportsBooking: boolean
description: string description: string
defaultCapacity: string defaultCapacity: string
numberOfUnits: string numberOfUnits: string
@@ -58,7 +55,8 @@ interface FacilityFormValues {
const EMPTY_VALUES: FacilityFormValues = { const EMPTY_VALUES: FacilityFormValues = {
name: "", name: "",
bookingMode: "Scheduled", supportsClasses: true,
supportsBooking: false,
description: "", description: "",
defaultCapacity: "1", defaultCapacity: "1",
numberOfUnits: "1", numberOfUnits: "1",
@@ -87,9 +85,15 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
setValues((v) => ({ ...v, [key]: value })) setValues((v) => ({ ...v, [key]: value }))
} }
const isAdhoc = values.bookingMode === "AdHoc" const unitsValid =
const unitsValid = isAdhoc ? Number(values.numberOfUnits) >= 1 : Number(values.defaultCapacity) >= 1 (!values.supportsClasses || Number(values.defaultCapacity) >= 1) &&
const canSubmit = !!values.name.trim() && unitsValid && Number(values.pricePerHour) >= 0 && !busy (!values.supportsBooking || Number(values.numberOfUnits) >= 1)
const canSubmit =
!!values.name.trim() &&
(values.supportsClasses || values.supportsBooking) &&
unitsValid &&
Number(values.pricePerHour) >= 0 &&
!busy
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -128,41 +132,30 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Booking Mode</Label> <Label>
<Select Supports <span className="text-destructive">*</span>
items={BOOKING_MODE_OPTIONS} </Label>
value={values.bookingMode} <label className="flex items-center gap-2 text-sm">
onValueChange={(v) => set("bookingMode", (v as FacilityInput["bookingMode"]) ?? "Scheduled")} <Checkbox
> checked={values.supportsClasses}
<SelectTrigger className="w-full"> onCheckedChange={(v) => set("supportsClasses", v === true)}
<SelectValue placeholder="Select booking mode" /> />
</SelectTrigger> Classes (scheduled sessions — shared capacity)
<SelectContent> </label>
{BOOKING_MODE_OPTIONS.map((opt) => ( <label className="flex items-center gap-2 text-sm">
<SelectItem key={opt.value} value={opt.value}> <Checkbox
{opt.label} checked={values.supportsBooking}
</SelectItem> onCheckedChange={(v) => set("supportsBooking", v === true)}
))} />
</SelectContent> Walk-in booking (courts/pool — independently bookable units)
</Select> </label>
{!values.supportsClasses && !values.supportsBooking && (
<p className="text-xs text-destructive">At least one must be checked.</p>
)}
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{isAdhoc ? ( {values.supportsClasses && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-units">
Number of Units <span className="text-destructive">*</span>
</Label>
<Input
id="facility-units"
type="number"
min={1}
value={values.numberOfUnits}
onChange={(e) => set("numberOfUnits", e.target.value)}
required
/>
</div>
) : (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="facility-capacity"> <Label htmlFor="facility-capacity">
Default Capacity <span className="text-destructive">*</span> Default Capacity <span className="text-destructive">*</span>
@@ -177,6 +170,21 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
/> />
</div> </div>
)} )}
{values.supportsBooking && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-units">
Number of Units <span className="text-destructive">*</span>
</Label>
<Input
id="facility-units"
type="number"
min={1}
value={values.numberOfUnits}
onChange={(e) => set("numberOfUnits", e.target.value)}
required
/>
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="facility-price"> <Label htmlFor="facility-price">
Price / Hour (Rs.) <span className="text-destructive">*</span> Price / Hour (Rs.) <span className="text-destructive">*</span>
@@ -234,7 +242,8 @@ function FacilitiesView() {
async function handleCreate(values: FacilityFormValues) { async function handleCreate(values: FacilityFormValues) {
const input: FacilityInput = { const input: FacilityInput = {
name: values.name.trim(), name: values.name.trim(),
bookingMode: values.bookingMode, supportsClasses: values.supportsClasses,
supportsBooking: values.supportsBooking,
description: values.description.trim() || undefined, description: values.description.trim() || undefined,
defaultCapacity: Number(values.defaultCapacity) || 1, defaultCapacity: Number(values.defaultCapacity) || 1,
numberOfUnits: Number(values.numberOfUnits) || 1, numberOfUnits: Number(values.numberOfUnits) || 1,
@@ -249,7 +258,8 @@ function FacilitiesView() {
if (!editTarget) return if (!editTarget) return
await updateFacility(editTarget.facilityId, { await updateFacility(editTarget.facilityId, {
name: values.name.trim(), name: values.name.trim(),
bookingMode: values.bookingMode, supportsClasses: values.supportsClasses,
supportsBooking: values.supportsBooking,
description: values.description.trim() || undefined, description: values.description.trim() || undefined,
defaultCapacity: Number(values.defaultCapacity) || 1, defaultCapacity: Number(values.defaultCapacity) || 1,
numberOfUnits: Number(values.numberOfUnits) || 1, numberOfUnits: Number(values.numberOfUnits) || 1,
@@ -313,10 +323,18 @@ function FacilitiesView() {
key: "mode", key: "mode",
header: "Mode", header: "Mode",
cell: (row) => ( cell: (row) => (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-1">
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge> <div className="flex gap-1">
{row.supportsClasses && <Badge variant="outline">Classes</Badge>}
{row.supportsBooking && <Badge variant="default">Walk-in</Badge>}
</div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`} {[
row.supportsClasses ? `Capacity ${row.defaultCapacity}` : null,
row.supportsBooking ? `${row.numberOfUnits} units` : null,
]
.filter(Boolean)
.join(" · ")}
</span> </span>
</div> </div>
), ),
@@ -445,7 +463,10 @@ function FacilitiesView() {
</div> </div>
<div> <div>
<p className="font-medium">{row.name}</p> <p className="font-medium">{row.name}</p>
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge> <div className="flex gap-1">
{row.supportsClasses && <Badge variant="outline">Classes</Badge>}
{row.supportsBooking && <Badge variant="default">Walk-in</Badge>}
</div>
</div> </div>
</div> </div>
<StatusBadge status={row.isActive ? "success" : "neutral"}> <StatusBadge status={row.isActive ? "success" : "neutral"}>
@@ -454,7 +475,12 @@ function FacilitiesView() {
</div> </div>
<div className="flex items-center justify-between border-t pt-2.5"> <div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`} · Rs. {row.pricePerHour.toLocaleString()}/hr {[
row.supportsClasses ? `Capacity ${row.defaultCapacity}` : null,
row.supportsBooking ? `${row.numberOfUnits} units` : null,
]
.filter(Boolean)
.join(" · ")} · Rs. {row.pricePerHour.toLocaleString()}/hr
</span> </span>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => startEdit(row)}> <Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => startEdit(row)}>
@@ -495,7 +521,8 @@ function FacilitiesView() {
dialogMode === "edit" && editTarget dialogMode === "edit" && editTarget
? { ? {
name: editTarget.name, name: editTarget.name,
bookingMode: editTarget.bookingMode, supportsClasses: editTarget.supportsClasses,
supportsBooking: editTarget.supportsBooking,
description: editTarget.description ?? "", description: editTarget.description ?? "",
defaultCapacity: String(editTarget.defaultCapacity), defaultCapacity: String(editTarget.defaultCapacity),
numberOfUnits: String(editTarget.numberOfUnits), numberOfUnits: String(editTarget.numberOfUnits),
@@ -1,5 +1,6 @@
"use client" "use client"
import { ClassesView } from "@/components/hexa/views/classes-view"
import { FacilitiesView } from "@/components/hexa/views/facilities-view" import { FacilitiesView } from "@/components/hexa/views/facilities-view"
import { GymPlansView } from "@/components/hexa/views/gym-plans-view" import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -10,6 +11,7 @@ function MembershipsView() {
<TabsList> <TabsList>
<TabsTrigger value="plans">Plans</TabsTrigger> <TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="facilities">Facilities</TabsTrigger> <TabsTrigger value="facilities">Facilities</TabsTrigger>
<TabsTrigger value="classes">Classes</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="plans"> <TabsContent value="plans">
@@ -18,6 +20,9 @@ function MembershipsView() {
<TabsContent value="facilities"> <TabsContent value="facilities">
<FacilitiesView /> <FacilitiesView />
</TabsContent> </TabsContent>
<TabsContent value="classes">
<ClassesView />
</TabsContent>
</Tabs> </Tabs>
) )
} }
@@ -5,7 +5,7 @@ import { CopyIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { getFacilityAvailability } from "@/lib/api/facilities" import { getFacilityAvailability } from "@/lib/api/facilities"
import { createGuestAdhocBooking } from "@/lib/api/bookings" import { createGuestWalkInBooking } from "@/lib/api/bookings"
import { getMembers } from "@/lib/api/customers" import { getMembers } from "@/lib/api/customers"
import { ApiError } from "@/lib/api/core-http" import { ApiError } from "@/lib/api/core-http"
import type { FacilityAvailability } from "@/lib/models/facility" import type { FacilityAvailability } from "@/lib/models/facility"
@@ -32,18 +32,44 @@ function toLocalInputValue(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
interface AdhocBookingTabProps { interface WalkInBookingTabProps {
facilities: Facility[] facilities: Facility[]
/** Set when the Dashboard calendar's drag-to-quick-book hands up a dragged time range --
* seeds the start/duration fields once, then must be cleared by the caller via
* onPrefillConsumed so it isn't re-applied on an unrelated re-render. */
prefill?: { startTime: string; endTime: string } | null
onPrefillConsumed?: () => void
} }
function AdhocBookingTab({ facilities }: AdhocBookingTabProps) { function WalkInBookingTab({ facilities, prefill, onPrefillConsumed }: WalkInBookingTabProps) {
const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive) const walkInFacilities = facilities.filter((f) => f.supportsBooking && f.isActive)
const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "") const [facilityId, setFacilityId] = React.useState(walkInFacilities[0]?.facilityId ?? "")
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date())) const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
const [hours, setHours] = React.useState("1") const [hours, setHours] = React.useState("1")
const [minutes, setMinutes] = React.useState("0") const [minutes, setMinutes] = React.useState("0")
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
// Same "reset on prop change" convention used by every dialog's prevOpen/prevBooking state in
// this codebase -- a plain render-body comparison, not an effect, so it can set local state
// directly without a cascading-render lint warning.
const [prevPrefill, setPrevPrefill] = React.useState(prefill)
if (prefill !== prevPrefill) {
setPrevPrefill(prefill)
if (prefill) {
setStartTime(toLocalInputValue(new Date(prefill.startTime)))
const totalMinutes = Math.max(15, Math.round((new Date(prefill.endTime).getTime() - new Date(prefill.startTime).getTime()) / 60000))
setHours(String(Math.floor(totalMinutes / 60)))
setMinutes(String(totalMinutes % 60))
}
}
// Notifying the parent to clear its draft is a genuine side effect (a different component's
// state), so it stays in an effect rather than the render body above.
React.useEffect(() => {
if (prefill) onPrefillConsumed?.()
// eslint-disable-next-line react-hooks/exhaustive-deps -- only fire when a new prefill arrives
}, [prefill])
const [pricePerHour, setPricePerHour] = React.useState(() => String(walkInFacilities[0]?.pricePerHour ?? 0))
const [partySize, setPartySize] = React.useState("") const [partySize, setPartySize] = React.useState("")
const [customerId, setCustomerId] = React.useState("") const [customerId, setCustomerId] = React.useState("")
const [guestName, setGuestName] = React.useState("") const [guestName, setGuestName] = React.useState("")
@@ -73,14 +99,14 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0) const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60 const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60
const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId })) const facilityOptions = walkInFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking
function selectFacility(id: string) { function selectFacility(id: string) {
setFacilityId(id) setFacilityId(id)
setAvailability(null) setAvailability(null)
const facility = adhocFacilities.find((f) => f.facilityId === id) const facility = walkInFacilities.find((f) => f.facilityId === id)
setPricePerHour(String(facility?.pricePerHour ?? 0)) setPricePerHour(String(facility?.pricePerHour ?? 0))
} }
@@ -107,7 +133,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
if (!canSubmit) return if (!canSubmit) return
try { try {
const bookingResult = await runBook(() => const bookingResult = await runBook(() =>
createGuestAdhocBooking({ createGuestWalkInBooking({
facilityId, facilityId,
startTime: new Date(startTime).toISOString(), startTime: new Date(startTime).toISOString(),
durationMinutes, durationMinutes,
@@ -139,10 +165,10 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
} }
} }
if (adhocFacilities.length === 0) { if (walkInFacilities.length === 0) {
return ( return (
<InfoBanner> <InfoBanner>
No ad-hoc facilities yet. Add a facility with Booking Mode set to &quot;Ad-hoc&quot; under No walk-in facilities yet. Add a facility and check &quot;Walk-in booking&quot; under
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here. Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
</InfoBanner> </InfoBanner>
) )
@@ -170,11 +196,11 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-start"> <Label htmlFor="walkin-start">
Start <span className="text-destructive">*</span> Start <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="adhoc-start" id="walkin-start"
type="datetime-local" type="datetime-local"
value={startTime} value={startTime}
onChange={(e) => { onChange={(e) => {
@@ -222,11 +248,11 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-price-per-hour"> <Label htmlFor="walkin-price-per-hour">
Price / Hour (Rs.) <span className="text-destructive">*</span> Price / Hour (Rs.) <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="adhoc-price-per-hour" id="walkin-price-per-hour"
type="number" type="number"
min={0} min={0}
step="0.01" step="0.01"
@@ -244,9 +270,9 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-party-size">Party Size</Label> <Label htmlFor="walkin-party-size">Party Size</Label>
<Input <Input
id="adhoc-party-size" id="walkin-party-size"
type="number" type="number"
min={1} min={1}
placeholder="Optional headcount" placeholder="Optional headcount"
@@ -262,8 +288,8 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
</Button> </Button>
{availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>} {availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>}
{availability && ( {availability && (
<StatusBadge status={availability.unitsFree > 0 ? "success" : "danger"}> <StatusBadge status={(availability.bookingUnitsFree ?? 0) > 0 ? "success" : "danger"}>
{availability.unitsFree} / {availability.totalUnits} units free {availability.bookingUnitsFree ?? 0} / {availability.bookingTotalUnits ?? 0} units free
</StatusBadge> </StatusBadge>
)} )}
</div> </div>
@@ -286,16 +312,16 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-guest-name"> <Label htmlFor="walkin-guest-name">
Guest Name <span className="text-destructive">*</span> Guest Name <span className="text-destructive">*</span>
</Label> </Label>
<Input id="adhoc-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required /> <Input id="walkin-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-guest-phone"> <Label htmlFor="walkin-guest-phone">
Mobile Number <span className="text-destructive">*</span> Mobile Number <span className="text-destructive">*</span>
</Label> </Label>
<Input id="adhoc-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required /> <Input id="walkin-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
</div> </div>
</div> </div>
@@ -315,4 +341,4 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
) )
} }
export { AdhocBookingTab } export { WalkInBookingTab }
+12 -12
View File
@@ -3,16 +3,16 @@ import type { BookingDashboardSummary } from "@/lib/models/booking-dashboard"
import type { import type {
Booking, Booking,
CompleteBookingInput, CompleteBookingInput,
CreateAdhocBookingInput,
CreateGuestAdhocBookingInput,
CreateGuestBookingInput, CreateGuestBookingInput,
CreateGuestWalkInBookingInput,
CreateWalkInBookingInput,
GetBookingsFilters, GetBookingsFilters,
MarkGuestBookingPaidInput, MarkGuestBookingPaidInput,
ReassignBookingInput, ReassignBookingInput,
RescheduleBookingInput, RescheduleBookingInput,
} from "@/lib/models/booking" } from "@/lib/models/booking"
/** Unified roster across both Scheduled and AdHoc bookings. */ /** Unified roster across both Scheduled and WalkIn bookings. */
function getBookings(filters: GetBookingsFilters = {}) { function getBookings(filters: GetBookingsFilters = {}) {
return apiRequest<Booking[]>("client/bookings", { query: filters }) return apiRequest<Booking[]>("client/bookings", { query: filters })
} }
@@ -27,14 +27,14 @@ function createGuestBooking(input: CreateGuestBookingInput) {
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input }) return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
} }
/** Member self-service AdHoc booking. */ /** Member self-service walk-in booking. */
function createAdhocBooking(input: CreateAdhocBookingInput) { function createWalkInBooking(input: CreateWalkInBookingInput) {
return apiRequest<Booking>("app/bookings/adhoc", { method: "POST", body: input }) return apiRequest<Booking>("app/bookings/walkin", { method: "POST", body: input })
} }
/** Staff-initiated walk-in AdHoc booking — same public endpoint the guest AdHoc flow uses. */ /** Staff-initiated walk-in booking — same public endpoint the guest walk-in flow uses. */
function createGuestAdhocBooking(input: CreateGuestAdhocBookingInput) { function createGuestWalkInBooking(input: CreateGuestWalkInBookingInput) {
return apiRequest<Booking>("public/bookings/guest/adhoc", { method: "POST", body: input }) return apiRequest<Booking>("public/bookings/guest/walkin", { method: "POST", body: input })
} }
function checkInBooking(otpCode: string, rfidTagId?: string) { function checkInBooking(otpCode: string, rfidTagId?: string) {
@@ -60,7 +60,7 @@ function reassignBooking(id: string, input: ReassignBookingInput) {
return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input }) return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input })
} }
/** Moves a Confirmed booking to a new time (AdHoc) or a new session (Scheduled) — only onto a free slot. */ /** Moves a Confirmed booking to a new time (WalkIn) or a new session (Scheduled) — only onto a free slot. */
function rescheduleBooking(id: string, input: RescheduleBookingInput) { function rescheduleBooking(id: string, input: RescheduleBookingInput) {
return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input }) return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input })
} }
@@ -81,8 +81,8 @@ export {
getBookings, getBookings,
getSessionBookings, getSessionBookings,
createGuestBooking, createGuestBooking,
createAdhocBooking, createWalkInBooking,
createGuestAdhocBooking, createGuestWalkInBooking,
checkInBooking, checkInBooking,
markGuestBookingPaid, markGuestBookingPaid,
cancelBooking, cancelBooking,
+1 -1
View File
@@ -34,7 +34,7 @@ function assignFacilityDevice(id: string, deviceId: string | null) {
}) })
} }
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) { function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string; includeCancelled?: boolean } = {}) {
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params }) return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
} }
+46
View File
@@ -0,0 +1,46 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
ClassOccurrence,
CreateFacilityClassInput,
FacilityClass,
SchedulingConflict,
UpdateFacilityClassInput,
} from "@/lib/models/facility-class"
function getFacilityClasses(includeInactive = true) {
return apiRequest<FacilityClass[]>("client/facility-classes", { query: { includeInactive } })
}
function createFacilityClass(input: CreateFacilityClassInput) {
return apiRequest<FacilityClass>("client/facility-classes", { method: "POST", body: input })
}
function updateFacilityClass(id: string, input: UpdateFacilityClassInput) {
return apiRequest<FacilityClass>(`client/facility-classes/${id}`, { method: "PUT", body: input })
}
function getClassOccurrences(id: string, includeCancelled = true) {
return apiRequest<ClassOccurrence[]>(`client/facility-classes/${id}/occurrences`, { query: { includeCancelled } })
}
/** "Mark not held" -- cascades to cancel + refund every Confirmed booking on the occurrence,
* and frees the slot back up for walk-in booking. */
function markOccurrenceNotHeld(classId: string, occurrenceId: string, reason?: string) {
return apiRequest<unknown>(`client/facility-classes/${classId}/occurrences/${occurrenceId}/not-held`, {
method: "POST",
body: { reason },
})
}
function getSchedulingConflicts() {
return apiRequest<SchedulingConflict[]>("client/facility-classes/scheduling-conflicts")
}
export {
getFacilityClasses,
createFacilityClass,
updateFacilityClass,
getClassOccurrences,
markOccurrenceNotHeld,
getSchedulingConflicts,
}
+6 -6
View File
@@ -1,18 +1,18 @@
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed" export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
export type PaymentMethod = "Cash" | "Bank" export type PaymentMethod = "Cash" | "Bank"
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc /** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and WalkIn
* (facilitySessionId null, exclusive-unit) bookings. */ * (facilitySessionId null, exclusive-unit) bookings. */
export interface Booking { export interface Booking {
bookingId: string bookingId: string
/** Null for an AdHoc booking — no pre-created session. */ /** Null for a WalkIn booking — no pre-created session. */
facilitySessionId?: string | null facilitySessionId?: string | null
facilityId: string facilityId: string
facilityName?: string | null facilityName?: string | null
bookingMode: "Scheduled" | "AdHoc" bookingMode: "Scheduled" | "WalkIn"
startTime: string startTime: string
endTime: string endTime: string
/** Informational headcount, AdHoc only. */ /** Informational headcount, WalkIn only. */
partySize?: number | null partySize?: number | null
isGuest: boolean isGuest: boolean
@@ -59,14 +59,14 @@ export interface CreateGuestBookingInput {
customerId?: string customerId?: string
} }
export interface CreateAdhocBookingInput { export interface CreateWalkInBookingInput {
facilityId: string facilityId: string
startTime: string startTime: string
durationMinutes: number durationMinutes: number
partySize?: number partySize?: number
} }
export interface CreateGuestAdhocBookingInput { export interface CreateGuestWalkInBookingInput {
facilityId: string facilityId: string
startTime: string startTime: string
durationMinutes: number durationMinutes: number
+90
View File
@@ -0,0 +1,90 @@
export type RecurrenceType = "Daily" | "Weekly" | "Monthly" | "Custom"
/** Mirrors FacilityClassDto. A recurring class definition (Zumba, Yoga) that generates
* FacilitySession "occurrences" on a schedule. */
export interface FacilityClass {
facilityClassId: string
facilityId: string
facilityName?: string | null
name: string
description?: string | null
recurrenceType: RecurrenceType
/** "HH:mm" local time -- null for Custom. */
startTimeOfDay?: string | null
durationMinutes: number
/** Weekly only. Bit flags, Sunday = 1 (bit 0) through Saturday = 64 (bit 6). */
weekDaysMask: number
/** Monthly only. Comma-separated day-of-month list, e.g. "1,15". */
monthDays?: string | null
seriesStartDate: string
seriesEndDate?: string | null
capacity?: number | null
isUnlimitedCapacity: boolean
isActive: boolean
createdAt: string
}
export interface CustomOccurrenceInput {
/** ISO UTC instant (e.g. new Date(...).toISOString()), same convention as every other
* datetime input in this app -- the browser resolves local time to UTC before sending. */
startTime: string
durationMinutes: number
}
export interface CreateFacilityClassInput {
facilityId: string
name: string
description?: string
recurrenceType: RecurrenceType
/** "HH:mm" local time -- required unless recurrenceType is "Custom". */
startTimeOfDay?: string
durationMinutes: number
weekDaysMask?: number
monthDays?: string
seriesStartDate?: string
seriesEndDate?: string
capacity?: number
isUnlimitedCapacity?: boolean
/** "Custom" only -- explicit occurrence windows. */
customOccurrences?: CustomOccurrenceInput[]
}
export interface UpdateFacilityClassInput {
name?: string
description?: string
recurrenceType?: RecurrenceType
startTimeOfDay?: string
durationMinutes?: number
weekDaysMask?: number
monthDays?: string
seriesStartDate?: string
seriesEndDate?: string
capacity?: number
isUnlimitedCapacity?: boolean
isActive?: boolean
/** "Custom" only -- adds more explicit occurrences without touching existing ones. */
addCustomOccurrences?: CustomOccurrenceInput[]
}
/** One row in a class's occurrence history. */
export interface ClassOccurrence {
facilitySessionId: string
startTime: string
endTime: string
capacity?: number | null
isUnlimitedCapacity: boolean
isCancelled: boolean
notHeldReason?: string | null
notHeldAt?: string | null
}
/** A confirmed walk-in booking whose window overlaps an active class occurrence on the same
* facility -- surfaced so staff can resolve it via the Roster tab's Reschedule action. */
export interface SchedulingConflict {
bookingId: string
facilityId: string
facilityName?: string | null
guestName?: string | null
startTime: string
endTime: string
}
+25 -11
View File
@@ -1,16 +1,17 @@
export type BookingMode = "Scheduled" | "AdHoc"
/** Mirrors FacilityDto. A bookable area (gym floor, pool, court, class) that's also the /** Mirrors FacilityDto. A bookable area (gym floor, pool, court, class) that's also the
* access-control zone for its optional fingerprint/face terminal — merged from the former * access-control zone for its optional fingerprint/face terminal — merged from the former
* separate Facility/Place concepts. */ * separate Facility/Place concepts. The two flags are independent: a court can support classes
* AND ad-hoc walk-in booking at once (a scheduled class occupies the whole facility for its
* window; ad-hoc booking is only available outside that window). At least one is always true. */
export interface Facility { export interface Facility {
facilityId: string facilityId: string
name: string name: string
bookingMode: BookingMode supportsClasses: boolean
supportsBooking: boolean
description?: string | null description?: string | null
/** Meaningful when bookingMode === "Scheduled" — shared capacity per session. */ /** Meaningful when supportsClasses — shared capacity per session. */
defaultCapacity: number defaultCapacity: number
/** Meaningful when bookingMode === "AdHoc" — e.g. 3 for "3 courts", each independently bookable. */ /** Meaningful when supportsBooking — e.g. 3 for "3 courts", each independently bookable. */
numberOfUnits: number numberOfUnits: number
pricePerHour: number pricePerHour: number
isActive: boolean isActive: boolean
@@ -21,7 +22,8 @@ export interface Facility {
export interface FacilityInput { export interface FacilityInput {
name: string name: string
bookingMode: BookingMode supportsClasses: boolean
supportsBooking: boolean
description?: string description?: string
defaultCapacity: number defaultCapacity: number
numberOfUnits: number numberOfUnits: number
@@ -36,8 +38,15 @@ export interface FacilitySession {
startTime: string startTime: string
endTime: string endTime: string
capacity: number capacity: number
/** When true, capacity above is meaningless -- this occurrence has no cap. */
isUnlimitedCapacity: boolean
bookedCount: number bookedCount: number
isCancelled: boolean isCancelled: boolean
/** Null for a manually-created session; set for a class occurrence. */
facilityClassId?: string | null
facilityClassName?: string | null
notHeldReason?: string | null
notHeldAt?: string | null
} }
export interface FacilitySessionInput { export interface FacilitySessionInput {
@@ -48,12 +57,17 @@ export interface FacilitySessionInput {
capacity?: number capacity?: number
} }
/** Mirrors FacilityAvailabilityDto. */ /** Mirrors FacilityAvailabilityDto. A facility can support both booking and classes at once, so
* the two number pairs are independently nullable — null means "this facility doesn't offer that
* mode," not zero. bookingUnitsFree is forced to 0 when an active class occupies the window. */
export interface FacilityAvailability { export interface FacilityAvailability {
facilityId: string facilityId: string
facilityName: string facilityName: string
bookingMode: BookingMode supportsClasses: boolean
totalUnits: number supportsBooking: boolean
unitsFree: number bookingTotalUnits?: number | null
bookingUnitsFree?: number | null
classTotalCapacity?: number | null
classUnitsFree?: number | null
pricePerHour: number pricePerHour: number
} }
+8
View File
@@ -46,4 +46,12 @@ export interface GymPlanInput {
installmentCount?: number installmentCount?: number
/** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */ /** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */
placeIds?: string[] placeIds?: string[]
/** Null/omitted = unlimited-access plan. A number = this plan grants that many bookable
* sessions per billing cycle -- used by the Classes tab's "Manage Plan" action. */
sessionsPerCycle?: number
/** Classes tab's "Manage Plan" action only -- links the new plan to one class. */
facilityClassId?: string
/** Only meaningful with facilityClassId -- whether this class plan also grants general
* gym-floor access instead of class-only. Defaults to false (class-only) when omitted. */
grantsGymAccess?: boolean
} }