Files
Client_Fe/src/components/hexa/views/bookings-view.tsx
T
HarithaRandunu 698fd4239b feat: integrate FullCalendar for booking management and add booking dashboard
- 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.
2026-10-01 03:45:55 +05:30

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 &amp; 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 }