698fd4239b
- Added FullCalendar component to display bookings in a calendar view. - Implemented BookingDashboardTab to show summary statistics including bookings today, revenue, checked-in guests, and outstanding payments. - Created BookingInvoicesTab to manage and display booking payment receipts separately from membership invoices. - Enhanced AdhocBookingTab to allow selection of existing members for guest bookings. - Updated BookingRosterTab to include payment methods and improved UI for guest identification. - Introduced new API endpoints for fetching booking invoices and dashboard summaries. - Updated models to support new features including payment methods and invoice structures.
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
import {
|
|
cancelFacilitySession,
|
|
createFacilitySession,
|
|
getFacilities,
|
|
getFacilitySessions,
|
|
} from "@/lib/api/facilities"
|
|
import type { Facility, FacilitySession } from "@/lib/models/facility"
|
|
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
|
import { PageHeader } from "@/components/hexa/page-header"
|
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
|
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
|
|
import { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab"
|
|
import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab"
|
|
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
|
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-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() {
|
|
const facilitiesFetch = useFetch(() => getFacilities(true))
|
|
const facilities = facilitiesFetch.data ?? []
|
|
|
|
if (facilitiesFetch.loading) {
|
|
return <PageSkeleton variant="stats-table" />
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<PageHeader
|
|
title="Bookings"
|
|
description="Dashboard, scheduled sessions, ad-hoc reservations, the booking roster, invoices, RFID tags and time-bank cards"
|
|
/>
|
|
|
|
<Tabs defaultValue="dashboard" className="gap-4">
|
|
<TabsList>
|
|
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
|
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
|
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
|
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
|
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
|
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="dashboard">
|
|
<BookingDashboardTab />
|
|
</TabsContent>
|
|
<TabsContent value="sessions">
|
|
<SessionsTab facilities={facilities} />
|
|
</TabsContent>
|
|
<TabsContent value="adhoc">
|
|
<AdhocBookingTab facilities={facilities} />
|
|
</TabsContent>
|
|
<TabsContent value="roster">
|
|
<BookingRosterTab facilities={facilities} />
|
|
</TabsContent>
|
|
<TabsContent value="invoices">
|
|
<BookingInvoicesTab />
|
|
</TabsContent>
|
|
<TabsContent value="rfid-tags">
|
|
<RfidTagsTab />
|
|
</TabsContent>
|
|
<TabsContent value="time-bank-cards">
|
|
<TimeBankCardsTab facilities={facilities} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- 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 }
|