Dev #3
Generated
+65
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Mirrors BookingDashboardSummaryDto. */
|
||||
export interface BookingDashboardSummary {
|
||||
bookingsToday: number
|
||||
revenueToday: number
|
||||
checkedInNow: number
|
||||
upcomingConfirmed: number
|
||||
outstandingUnpaid: number
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user