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:
Generated
+65
@@ -9,6 +9,11 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@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",
|
"@microsoft/signalr": "^8.0.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -985,6 +990,56 @@
|
|||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
@@ -8682,6 +8737,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@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",
|
"@microsoft/signalr": "^8.0.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
import { getFacilityAvailability } from "@/lib/api/facilities"
|
import { getFacilityAvailability } from "@/lib/api/facilities"
|
||||||
import { createGuestAdhocBooking } from "@/lib/api/bookings"
|
import { createGuestAdhocBooking } from "@/lib/api/bookings"
|
||||||
|
import { getMembers } from "@/lib/api/customers"
|
||||||
import { ApiError } from "@/lib/api/core-http"
|
import { ApiError } from "@/lib/api/core-http"
|
||||||
import type { FacilityAvailability } from "@/lib/models/facility"
|
import type { FacilityAvailability } from "@/lib/models/facility"
|
||||||
import type { Facility } from "@/lib/models/facility"
|
import type { Facility } from "@/lib/models/facility"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
import { useMutation } from "@/lib/use-mutation"
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -43,9 +45,26 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
const [minutes, setMinutes] = React.useState("0")
|
const [minutes, setMinutes] = React.useState("0")
|
||||||
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
|
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
|
||||||
const [partySize, setPartySize] = React.useState("")
|
const [partySize, setPartySize] = React.useState("")
|
||||||
|
const [customerId, setCustomerId] = React.useState("")
|
||||||
const [guestName, setGuestName] = React.useState("")
|
const [guestName, setGuestName] = React.useState("")
|
||||||
const [guestPhone, setGuestPhone] = 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 [availability, setAvailability] = React.useState<FacilityAvailability | null>(null)
|
||||||
const [availabilityError, setAvailabilityError] = React.useState<string | null>(null)
|
const [availabilityError, setAvailabilityError] = React.useState<string | null>(null)
|
||||||
const { busy: checking, run: runCheck } = useMutation()
|
const { busy: checking, run: runCheck } = useMutation()
|
||||||
@@ -96,6 +115,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
guestPhone: guestPhone.trim(),
|
guestPhone: guestPhone.trim(),
|
||||||
partySize: partySize ? Number(partySize) : undefined,
|
partySize: partySize ? Number(partySize) : undefined,
|
||||||
pricePerHour: Number(pricePerHour),
|
pricePerHour: Number(pricePerHour),
|
||||||
|
customerId: customerId || undefined,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
if (bookingResult.shareUrl) {
|
if (bookingResult.shareUrl) {
|
||||||
@@ -111,6 +131,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
setGuestName("")
|
setGuestName("")
|
||||||
setGuestPhone("")
|
setGuestPhone("")
|
||||||
setPartySize("")
|
setPartySize("")
|
||||||
|
setCustomerId("")
|
||||||
setAvailability(null)
|
setAvailability(null)
|
||||||
handleCheckAvailability()
|
handleCheckAvailability()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -247,7 +268,23 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-guest-name">
|
<Label htmlFor="adhoc-guest-name">
|
||||||
Guest Name <span className="text-destructive">*</span>
|
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,
|
reassignBooking,
|
||||||
rescheduleBooking,
|
rescheduleBooking,
|
||||||
} from "@/lib/api/bookings"
|
} from "@/lib/api/bookings"
|
||||||
|
import { getMembers } from "@/lib/api/customers"
|
||||||
import { getFacilitySessions } from "@/lib/api/facilities"
|
import { getFacilitySessions } from "@/lib/api/facilities"
|
||||||
import { getRfidTags } from "@/lib/api/rfid-tags"
|
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 type { Facility, FacilitySession } from "@/lib/models/facility"
|
||||||
import { useFetch } from "@/lib/use-fetch"
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
import { useMutation, runMutation } from "@/lib/use-mutation"
|
import { useMutation, runMutation } from "@/lib/use-mutation"
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -73,6 +75,11 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [
|
|||||||
{ label: "Completed", value: "Completed" },
|
{ label: "Completed", value: "Completed" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
|
||||||
|
{ label: "Cash", value: "Cash" },
|
||||||
|
{ label: "Bank", value: "Bank" },
|
||||||
|
]
|
||||||
|
|
||||||
function formatDateTime(iso: string) {
|
function formatDateTime(iso: string) {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -100,6 +107,12 @@ function paymentStatusTone(status: string) {
|
|||||||
return "neutral" as const
|
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 {
|
interface BookingRosterTabProps {
|
||||||
facilities: Facility[]
|
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) {
|
function copyShareLink(booking: Booking) {
|
||||||
if (!booking.shareUrl) return
|
if (!booking.shareUrl) return
|
||||||
navigator.clipboard.writeText(booking.shareUrl)
|
navigator.clipboard.writeText(booking.shareUrl)
|
||||||
@@ -225,7 +224,10 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
header: "Booked By",
|
header: "Booked By",
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
<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.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>}
|
{row.partySize != null && <p className="text-xs text-muted-foreground">Party of {row.partySize}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +272,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<TooltipContent>Copy share link</TooltipContent>
|
<TooltipContent>Copy share link</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
|
{canMarkPaid(row) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -452,7 +454,10 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
||||||
|
{row.isGuest && row.customerId && <Badge variant="outline">Registered</Badge>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
|
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,7 +469,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Button>
|
</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)}>
|
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
|
||||||
<WalletIcon />
|
<WalletIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -540,20 +545,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog open={!!completeTarget} onOpenChange={(open) => !open && setCompleteTarget(null)}>
|
<CompleteBookingDialog
|
||||||
<AlertDialogContent>
|
booking={completeTarget}
|
||||||
<AlertDialogHeader>
|
onOpenChange={(open) => !open && setCompleteTarget(null)}
|
||||||
<AlertDialogTitle>Complete this booking?</AlertDialogTitle>
|
onCompleted={() => {
|
||||||
<AlertDialogDescription>
|
reload()
|
||||||
Ends the session and releases the RFID tag (if one was assigned) back to the available pool.
|
availableTagsFetch.reload()
|
||||||
</AlertDialogDescription>
|
}}
|
||||||
</AlertDialogHeader>
|
/>
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Not Yet</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmComplete}>Complete</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -568,6 +567,7 @@ interface AddWalkInGuestDialogProps {
|
|||||||
function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: AddWalkInGuestDialogProps) {
|
function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: AddWalkInGuestDialogProps) {
|
||||||
const [facilityId, setFacilityId] = React.useState("")
|
const [facilityId, setFacilityId] = React.useState("")
|
||||||
const [sessionId, setSessionId] = React.useState("")
|
const [sessionId, setSessionId] = React.useState("")
|
||||||
|
const [customerId, setCustomerId] = React.useState("")
|
||||||
const [guestName, setGuestName] = React.useState("")
|
const [guestName, setGuestName] = React.useState("")
|
||||||
const [guestPhone, setGuestPhone] = React.useState("")
|
const [guestPhone, setGuestPhone] = React.useState("")
|
||||||
const { busy, run } = useMutation()
|
const { busy, run } = useMutation()
|
||||||
@@ -578,6 +578,7 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
|
|||||||
if (open) {
|
if (open) {
|
||||||
setFacilityId(facilities[0]?.facilityId ?? "")
|
setFacilityId(facilities[0]?.facilityId ?? "")
|
||||||
setSessionId("")
|
setSessionId("")
|
||||||
|
setCustomerId("")
|
||||||
setGuestName("")
|
setGuestName("")
|
||||||
setGuestPhone("")
|
setGuestPhone("")
|
||||||
}
|
}
|
||||||
@@ -586,6 +587,22 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
|
|||||||
const sessionsFetch = useFetch(() => (facilityId ? getFacilitySessions({ facilityId }) : Promise.resolve([])))
|
const sessionsFetch = useFetch(() => (facilityId ? getFacilitySessions({ facilityId }) : Promise.resolve([])))
|
||||||
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled && new Date(s.startTime) > new Date())
|
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 facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||||
const sessionOptions = sessions.map((s) => ({
|
const sessionOptions = sessions.map((s) => ({
|
||||||
label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
|
label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
|
||||||
@@ -599,7 +616,12 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
|
|||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
try {
|
try {
|
||||||
const booking = await run(() =>
|
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) {
|
if (booking.shareUrl) {
|
||||||
navigator.clipboard.writeText(booking.shareUrl)
|
navigator.clipboard.writeText(booking.shareUrl)
|
||||||
@@ -670,6 +692,22 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: Add
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="guest-name">
|
<Label htmlFor="guest-name">
|
||||||
Guest Name <span className="text-destructive">*</span>
|
Guest Name <span className="text-destructive">*</span>
|
||||||
@@ -706,12 +744,14 @@ interface MarkPaidDialogProps {
|
|||||||
|
|
||||||
function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps) {
|
function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps) {
|
||||||
const [amount, setAmount] = React.useState("")
|
const [amount, setAmount] = React.useState("")
|
||||||
|
const [paymentMethod, setPaymentMethod] = React.useState<PaymentMethod>("Cash")
|
||||||
const { busy, run } = useMutation()
|
const { busy, run } = useMutation()
|
||||||
const [prevBooking, setPrevBooking] = React.useState(booking)
|
const [prevBooking, setPrevBooking] = React.useState(booking)
|
||||||
|
|
||||||
if (booking !== prevBooking) {
|
if (booking !== prevBooking) {
|
||||||
setPrevBooking(booking)
|
setPrevBooking(booking)
|
||||||
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
|
setAmount(booking?.amountCharged ? String(booking.amountCharged) : "")
|
||||||
|
setPaymentMethod("Cash")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit = !!booking && Number(amount) > 0 && !busy
|
const canSubmit = !!booking && Number(amount) > 0 && !busy
|
||||||
@@ -720,7 +760,7 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!booking || !canSubmit) return
|
if (!booking || !canSubmit) return
|
||||||
try {
|
try {
|
||||||
await run(() => markGuestBookingPaid(booking.bookingId, Number(amount)))
|
await run(() => markGuestBookingPaid(booking.bookingId, { amountCharged: Number(amount), paymentMethod }))
|
||||||
toast.success("Payment recorded")
|
toast.success("Payment recorded")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
onPaid()
|
onPaid()
|
||||||
@@ -738,6 +778,7 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
|
|||||||
<DialogDescription>Record the amount collected from {booking?.guestName ?? "this guest"}.</DialogDescription>
|
<DialogDescription>Record the amount collected from {booking?.guestName ?? "this guest"}.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="paid-amount">
|
<Label htmlFor="paid-amount">
|
||||||
Amount (Rs.) <span className="text-destructive">*</span>
|
Amount (Rs.) <span className="text-destructive">*</span>
|
||||||
@@ -752,6 +793,26 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
@@ -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 {
|
interface ReassignDialogProps {
|
||||||
booking: Booking | null
|
booking: Booking | null
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import { PageHeader } from "@/components/hexa/page-header"
|
|||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
|
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 { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
||||||
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
||||||
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
||||||
@@ -80,18 +82,23 @@ function BookingsView() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Bookings"
|
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>
|
<TabsList>
|
||||||
|
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||||
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
||||||
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
||||||
|
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
||||||
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
||||||
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="dashboard">
|
||||||
|
<BookingDashboardTab />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="sessions">
|
<TabsContent value="sessions">
|
||||||
<SessionsTab facilities={facilities} />
|
<SessionsTab facilities={facilities} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -101,6 +108,9 @@ function BookingsView() {
|
|||||||
<TabsContent value="roster">
|
<TabsContent value="roster">
|
||||||
<BookingRosterTab facilities={facilities} />
|
<BookingRosterTab facilities={facilities} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="invoices">
|
||||||
|
<BookingInvoicesTab />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="rfid-tags">
|
<TabsContent value="rfid-tags">
|
||||||
<RfidTagsTab />
|
<RfidTagsTab />
|
||||||
</TabsContent>
|
</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 { apiRequest } from "@/lib/api/core-http"
|
||||||
|
import type { BookingDashboardSummary } from "@/lib/models/booking-dashboard"
|
||||||
import type {
|
import type {
|
||||||
Booking,
|
Booking,
|
||||||
|
CompleteBookingInput,
|
||||||
CreateAdhocBookingInput,
|
CreateAdhocBookingInput,
|
||||||
CreateGuestAdhocBookingInput,
|
CreateGuestAdhocBookingInput,
|
||||||
CreateGuestBookingInput,
|
CreateGuestBookingInput,
|
||||||
GetBookingsFilters,
|
GetBookingsFilters,
|
||||||
|
MarkGuestBookingPaidInput,
|
||||||
ReassignBookingInput,
|
ReassignBookingInput,
|
||||||
RescheduleBookingInput,
|
RescheduleBookingInput,
|
||||||
} from "@/lib/models/booking"
|
} 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`, {
|
return apiRequest<Booking>(`client/bookings/${id}/mark-paid`, {
|
||||||
method: "POST",
|
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" })
|
return apiRequest<Booking>(`client/bookings/${id}/no-show`, { method: "POST" })
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeBooking(id: string) {
|
function completeBooking(id: string, input: CompleteBookingInput = {}) {
|
||||||
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST" })
|
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookingDashboardSummary() {
|
||||||
|
return apiRequest<BookingDashboardSummary>("client/bookings/dashboard-summary")
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -83,4 +90,5 @@ export {
|
|||||||
rescheduleBooking,
|
rescheduleBooking,
|
||||||
markNoShow,
|
markNoShow,
|
||||||
completeBooking,
|
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 BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
|
||||||
|
export type PaymentMethod = "Cash" | "Bank"
|
||||||
|
|
||||||
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
|
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
|
||||||
* (facilitySessionId null, exclusive-unit) bookings. */
|
* (facilitySessionId null, exclusive-unit) bookings. */
|
||||||
@@ -30,6 +31,9 @@ export interface Booking {
|
|||||||
|
|
||||||
paymentStatus: string
|
paymentStatus: string
|
||||||
amountCharged: number
|
amountCharged: number
|
||||||
|
/** Set once paymentStatus becomes "Paid". */
|
||||||
|
paymentMethod?: PaymentMethod | null
|
||||||
|
paidAt?: string | null
|
||||||
status: BookingStatus
|
status: BookingStatus
|
||||||
bookedAt: string
|
bookedAt: string
|
||||||
checkedInAt?: string | null
|
checkedInAt?: string | null
|
||||||
@@ -51,6 +55,8 @@ export interface CreateGuestBookingInput {
|
|||||||
facilitySessionId: string
|
facilitySessionId: string
|
||||||
guestName: string
|
guestName: string
|
||||||
guestPhone: string
|
guestPhone: string
|
||||||
|
/** Optional link to an existing registered customer — identification/auto-fill only. */
|
||||||
|
customerId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAdhocBookingInput {
|
export interface CreateAdhocBookingInput {
|
||||||
@@ -69,6 +75,8 @@ export interface CreateGuestAdhocBookingInput {
|
|||||||
partySize?: number
|
partySize?: number
|
||||||
/** Overrides the facility's own price per hour for this booking's charge only. */
|
/** Overrides the facility's own price per hour for this booking's charge only. */
|
||||||
pricePerHour?: number
|
pricePerHour?: number
|
||||||
|
/** Optional link to an existing registered customer — identification/auto-fill only. */
|
||||||
|
customerId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReassignBookingInput {
|
export interface ReassignBookingInput {
|
||||||
@@ -82,3 +90,16 @@ export interface RescheduleBookingInput {
|
|||||||
durationMinutes?: number
|
durationMinutes?: number
|
||||||
facilitySessionId?: string
|
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