diff --git a/package-lock.json b/package-lock.json index cedba90..bd69310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 53a9a40..f78c047 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/hexa/views/add-gym-plan-dialog.tsx b/src/components/hexa/views/add-gym-plan-dialog.tsx index 20075b3..327ec03 100644 --- a/src/components/hexa/views/add-gym-plan-dialog.tsx +++ b/src/components/hexa/views/add-gym-plan-dialog.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { useMutation } from "@/lib/use-mutation" -import type { Place } from "@/lib/models/place" +import type { Facility } from "@/lib/models/facility" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { @@ -48,7 +48,7 @@ interface GymPlanFormValues { onboardingFeeAmount: string allowSplit: boolean installmentCount: string - /** Places this plan grants access to. Empty = unrestricted (every active device). */ + /** Facilities this plan grants access to. Empty = unrestricted (every active device). */ placeIds: string[] } @@ -89,8 +89,8 @@ interface AddGymPlanDialogProps { onSubmit: (values: GymPlanFormValues) => Promise | void mode?: "create" | "edit" initialValues?: Partial - /** Active places to choose from. Empty selection means "unrestricted". */ - places?: Place[] + /** Active facilities to choose from. Empty selection means "unrestricted". */ + facilities?: Facility[] } function AddGymPlanDialog({ @@ -99,7 +99,7 @@ function AddGymPlanDialog({ onSubmit, mode = "create", initialValues, - places = [], + facilities = [], }: AddGymPlanDialogProps) { const [values, setValues] = React.useState(EMPTY_VALUES) const [customDuration, setCustomDuration] = React.useState(false) @@ -309,24 +309,24 @@ function AddGymPlanDialog({ )} - {/* Places */} + {/* Facilities */}
- +

Which access zones this plan reaches. Leave all unchecked for unrestricted access (every terminal).

- {places.length === 0 ? ( + {facilities.length === 0 ? (

- No places set up yet — this plan will be unrestricted. + No facilities set up yet — this plan will be unrestricted.

) : (
- {places.map((place) => { - const checked = values.placeIds.includes(place.placeId) + {facilities.map((facility) => { + const checked = values.placeIds.includes(facility.facilityId) return ( ) })} diff --git a/src/components/hexa/views/adhoc-booking-tab.tsx b/src/components/hexa/views/adhoc-booking-tab.tsx new file mode 100644 index 0000000..3e70807 --- /dev/null +++ b/src/components/hexa/views/adhoc-booking-tab.tsx @@ -0,0 +1,318 @@ +"use client" + +import * as React from "react" +import { CopyIcon, SearchIcon } from "lucide-react" +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" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Spinner } from "@/components/ui/spinner" +import { InfoBanner } from "@/components/hexa/info-banner" +import { StatusBadge } from "@/components/hexa/status-badge" + +/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */ +function toLocalInputValue(date: Date) { + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +interface AdhocBookingTabProps { + facilities: Facility[] +} + +function AdhocBookingTab({ facilities }: AdhocBookingTabProps) { + const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive) + + const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "") + const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date())) + const [hours, setHours] = React.useState("1") + 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(null) + const [availabilityError, setAvailabilityError] = React.useState(null) + const { busy: checking, run: runCheck } = useMutation() + const { busy: booking, run: runBook } = useMutation() + + const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0) + const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60 + + const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId })) + const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking + const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking + + function selectFacility(id: string) { + setFacilityId(id) + setAvailability(null) + const facility = adhocFacilities.find((f) => f.facilityId === id) + setPricePerHour(String(facility?.pricePerHour ?? 0)) + } + + async function handleCheckAvailability() { + if (!canCheck) return + setAvailabilityError(null) + try { + const results = await runCheck(() => + getFacilityAvailability({ + facilityId, + startTime: new Date(startTime).toISOString(), + durationMinutes, + }) + ) + setAvailability(results[0] ?? null) + } catch (err) { + setAvailability(null) + setAvailabilityError(err instanceof ApiError ? err.message : "Couldn't check availability.") + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!canSubmit) return + try { + const bookingResult = await runBook(() => + createGuestAdhocBooking({ + facilityId, + startTime: new Date(startTime).toISOString(), + durationMinutes, + guestName: guestName.trim(), + guestPhone: guestPhone.trim(), + partySize: partySize ? Number(partySize) : undefined, + pricePerHour: Number(pricePerHour), + customerId: customerId || undefined, + }) + ) + if (bookingResult.shareUrl) { + navigator.clipboard.writeText(bookingResult.shareUrl) + toast.success("Booking created — share link copied", { + description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`, + }) + } else { + toast.success("Booking created", { + description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`, + }) + } + setGuestName("") + setGuestPhone("") + setPartySize("") + setCustomerId("") + setAvailability(null) + handleCheckAvailability() + } catch { + // run() already toasted the real error; keep the form as-is for retry. + } + } + + if (adhocFacilities.length === 0) { + return ( + + No ad-hoc facilities yet. Add a facility with Booking Mode set to "Ad-hoc" under + Memberships → Facilities (e.g. courts or a pool with multiple units) to book here. + + ) + } + + return ( +
+
+
+ + +
+ +
+ + { + setStartTime(e.target.value) + setAvailability(null) + }} + required + /> +
+ +
+ +
+
+ { + setHours(e.target.value) + setAvailability(null) + }} + /> + hours +
+
+ { + setMinutes(e.target.value) + setAvailability(null) + }} + /> + minutes +
+
+
+ +
+
+ + setPricePerHour(e.target.value)} + required + /> +
+
+ +
+ Rs. {bookingPrice.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
+
+ +
+ + setPartySize(e.target.value)} + /> +
+ +
+ + {availabilityError &&

{availabilityError}

} + {availability && ( + 0 ? "success" : "danger"}> + {availability.unitsFree} / {availability.totalUnits} units free + + )} +
+ +
+ + +
+ +
+
+ + setGuestName(e.target.value)} required /> +
+
+ + setGuestPhone(e.target.value)} required /> +
+
+ +
+ +
+
+ + + Booking is recorded as pending check-in. Give the party an RFID tag and check them in from + the Roster tab when they arrive — the slot stays reserved until then. + +
+ ) +} + +export { AdhocBookingTab } diff --git a/src/components/hexa/views/booking-calendar.tsx b/src/components/hexa/views/booking-calendar.tsx new file mode 100644 index 0000000..866f42a --- /dev/null +++ b/src/components/hexa/views/booking-calendar.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState(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 ( +
+
+ {loading && Loading…} + {error && {error}} + {(Object.keys(STATUS_COLORS) as BookingStatus[]).map((status) => ( + + + {status} + + ))} +
+ +
+ ) +} + +export { BookingCalendar } diff --git a/src/components/hexa/views/booking-dashboard-tab.tsx b/src/components/hexa/views/booking-dashboard-tab.tsx new file mode 100644 index 0000000..870921e --- /dev/null +++ b/src/components/hexa/views/booking-dashboard-tab.tsx @@ -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: () => ( +
+ +
+ ), + } +) + +function BookingDashboardTab() { + const summaryFetch = useFetch(() => getBookingDashboardSummary()) + const summary = summaryFetch.data + + return ( +
+
+ + + + + +
+ + +
+ ) +} + +export { BookingDashboardTab } diff --git a/src/components/hexa/views/booking-invoices-tab.tsx b/src/components/hexa/views/booking-invoices-tab.tsx new file mode 100644 index 0000000..2d1c76e --- /dev/null +++ b/src/components/hexa/views/booking-invoices-tab.tsx @@ -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[] = [ + { + key: "facility", + header: "Facility & Session", + cell: (row) => ( +
+
+ +
+
+

{row.facilityName}

+

+ {formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)} +

+
+
+ ), + }, + { + key: "guest", + header: "Guest", + cell: (row) => ( +
+

{row.guestName}

+ {row.guestPhone &&

{row.guestPhone}

} +
+ ), + }, + { + key: "amount", + header: "Amount", + cell: (row) => Rs. {row.amount.toLocaleString()}, + }, + { + key: "method", + header: "Payment Method", + cell: (row) => {row.paymentMethod}, + }, + { key: "issued", header: "Issued", cell: (row) => formatDateTime(row.issuedAt) }, + { + key: "actions", + header: "Actions", + className: "text-center", + cell: (row) => ( +
+ + openReceipt(row)} + > + + + } + /> + Open receipt + + + copyReceiptLink(row)} + > + + + } + /> + Copy receipt link + +
+ ), + }, + ] + + return ( +
+ {invoicesFetch.loading ? ( +
+ +
+ ) : ( + row.bookingInvoiceId} + emptyMessage="No booking payments recorded yet." + renderMobileCard={(row) => ( +
+
+
+
+ +
+
+

{row.facilityName}

+

+ {formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)} +

+

{row.guestName}

+
+
+ {row.paymentMethod} +
+
+ Rs. {row.amount.toLocaleString()} +
+ + +
+
+
+ )} + /> + )} + + + 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. + +
+ ) +} + +export { BookingInvoicesTab } diff --git a/src/components/hexa/views/booking-roster-tab.tsx b/src/components/hexa/views/booking-roster-tab.tsx index 155e5df..4cd4f4b 100644 --- a/src/components/hexa/views/booking-roster-tab.tsx +++ b/src/components/hexa/views/booking-roster-tab.tsx @@ -1,18 +1,34 @@ "use client" import * as React from "react" -import { CheckIcon, CopyIcon, PlusIcon, Trash2Icon, WalletIcon } from "lucide-react" +import { + ArrowRightLeftIcon, + CalendarClockIcon, + CheckCheckIcon, + CheckIcon, + CopyIcon, + PlusIcon, + Trash2Icon, + UserXIcon, + WalletIcon, +} from "lucide-react" import { toast } from "sonner" import { cancelBooking, checkInBooking, + completeBooking, createGuestBooking, - getSessionBookings, + getBookings, markGuestBookingPaid, + markNoShow, + reassignBooking, + rescheduleBooking, } from "@/lib/api/bookings" +import { getMembers } from "@/lib/api/customers" import { getFacilitySessions } from "@/lib/api/facilities" -import type { Booking } from "@/lib/models/booking" +import { getRfidTags } from "@/lib/api/rfid-tags" +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" @@ -26,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, @@ -47,9 +64,22 @@ import { 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" import { StatusBadge } from "@/components/hexa/status-badge" +const STATUS_OPTIONS: { label: string; value: string }[] = [ + { label: "All statuses", value: "all" }, + { label: "Confirmed", value: "Confirmed" }, + { label: "Checked In", value: "CheckedIn" }, + { label: "Cancelled", value: "Cancelled" }, + { label: "No Show", value: "NoShow" }, + { 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", @@ -59,9 +89,15 @@ function formatDateTime(iso: string) { }) } -function bookingStatusTone(status: string) { - if (status === "Cancelled") return "danger" as const - if (status === "CheckedIn") return "success" as const +/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */ +function toLocalInputValue(date: Date) { + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function bookingStatusTone(status: BookingStatus | string) { + if (status === "Cancelled" || status === "NoShow") return "danger" as const + if (status === "CheckedIn" || status === "Completed") return "success" as const return "info" as const } @@ -71,42 +107,64 @@ 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[] } function BookingRosterTab({ facilities }: BookingRosterTabProps) { - const sessionsFetch = useFetch(() => getFacilitySessions({})) - const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled) + const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled" && f.isActive) - const [sessionId, setSessionId] = React.useState("") - React.useEffect(() => { - if (!sessionId && sessions.length > 0) setSessionId(sessions[0].facilitySessionId) - // eslint-disable-next-line react-hooks/exhaustive-deps -- only auto-select once sessions load - }, [sessions.length]) - - const bookingsFetch = useFetch(() => (sessionId ? getSessionBookings(sessionId) : Promise.resolve([]))) + const [facilityFilter, setFacilityFilter] = React.useState("all") + const [statusFilter, setStatusFilter] = React.useState("all") + const bookingsFetch = useFetch(() => + getBookings({ + facilityId: facilityFilter === "all" ? undefined : facilityFilter, + status: statusFilter === "all" ? undefined : statusFilter, + }) + ) const bookings = bookingsFetch.data ?? [] + const availableTagsFetch = useFetch(() => getRfidTags("Available")) + const availableTags = availableTagsFetch.data ?? [] + const [otpCode, setOtpCode] = React.useState("") + const [rfidTagId, setRfidTagId] = React.useState("") const { busy: checkingIn, run: runCheckIn } = useMutation() + const [payTarget, setPayTarget] = React.useState(null) const [cancelTarget, setCancelTarget] = React.useState(null) + const [noShowTarget, setNoShowTarget] = React.useState(null) + const [completeTarget, setCompleteTarget] = React.useState(null) + const [reassignTarget, setReassignTarget] = React.useState(null) + const [rescheduleTarget, setRescheduleTarget] = React.useState(null) const [guestDialogOpen, setGuestDialogOpen] = React.useState(false) - const sessionOptions = sessions.map((s) => ({ - label: `${s.facilityName ?? "-"} · ${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, - value: s.facilitySessionId, - })) + const facilityFilterOptions = [ + { label: "All facilities", value: "all" }, + ...facilities.map((f) => ({ label: f.name, value: f.facilityId })), + ] + + const tagOptions = [ + { label: "No tag", value: "none" }, + ...availableTags.map((t) => ({ label: t.label ? `${t.label} (${t.tagCode})` : t.tagCode, value: t.rfidTagId })), + ] async function handleCheckIn(e: React.FormEvent) { e.preventDefault() if (!otpCode.trim()) return try { - await runCheckIn(() => checkInBooking(otpCode.trim())) + await runCheckIn(() => checkInBooking(otpCode.trim(), rfidTagId && rfidTagId !== "none" ? rfidTagId : undefined)) toast.success("Checked in") setOtpCode("") + setRfidTagId("") bookingsFetch.reload() + availableTagsFetch.reload() } catch { // run() already toasted the real error. } @@ -125,27 +183,65 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { } } + async function confirmNoShow() { + if (!noShowTarget) return + const target = noShowTarget + setNoShowTarget(null) + try { + await runMutation(() => markNoShow(target.bookingId)) + toast.success("Marked as no-show") + bookingsFetch.reload() + } catch { + // runMutation() already toasted the real error. + } + } + function copyShareLink(booking: Booking) { if (!booking.shareUrl) return navigator.clipboard.writeText(booking.shareUrl) toast.success("Link copied", { description: "Share it with the guest so they have their receipt." }) } + function reload() { + bookingsFetch.reload() + } + const columns: DataTableColumn[] = [ + { + key: "facility", + header: "Facility & Time", + cell: (row) => ( +
+

{row.facilityName ?? "-"}

+

+ {formatDateTime(row.startTime)} - {formatDateTime(row.endTime)} +

+
+ ), + }, { key: "who", header: "Booked By", cell: (row) => (
-

{row.isGuest ? row.guestName ?? "Guest" : "Member"}

+
+

{row.isGuest ? row.guestName ?? "Guest" : "Member"}

+ {row.isGuest && row.customerId && Registered} +
{row.isGuest &&

{row.guestPhone}

} + {row.partySize != null &&

Party of {row.partySize}

}
), }, { key: "status", header: "Status", - cell: (row) => {row.status}, + cell: (row) => ( +
+ {row.status} + {row.rfidTagLabel && Tag: {row.rfidTagLabel}} +
+ ), }, { key: "payment", @@ -163,7 +259,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { header: "Actions", className: "text-center", cell: (row) => ( -
+
{row.shareUrl && ( Copy share link )} - {row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && ( + {canMarkPaid(row) && ( Mark as paid )} - {row.status !== "Cancelled" && ( + {row.status === "Confirmed" && row.isGuest && ( + + setReassignTarget(row)}> + + + } + /> + Reassign to someone else + + )} + {row.status === "Confirmed" && ( + + setRescheduleTarget(row)}> + + + } + /> + Reschedule + + )} + {row.status === "Confirmed" && ( + + setNoShowTarget(row)}> + + + } + /> + Mark no-show + + )} + {row.status === "Confirmed" && ( Cancel booking )} + {row.status === "CheckedIn" && ( + + setCompleteTarget(row)}> + + + } + /> + Complete (release tag) + + )}
), }, @@ -218,21 +362,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { className="w-56" />
- - - -
- - setRfidTagId(v ?? "none")}> + + - {sessionOptions.map((opt) => ( + {tagOptions.map((opt) => ( {opt.label} @@ -240,15 +377,66 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
- + + +
+
+
+ + +
+
+ + +
+
+
- {!sessionId ? ( - Schedule a session first, then come back here to manage its roster. - ) : bookingsFetch.loading ? ( + {bookingsFetch.loading ? (
@@ -257,34 +445,60 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { columns={columns} rows={bookings} rowKey={(row) => row.bookingId} - emptyMessage="No bookings for this session yet." + emptyMessage="No bookings match these filters." renderMobileCard={(row) => (
-

{row.isGuest ? row.guestName ?? "Guest" : "Member"}

- {row.isGuest &&

{row.guestPhone}

} +

{row.facilityName ?? "-"}

+

+ {formatDateTime(row.startTime)} - {formatDateTime(row.endTime)} +

+
+

{row.isGuest ? row.guestName ?? "Guest" : "Member"}

+ {row.isGuest && row.customerId && Registered} +
{row.status}
{row.paymentStatus} -
+
{row.shareUrl && ( )} - {row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && ( + {canMarkPaid(row) && ( )} - {row.status !== "Cancelled" && ( + {row.status === "Confirmed" && row.isGuest && ( + + )} + {row.status === "Confirmed" && ( + + )} + {row.status === "Confirmed" && ( + + )} + {row.status === "Confirmed" && ( )} + {row.status === "CheckedIn" && ( + + )}
@@ -295,17 +509,21 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { - !open && setPayTarget(null)} onPaid={bookingsFetch.reload} /> + !open && setPayTarget(null)} onPaid={reload} /> + + !open && setReassignTarget(null)} onSaved={reload} /> + + !open && setRescheduleTarget(null)} onSaved={reload} /> !open && setCancelTarget(null)}> Cancel this booking? - This frees up a spot in the session. This cannot be undone. + This frees up the slot. This cannot be undone. Keep Booking @@ -313,6 +531,28 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { + + !open && setNoShowTarget(null)}> + + + Mark this booking as no-show? + Use this when the party never arrived for their slot. + + + Keep as Confirmed + Mark No-Show + + + + + !open && setCompleteTarget(null)} + onCompleted={() => { + reload() + availableTagsFetch.reload() + }} + />
) } @@ -320,11 +560,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { interface AddWalkInGuestDialogProps { open: boolean onOpenChange: (open: boolean) => void - facilitySessionId: string + facilities: Facility[] onCreated: () => void } -function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated }: 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() @@ -333,19 +576,52 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated if (open !== prevOpen) { setPrevOpen(open) if (open) { + setFacilityId(facilities[0]?.facilityId ?? "") + setSessionId("") + setCustomerId("") setGuestName("") setGuestPhone("") } } - const canSubmit = !!guestName.trim() && !!guestPhone.trim() && !busy + 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})`, + value: s.facilitySessionId, + })) + + const canSubmit = !!facilityId && !!sessionId && !!guestName.trim() && !!guestPhone.trim() && !busy async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!canSubmit) return try { const booking = await run(() => - createGuestBooking({ facilitySessionId, 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) @@ -369,10 +645,69 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated Add Walk-in Guest - Books a spot for a non-member and hands them an OTP receipt with a shareable link. + Books a spot in a Scheduled session for a non-member and hands them an OTP receipt. +
+ + +
+ +
+ + +
+ +
+ + +
+