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..4c746d3 --- /dev/null +++ b/src/components/hexa/views/adhoc-booking-tab.tsx @@ -0,0 +1,247 @@ +"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 { ApiError } from "@/lib/api/core-http" +import type { FacilityAvailability } from "@/lib/models/facility" +import type { Facility } from "@/lib/models/facility" +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 [duration, setDuration] = React.useState("60") + const [partySize, setPartySize] = React.useState("") + const [guestName, setGuestName] = React.useState("") + const [guestPhone, setGuestPhone] = React.useState("") + + 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 selectedFacility = adhocFacilities.find((f) => f.facilityId === facilityId) + const durationMinutes = Number(duration) || 0 + const estimatedPrice = selectedFacility ? (selectedFacility.pricePerHour * durationMinutes) / 60 : 0 + + const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId })) + const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking + const canSubmit = canCheck && !!guestName.trim() && !!guestPhone.trim() && !booking + + 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, + }) + ) + 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("") + 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 + /> +
+
+ + { + setDuration(e.target.value) + setAvailability(null) + }} + required + /> +
+
+ +
+
+ + setPartySize(e.target.value)} + /> +
+
+ +
+ Rs. {estimatedPrice.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
+
+ +
+ + {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-roster-tab.tsx b/src/components/hexa/views/booking-roster-tab.tsx index 155e5df..455d47b 100644 --- a/src/components/hexa/views/booking-roster-tab.tsx +++ b/src/components/hexa/views/booking-roster-tab.tsx @@ -1,18 +1,33 @@ "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 { getFacilitySessions } from "@/lib/api/facilities" -import type { Booking } from "@/lib/models/booking" +import { getRfidTags } from "@/lib/api/rfid-tags" +import type { Booking, BookingStatus } 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" @@ -47,9 +62,17 @@ 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" }, +] + function formatDateTime(iso: string) { return new Date(iso).toLocaleString(undefined, { day: "numeric", @@ -59,9 +82,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 } @@ -76,37 +105,53 @@ interface BookingRosterTabProps { } 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,13 +170,56 @@ 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. + } + } + + 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) 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", @@ -139,13 +227,19 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {

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

{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 +257,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" && ( + {row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && ( 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 +360,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) { className="w-56" />
- - - -
- - setRfidTagId(v ?? "none")}> + + - {sessionOptions.map((opt) => ( + {tagOptions.map((opt) => ( {opt.label} @@ -240,15 +375,66 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
- + + +
+
+
+ + +
+
+ + +
+
+
- {!sessionId ? ( - Schedule a session first, then come back here to manage its roster. - ) : bookingsFetch.loading ? ( + {bookingsFetch.loading ? (
@@ -257,34 +443,57 @@ 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.status}
{row.paymentStatus} -
+
{row.shareUrl && ( )} - {row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && ( + {row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && ( )} - {row.status !== "Cancelled" && ( + {row.status === "Confirmed" && row.isGuest && ( + + )} + {row.status === "Confirmed" && ( + + )} + {row.status === "Confirmed" && ( + + )} + {row.status === "Confirmed" && ( )} + {row.status === "CheckedIn" && ( + + )}
@@ -295,17 +504,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 +526,34 @@ 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)}> + + + Complete this booking? + + Ends the session and releases the RFID tag (if one was assigned) back to the available pool. + + + + Not Yet + Complete + + +
) } @@ -320,11 +561,13 @@ 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 [guestName, setGuestName] = React.useState("") const [guestPhone, setGuestPhone] = React.useState("") const { busy, run } = useMutation() @@ -333,19 +576,30 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated if (open !== prevOpen) { setPrevOpen(open) if (open) { + setFacilityId(facilities[0]?.facilityId ?? "") + setSessionId("") 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 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() }) ) if (booking.shareUrl) { navigator.clipboard.writeText(booking.shareUrl) @@ -369,10 +623,53 @@ 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. +
+ + +
+ +
+ + +
+