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.
This commit is contained in:
2026-10-01 03:45:55 +05:30
parent 0ba1e6a9e6
commit 698fd4239b
13 changed files with 752 additions and 55 deletions
+65
View File
@@ -9,6 +9,11 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.6.0",
"@fullcalendar/core": "^6.1.21",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@microsoft/signalr": "^8.0.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -985,6 +990,56 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fullcalendar/core": {
"version": "6.1.21",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.21",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.21",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@fullcalendar/react": {
"version": "6.1.21",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz",
"integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.21",
"react": "^16.7.0 || ^17 || ^18 || ^19",
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.21",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz",
"integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.21"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.21"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -8682,6 +8737,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+5
View File
@@ -10,6 +10,11 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@fullcalendar/core": "^6.1.21",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@microsoft/signalr": "^8.0.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -6,9 +6,11 @@ import { toast } from "sonner"
import { getFacilityAvailability } from "@/lib/api/facilities"
import { createGuestAdhocBooking } from "@/lib/api/bookings"
import { getMembers } from "@/lib/api/customers"
import { ApiError } from "@/lib/api/core-http"
import type { FacilityAvailability } from "@/lib/models/facility"
import type { Facility } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -43,9 +45,26 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
const [minutes, setMinutes] = React.useState("0")
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
const [partySize, setPartySize] = React.useState("")
const [customerId, setCustomerId] = React.useState("")
const [guestName, setGuestName] = React.useState("")
const [guestPhone, setGuestPhone] = React.useState("")
const membersFetch = useFetch(() => getMembers())
const members = (membersFetch.data ?? []).filter((m) => !!m.customer)
const memberOptions = [
{ label: "New guest (not registered)", value: "none" },
...members.map((m) => ({ label: m.customer!.cusName ?? m.customer!.primaryPH ?? "Member", value: m.fkCustomerId })),
]
function selectMember(id: string) {
setCustomerId(id === "none" ? "" : id)
const member = id !== "none" ? members.find((m) => m.fkCustomerId === id) : undefined
if (member?.customer) {
setGuestName(member.customer.cusName ?? "")
setGuestPhone(member.customer.primaryPH ?? "")
}
}
const [availability, setAvailability] = React.useState<FacilityAvailability | null>(null)
const [availabilityError, setAvailabilityError] = React.useState<string | null>(null)
const { busy: checking, run: runCheck } = useMutation()
@@ -96,6 +115,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
guestPhone: guestPhone.trim(),
partySize: partySize ? Number(partySize) : undefined,
pricePerHour: Number(pricePerHour),
customerId: customerId || undefined,
})
)
if (bookingResult.shareUrl) {
@@ -111,6 +131,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
setGuestName("")
setGuestPhone("")
setPartySize("")
setCustomerId("")
setAvailability(null)
handleCheckAvailability()
} catch {
@@ -247,7 +268,23 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
)}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5 border-t pt-3">
<Label>Select existing member (optional)</Label>
<Select items={memberOptions} value={customerId || "none"} onValueChange={(v) => selectMember(v ?? "none")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={membersFetch.loading ? "Loading…" : "New guest (not registered)"} />
</SelectTrigger>
<SelectContent>
{memberOptions.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="adhoc-guest-name">
Guest Name <span className="text-destructive">*</span>
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import interactionPlugin from "@fullcalendar/interaction"
import type { DatesSetArg, EventInput } from "@fullcalendar/core"
import { getBookings } from "@/lib/api/bookings"
import { ApiError } from "@/lib/api/core-http"
import type { Booking, BookingStatus } from "@/lib/models/booking"
const STATUS_COLORS: Record<BookingStatus, string> = {
Confirmed: "#2563eb",
CheckedIn: "#16a34a",
Completed: "#6b7280",
NoShow: "#dc2626",
Cancelled: "#dc2626",
}
function bookingToEvent(booking: Booking): EventInput {
const color = STATUS_COLORS[booking.status] ?? "#2563eb"
return {
id: booking.bookingId,
title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`,
start: booking.startTime,
end: booking.endTime,
backgroundColor: color,
borderColor: color,
}
}
/** Read-only calendar view of bookings — reuses the existing roster's getBookings query,
* refetched for whatever range FullCalendar currently has visible. */
function BookingCalendar() {
const [events, setEvents] = React.useState<EventInput[]>([])
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
async function handleDatesSet(arg: DatesSetArg) {
setLoading(true)
setError(null)
try {
const bookings = await getBookings({
fromDate: arg.start.toISOString(),
toDate: arg.end.toISOString(),
})
setEvents(bookings.map(bookingToEvent))
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't load bookings for this range.")
} finally {
setLoading(false)
}
}
return (
<div className="rounded-lg border p-3">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{loading && <span>Loading…</span>}
{error && <span className="text-destructive">{error}</span>}
{(Object.keys(STATUS_COLORS) as BookingStatus[]).map((status) => (
<span key={status} className="flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-full" style={{ backgroundColor: STATUS_COLORS[status] }} />
{status}
</span>
))}
</div>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek"
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
height="auto"
nowIndicator
events={events}
datesSet={handleDatesSet}
eventDisplay="block"
/>
</div>
)
}
export { BookingCalendar }
@@ -0,0 +1,76 @@
"use client"
import dynamic from "next/dynamic"
import {
CalendarClockIcon,
CheckCheckIcon,
ClipboardListIcon,
DollarSignIcon,
TriangleAlertIcon,
} from "lucide-react"
import { getBookingDashboardSummary } from "@/lib/api/bookings"
import { useFetch } from "@/lib/use-fetch"
import { Spinner } from "@/components/ui/spinner"
import { StatCard } from "@/components/hexa/stat-card"
// FullCalendar touches the DOM at import time -- must not run during server-side rendering.
const BookingCalendar = dynamic(
() => import("@/components/hexa/views/booking-calendar").then((m) => m.BookingCalendar),
{
ssr: false,
loading: () => (
<div className="flex h-[500px] items-center justify-center rounded-lg border">
<Spinner className="size-6" />
</div>
),
}
)
function BookingDashboardTab() {
const summaryFetch = useFetch(() => getBookingDashboardSummary())
const summary = summaryFetch.data
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<StatCard
icon={ClipboardListIcon}
tone="blue"
label="Bookings Today"
value={summaryFetch.loading ? "…" : (summary?.bookingsToday ?? 0)}
/>
<StatCard
icon={DollarSignIcon}
tone="green"
label="Revenue Today"
value={summaryFetch.loading ? "…" : `Rs. ${(summary?.revenueToday ?? 0).toLocaleString()}`}
/>
<StatCard
icon={CheckCheckIcon}
tone="purple"
label="Checked In Now"
value={summaryFetch.loading ? "…" : (summary?.checkedInNow ?? 0)}
/>
<StatCard
icon={CalendarClockIcon}
tone="amber"
label="Upcoming"
value={summaryFetch.loading ? "…" : (summary?.upcomingConfirmed ?? 0)}
hint="Confirmed, not yet checked in"
/>
<StatCard
icon={TriangleAlertIcon}
tone="amber"
label="Outstanding"
value={summaryFetch.loading ? "…" : `Rs. ${(summary?.outstandingUnpaid ?? 0).toLocaleString()}`}
hint="Unpaid guest bookings"
/>
</div>
<BookingCalendar />
</div>
)
}
export { BookingDashboardTab }
@@ -0,0 +1,188 @@
"use client"
import * as React from "react"
import { CopyIcon, ExternalLinkIcon, ReceiptIcon } from "lucide-react"
import { toast } from "sonner"
import { getBookingInvoices } from "@/lib/api/booking-invoices"
import type { BookingInvoice } from "@/lib/models/booking-invoice"
import { useFetch } from "@/lib/use-fetch"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
}
function BookingInvoicesTab() {
const invoicesFetch = useFetch(() => getBookingInvoices())
const invoices = invoicesFetch.data ?? []
function copyReceiptLink(invoice: BookingInvoice) {
if (!invoice.shareUrl) return
navigator.clipboard.writeText(invoice.shareUrl)
toast.success("Receipt link copied")
}
function openReceipt(invoice: BookingInvoice) {
if (!invoice.shareUrl) return
window.open(invoice.shareUrl, "_blank", "noopener,noreferrer")
}
const columns: DataTableColumn<BookingInvoice>[] = [
{
key: "facility",
header: "Facility & Session",
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">
<ReceiptIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.facilityName}</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)}
</p>
</div>
</div>
),
},
{
key: "guest",
header: "Guest",
cell: (row) => (
<div>
<p className="font-medium">{row.guestName}</p>
{row.guestPhone && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
</div>
),
},
{
key: "amount",
header: "Amount",
cell: (row) => <span className="font-medium">Rs. {row.amount.toLocaleString()}</span>,
},
{
key: "method",
header: "Payment Method",
cell: (row) => <Badge variant="outline">{row.paymentMethod}</Badge>,
},
{ key: "issued", header: "Issued", cell: (row) => formatDateTime(row.issuedAt) },
{
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="Open receipt"
disabled={!row.shareUrl}
onClick={() => openReceipt(row)}
>
<ExternalLinkIcon />
</Button>
}
/>
<TooltipContent>Open receipt</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Copy receipt link"
disabled={!row.shareUrl}
onClick={() => copyReceiptLink(row)}
>
<CopyIcon />
</Button>
}
/>
<TooltipContent>Copy receipt link</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
{invoicesFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={invoices}
rowKey={(row) => row.bookingInvoiceId}
emptyMessage="No booking payments recorded yet."
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">
<ReceiptIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.facilityName}</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)}
</p>
<p className="text-sm">{row.guestName}</p>
</div>
</div>
<Badge variant="outline">{row.paymentMethod}</Badge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="font-medium">Rs. {row.amount.toLocaleString()}</span>
<div className="flex gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label="Open receipt"
disabled={!row.shareUrl}
onClick={() => openReceipt(row)}
>
<ExternalLinkIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Copy receipt link"
disabled={!row.shareUrl}
onClick={() => copyReceiptLink(row)}
>
<CopyIcon />
</Button>
</div>
</div>
</div>
)}
/>
)}
<InfoBanner>
A receipt is generated automatically whenever a booking payment is recorded (check-out or
Mark as Paid) — separate from membership invoices, which stay on the Customer Payments page.
</InfoBanner>
</div>
)
}
export { BookingInvoicesTab }
+212 -48
View File
@@ -25,9 +25,10 @@ import {
reassignBooking,
rescheduleBooking,
} from "@/lib/api/bookings"
import { getMembers } from "@/lib/api/customers"
import { getFacilitySessions } from "@/lib/api/facilities"
import { getRfidTags } from "@/lib/api/rfid-tags"
import type { Booking, BookingStatus } from "@/lib/models/booking"
import type { Booking, BookingStatus, PaymentMethod } from "@/lib/models/booking"
import type { Facility, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
@@ -41,6 +42,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -73,6 +75,11 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [
{ label: "Completed", value: "Completed" },
]
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
{ label: "Cash", value: "Cash" },
{ label: "Bank", value: "Bank" },
]
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
@@ -100,6 +107,12 @@ function paymentStatusTone(status: string) {
return "neutral" as const
}
/** Staff can settle payment any time before checkout, not only before check-in — checkout
* itself will otherwise force the same prompt (see CompleteBookingDialog). */
function canMarkPaid(booking: Booking) {
return (booking.status === "Confirmed" || booking.status === "CheckedIn") && booking.isGuest && booking.paymentStatus === "Unpaid"
}
interface BookingRosterTabProps {
facilities: Facility[]
}
@@ -183,20 +196,6 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
}
}
async function confirmComplete() {
if (!completeTarget) return
const target = completeTarget
setCompleteTarget(null)
try {
await runMutation(() => completeBooking(target.bookingId))
toast.success("Booking completed")
bookingsFetch.reload()
availableTagsFetch.reload()
} catch {
// runMutation() already toasted the real error.
}
}
function copyShareLink(booking: Booking) {
if (!booking.shareUrl) return
navigator.clipboard.writeText(booking.shareUrl)
@@ -225,7 +224,10 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
header: "Booked By",
cell: (row) => (
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
<div className="flex items-center gap-1.5">
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && row.customerId && <Badge variant="outline">Registered</Badge>}
</div>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
{row.partySize != null && <p className="text-xs text-muted-foreground">Party of {row.partySize}</p>}
</div>
@@ -270,7 +272,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<TooltipContent>Copy share link</TooltipContent>
</Tooltip>
)}
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
{canMarkPaid(row) && (
<Tooltip>
<TooltipTrigger
render={
@@ -452,7 +454,10 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<p className="text-xs text-muted-foreground">
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
</p>
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && row.customerId && <Badge variant="outline">Registered</Badge>}
</div>
</div>
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
</div>
@@ -464,7 +469,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<CopyIcon />
</Button>
)}
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
{canMarkPaid(row) && (
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
@@ -540,20 +545,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!completeTarget} onOpenChange={(open) => !open && setCompleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Complete this booking?</AlertDialogTitle>
<AlertDialogDescription>
Ends the session and releases the RFID tag (if one was assigned) back to the available pool.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not Yet</AlertDialogCancel>
<AlertDialogAction onClick={confirmComplete}>Complete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CompleteBookingDialog
booking={completeTarget}
onOpenChange={(open) => !open && setCompleteTarget(null)}
onCompleted={() => {
reload()
availableTagsFetch.reload()
}}
/>
</div>
)
}
@@ -568,6 +567,7 @@ interface AddWalkInGuestDialogProps {
function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: AddWalkInGuestDialogProps) {
const [facilityId, setFacilityId] = React.useState("")
const [sessionId, setSessionId] = React.useState("")
const [customerId, setCustomerId] = React.useState("")
const [guestName, setGuestName] = React.useState("")
const [guestPhone, setGuestPhone] = React.useState("")
const { busy, run } = useMutation()
@@ -578,6 +578,7 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
if (open) {
setFacilityId(facilities[0]?.facilityId ?? "")
setSessionId("")
setCustomerId("")
setGuestName("")
setGuestPhone("")
}
@@ -586,6 +587,22 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
const sessionsFetch = useFetch(() => (facilityId ? getFacilitySessions({ facilityId }) : Promise.resolve([])))
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled && new Date(s.startTime) > new Date())
const membersFetch = useFetch(() => getMembers())
const members = (membersFetch.data ?? []).filter((m) => !!m.customer)
const memberOptions = [
{ label: "New guest (not registered)", value: "none" },
...members.map((m) => ({ label: m.customer!.cusName ?? m.customer!.primaryPH ?? "Member", value: m.fkCustomerId })),
]
function selectMember(id: string) {
setCustomerId(id === "none" ? "" : id)
const member = id !== "none" ? members.find((m) => m.fkCustomerId === id) : undefined
if (member?.customer) {
setGuestName(member.customer.cusName ?? "")
setGuestPhone(member.customer.primaryPH ?? "")
}
}
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
const sessionOptions = sessions.map((s) => ({
label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
@@ -599,7 +616,12 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
if (!canSubmit) return
try {
const booking = await run(() =>
createGuestBooking({ facilitySessionId: sessionId, guestName: guestName.trim(), guestPhone: guestPhone.trim() })
createGuestBooking({
facilitySessionId: sessionId,
guestName: guestName.trim(),
guestPhone: guestPhone.trim(),
customerId: customerId || undefined,
})
)
if (booking.shareUrl) {
navigator.clipboard.writeText(booking.shareUrl)
@@ -670,6 +692,22 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Select existing member (optional)</Label>
<Select items={memberOptions} value={customerId || "none"} onValueChange={(v) => selectMember(v ?? "none")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={membersFetch.loading ? "Loading…" : "New guest (not registered)"} />
</SelectTrigger>
<SelectContent>
{memberOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-name">
Guest Name <span className="text-destructive">*</span>
@@ -706,12 +744,14 @@ interface MarkPaidDialogProps {
function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps) {
const [amount, setAmount] = React.useState("")
const [paymentMethod, setPaymentMethod] = React.useState<PaymentMethod>("Cash")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
setPaymentMethod("Cash")
}
const canSubmit = !!booking && Number(amount) > 0 && !busy
@@ -720,7 +760,7 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
e.preventDefault()
if (!booking || !canSubmit) return
try {
await run(() => markGuestBookingPaid(booking.bookingId, Number(amount)))
await run(() => markGuestBookingPaid(booking.bookingId, { amountCharged: Number(amount), paymentMethod }))
toast.success("Payment recorded")
onOpenChange(false)
onPaid()
@@ -738,19 +778,40 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
<DialogDescription>Record the amount collected from {booking?.guestName ?? "this guest"}.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="paid-amount">
Amount (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="paid-amount"
type="number"
min={0.01}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="paid-amount">
Amount (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="paid-amount"
type="number"
min={0.01}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Payment Method</Label>
<Select
items={PAYMENT_METHOD_OPTIONS}
value={paymentMethod}
onValueChange={(v) => setPaymentMethod((v as PaymentMethod) ?? "Cash")}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
@@ -768,6 +829,109 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
)
}
interface CompleteBookingDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
onCompleted: () => void
}
function CompleteBookingDialog({ booking, onOpenChange, onCompleted }: CompleteBookingDialogProps) {
const needsPayment = !!booking && booking.isGuest && booking.paymentStatus === "Unpaid"
const [amount, setAmount] = React.useState("")
const [paymentMethod, setPaymentMethod] = React.useState<PaymentMethod>("Cash")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
setPaymentMethod("Cash")
}
const canSubmit = needsPayment ? !!booking && Number(amount) > 0 && !busy : !!booking && !busy
async function handleComplete() {
if (!booking || !canSubmit) return
try {
await run(() =>
completeBooking(
booking.bookingId,
needsPayment ? { markPaid: true, amountCollected: Number(amount), paymentMethod } : {}
)
)
toast.success("Booking completed")
onOpenChange(false)
onCompleted()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={!!booking} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{needsPayment ? "Collect Payment to Check Out" : "Complete this booking?"}</DialogTitle>
<DialogDescription>
{needsPayment
? `${booking?.guestName ?? "This guest"} must pay before this booking can be checked out.`
: "Ends the session and releases the RFID tag (if one was assigned) back to the available pool."}
</DialogDescription>
</DialogHeader>
{needsPayment && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="checkout-amount">
Amount (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="checkout-amount"
type="number"
min={0.01}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Payment Method</Label>
<Select
items={PAYMENT_METHOD_OPTIONS}
value={paymentMethod}
onValueChange={(v) => setPaymentMethod((v as PaymentMethod) ?? "Cash")}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{needsPayment ? "Cancel" : "Not Yet"}
</Button>
<Button type="button" onClick={handleComplete} disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{needsPayment ? "Mark Paid & Check Out" : "Complete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ReassignDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
+12 -2
View File
@@ -49,6 +49,8 @@ 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"
@@ -80,18 +82,23 @@ function BookingsView() {
<div className="flex flex-col gap-4">
<PageHeader
title="Bookings"
description="Scheduled sessions, ad-hoc reservations, the booking roster, RFID tags and time-bank cards"
description="Dashboard, scheduled sessions, ad-hoc reservations, the booking roster, invoices, RFID tags and time-bank cards"
/>
<Tabs defaultValue="sessions" className="gap-4">
<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>
@@ -101,6 +108,9 @@ function BookingsView() {
<TabsContent value="roster">
<BookingRosterTab facilities={facilities} />
</TabsContent>
<TabsContent value="invoices">
<BookingInvoicesTab />
</TabsContent>
<TabsContent value="rfid-tags">
<RfidTagsTab />
</TabsContent>
+9
View File
@@ -0,0 +1,9 @@
import { apiRequest } from "@/lib/api/core-http"
import type { BookingInvoice, GetBookingInvoicesFilters } from "@/lib/models/booking-invoice"
/** Booking payment receipts — kept separate from the membership-based Customer Payments view. */
function getBookingInvoices(filters: GetBookingInvoicesFilters = {}) {
return apiRequest<BookingInvoice[]>("client/bookings/invoices", { query: filters })
}
export { getBookingInvoices }
+12 -4
View File
@@ -1,10 +1,13 @@
import { apiRequest } from "@/lib/api/core-http"
import type { BookingDashboardSummary } from "@/lib/models/booking-dashboard"
import type {
Booking,
CompleteBookingInput,
CreateAdhocBookingInput,
CreateGuestAdhocBookingInput,
CreateGuestBookingInput,
GetBookingsFilters,
MarkGuestBookingPaidInput,
ReassignBookingInput,
RescheduleBookingInput,
} from "@/lib/models/booking"
@@ -41,10 +44,10 @@ function checkInBooking(otpCode: string, rfidTagId?: string) {
})
}
function markGuestBookingPaid(id: string, amountCharged: number) {
function markGuestBookingPaid(id: string, input: MarkGuestBookingPaidInput) {
return apiRequest<Booking>(`client/bookings/${id}/mark-paid`, {
method: "POST",
body: { bookingId: id, amountCharged },
body: { bookingId: id, ...input },
})
}
@@ -66,8 +69,12 @@ function markNoShow(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/no-show`, { method: "POST" })
}
function completeBooking(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST" })
function completeBooking(id: string, input: CompleteBookingInput = {}) {
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST", body: input })
}
function getBookingDashboardSummary() {
return apiRequest<BookingDashboardSummary>("client/bookings/dashboard-summary")
}
export {
@@ -83,4 +90,5 @@ export {
rescheduleBooking,
markNoShow,
completeBooking,
getBookingDashboardSummary,
}
+8
View File
@@ -0,0 +1,8 @@
/** Mirrors BookingDashboardSummaryDto. */
export interface BookingDashboardSummary {
bookingsToday: number
revenueToday: number
checkedInNow: number
upcomingConfirmed: number
outstandingUnpaid: number
}
+23
View File
@@ -0,0 +1,23 @@
import type { PaymentMethod } from "@/lib/models/booking"
/** Mirrors BookingInvoiceDto. A booking payment receipt — deliberately separate from
* membership-based Customer Payments/invoices. */
export interface BookingInvoice {
bookingInvoiceId: string
bookingId: string
amount: number
paymentMethod: PaymentMethod
guestName: string
guestPhone?: string | null
facilityName: string
bookingStartTime: string
bookingEndTime: string
issuedAt: string
/** Public, unauthenticated link to this receipt's printable page. */
shareUrl?: string | null
}
export type GetBookingInvoicesFilters = {
fromDate?: string
toDate?: string
}
+21
View File
@@ -1,4 +1,5 @@
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
export type PaymentMethod = "Cash" | "Bank"
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
* (facilitySessionId null, exclusive-unit) bookings. */
@@ -30,6 +31,9 @@ export interface Booking {
paymentStatus: string
amountCharged: number
/** Set once paymentStatus becomes "Paid". */
paymentMethod?: PaymentMethod | null
paidAt?: string | null
status: BookingStatus
bookedAt: string
checkedInAt?: string | null
@@ -51,6 +55,8 @@ export interface CreateGuestBookingInput {
facilitySessionId: string
guestName: string
guestPhone: string
/** Optional link to an existing registered customer — identification/auto-fill only. */
customerId?: string
}
export interface CreateAdhocBookingInput {
@@ -69,6 +75,8 @@ export interface CreateGuestAdhocBookingInput {
partySize?: number
/** Overrides the facility's own price per hour for this booking's charge only. */
pricePerHour?: number
/** Optional link to an existing registered customer — identification/auto-fill only. */
customerId?: string
}
export interface ReassignBookingInput {
@@ -82,3 +90,16 @@ export interface RescheduleBookingInput {
durationMinutes?: number
facilitySessionId?: string
}
/** Only required when the target booking is a guest booking still Unpaid — the backend
* rejects checkout otherwise. Ignored for a member booking or a guest booking already Paid. */
export interface CompleteBookingInput {
markPaid?: boolean
amountCollected?: number
paymentMethod?: PaymentMethod
}
export interface MarkGuestBookingPaidInput {
amountCharged: number
paymentMethod?: PaymentMethod
}