refactor: replace PlacesView with FacilitiesView in MembershipsView and remove PlacesView component

feat: add RfidTagsTab component for managing RFID tags

refactor: update auth API endpoints to use unified auth endpoint

feat: enhance bookings API with AdHoc booking functionality and new booking management features

refactor: update facilities API to include device assignment and availability checks

delete: remove obsolete places API and model definitions

feat: introduce RFID tag management API and model definitions
This commit is contained in:
2026-09-29 13:02:16 +05:30
parent a1b870fa8a
commit 23ae7b39d0
18 changed files with 1906 additions and 915 deletions
@@ -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> | void
mode?: "create" | "edit"
initialValues?: Partial<GymPlanFormValues>
/** 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<GymPlanFormValues>(EMPTY_VALUES)
const [customDuration, setCustomDuration] = React.useState(false)
@@ -309,24 +309,24 @@ function AddGymPlanDialog({
</div>
)}
{/* Places */}
{/* Facilities */}
<div className="flex flex-col gap-1.5">
<Label>Places</Label>
<Label>Facilities</Label>
<p className="text-xs text-muted-foreground">
Which access zones this plan reaches. Leave all unchecked for unrestricted
access (every terminal).
</p>
{places.length === 0 ? (
{facilities.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No places set up yet — this plan will be unrestricted.
No facilities set up yet — this plan will be unrestricted.
</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border p-3">
{places.map((place) => {
const checked = values.placeIds.includes(place.placeId)
{facilities.map((facility) => {
const checked = values.placeIds.includes(facility.facilityId)
return (
<label
key={place.placeId}
key={facility.facilityId}
className="flex items-center gap-2 text-sm"
>
<Checkbox
@@ -335,12 +335,12 @@ function AddGymPlanDialog({
set(
"placeIds",
isChecked
? [...values.placeIds, place.placeId]
: values.placeIds.filter((id) => id !== place.placeId)
? [...values.placeIds, facility.facilityId]
: values.placeIds.filter((id) => id !== facility.facilityId)
)
}
/>
{place.name}
{facility.name}
</label>
)
})}
@@ -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<FacilityAvailability | null>(null)
const [availabilityError, setAvailabilityError] = React.useState<string | null>(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 (
<InfoBanner>
No ad-hoc facilities yet. Add a facility with Booking Mode set to &quot;Ad-hoc&quot; under
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
</InfoBanner>
)
}
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-1.5">
<Label>
Facility <span className="text-destructive">*</span>
</Label>
<Select
items={facilityOptions}
value={facilityId}
onValueChange={(v) => {
setFacilityId(v ?? "")
setAvailability(null)
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select facility" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-start">
Start <span className="text-destructive">*</span>
</Label>
<Input
id="adhoc-start"
type="datetime-local"
value={startTime}
onChange={(e) => {
setStartTime(e.target.value)
setAvailability(null)
}}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-duration">
Duration (minutes) <span className="text-destructive">*</span>
</Label>
<Input
id="adhoc-duration"
type="number"
min={1}
value={duration}
onChange={(e) => {
setDuration(e.target.value)
setAvailability(null)
}}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-party-size">Party Size</Label>
<Input
id="adhoc-party-size"
type="number"
min={1}
placeholder="Optional headcount"
value={partySize}
onChange={(e) => setPartySize(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Estimated Price</Label>
<div className="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm font-medium">
Rs. {estimatedPrice.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</div>
</div>
<div className="flex items-end gap-3">
<Button type="button" variant="outline" onClick={handleCheckAvailability} disabled={!canCheck}>
{checking ? <Spinner className="size-4" /> : <SearchIcon />}
Check Availability
</Button>
{availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>}
{availability && (
<StatusBadge status={availability.unitsFree > 0 ? "success" : "danger"}>
{availability.unitsFree} / {availability.totalUnits} units free
</StatusBadge>
)}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-guest-name">
Guest Name <span className="text-destructive">*</span>
</Label>
<Input id="adhoc-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="adhoc-guest-phone">
Mobile Number <span className="text-destructive">*</span>
</Label>
<Input id="adhoc-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={!canSubmit}>
{booking ? <Spinner className="size-4" /> : <CopyIcon />}
Book Now
</Button>
</div>
</form>
<InfoBanner>
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.
</InfoBanner>
</div>
)
}
export { AdhocBookingTab }
+556 -56
View File
@@ -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<Booking | null>(null)
const [cancelTarget, setCancelTarget] = React.useState<Booking | null>(null)
const [noShowTarget, setNoShowTarget] = React.useState<Booking | null>(null)
const [completeTarget, setCompleteTarget] = React.useState<Booking | null>(null)
const [reassignTarget, setReassignTarget] = React.useState<Booking | null>(null)
const [rescheduleTarget, setRescheduleTarget] = React.useState<Booking | null>(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<Booking>[] = [
{
key: "facility",
header: "Facility & Time",
cell: (row) => (
<div>
<p className="font-medium">{row.facilityName ?? "-"}</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
</p>
</div>
),
},
{
key: "who",
header: "Booked By",
@@ -139,13 +227,19 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</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>}
</div>
),
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>,
cell: (row) => (
<div className="flex flex-col gap-1">
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
{row.rfidTagLabel && <span className="text-xs text-muted-foreground">Tag: {row.rfidTagLabel}</span>}
</div>
),
},
{
key: "payment",
@@ -163,7 +257,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
<div className="flex flex-wrap justify-center gap-1.5">
{row.shareUrl && (
<Tooltip>
<TooltipTrigger
@@ -176,7 +270,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<TooltipContent>Copy share link</TooltipContent>
</Tooltip>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
<Tooltip>
<TooltipTrigger
render={
@@ -188,7 +282,43 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<TooltipContent>Mark as paid</TooltipContent>
</Tooltip>
)}
{row.status !== "Cancelled" && (
{row.status === "Confirmed" && row.isGuest && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Reassign booking" onClick={() => setReassignTarget(row)}>
<ArrowRightLeftIcon />
</Button>
}
/>
<TooltipContent>Reassign to someone else</TooltipContent>
</Tooltip>
)}
{row.status === "Confirmed" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Reschedule booking" onClick={() => setRescheduleTarget(row)}>
<CalendarClockIcon />
</Button>
}
/>
<TooltipContent>Reschedule</TooltipContent>
</Tooltip>
)}
{row.status === "Confirmed" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Mark no-show" onClick={() => setNoShowTarget(row)}>
<UserXIcon />
</Button>
}
/>
<TooltipContent>Mark no-show</TooltipContent>
</Tooltip>
)}
{row.status === "Confirmed" && (
<Tooltip>
<TooltipTrigger
render={
@@ -200,6 +330,18 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<TooltipContent>Cancel booking</TooltipContent>
</Tooltip>
)}
{row.status === "CheckedIn" && (
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Complete booking" onClick={() => setCompleteTarget(row)}>
<CheckCheckIcon />
</Button>
}
/>
<TooltipContent>Complete (release tag)</TooltipContent>
</Tooltip>
)}
</div>
),
},
@@ -218,21 +360,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
className="w-56"
/>
</div>
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
Check In
</Button>
</form>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Label>Session</Label>
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
<SelectTrigger className="w-96 max-w-full">
<SelectValue placeholder="Select a session" />
<Label>RFID Tag</Label>
<Select items={tagOptions} value={rfidTagId || "none"} onValueChange={(v) => setRfidTagId(v ?? "none")}>
<SelectTrigger className="w-56">
<SelectValue placeholder="No tag" />
</SelectTrigger>
<SelectContent>
{sessionOptions.map((opt) => (
{tagOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
@@ -240,15 +375,66 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={!sessionId}>
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
Check In
</Button>
</form>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>Facility</Label>
<Select
items={facilityFilterOptions}
value={facilityFilter}
onValueChange={(v) => {
setFacilityFilter(v ?? "all")
reload()
}}
>
<SelectTrigger className="w-56">
<SelectValue placeholder="All facilities" />
</SelectTrigger>
<SelectContent>
{facilityFilterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Status</Label>
<Select
items={STATUS_OPTIONS}
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v ?? "all")
reload()
}}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
<PlusIcon />
Add Walk-in Guest
</Button>
</div>
{!sessionId ? (
<InfoBanner>Schedule a session first, then come back here to manage its roster.</InfoBanner>
) : bookingsFetch.loading ? (
{bookingsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
@@ -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) => (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
<p className="font-medium">{row.facilityName ?? "-"}</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
</p>
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
</div>
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2">
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>{row.paymentStatus}</StatusBadge>
<div className="flex gap-1.5">
<div className="flex flex-wrap justify-end gap-1.5">
{row.shareUrl && (
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
<CopyIcon />
</Button>
)}
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
<WalletIcon />
</Button>
)}
{row.status !== "Cancelled" && (
{row.status === "Confirmed" && row.isGuest && (
<Button variant="outline" size="icon-sm" aria-label="Reassign booking" onClick={() => setReassignTarget(row)}>
<ArrowRightLeftIcon />
</Button>
)}
{row.status === "Confirmed" && (
<Button variant="outline" size="icon-sm" aria-label="Reschedule booking" onClick={() => setRescheduleTarget(row)}>
<CalendarClockIcon />
</Button>
)}
{row.status === "Confirmed" && (
<Button variant="outline" size="icon-sm" aria-label="Mark no-show" onClick={() => setNoShowTarget(row)}>
<UserXIcon />
</Button>
)}
{row.status === "Confirmed" && (
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
<Trash2Icon />
</Button>
)}
{row.status === "CheckedIn" && (
<Button variant="outline" size="icon-sm" aria-label="Complete booking" onClick={() => setCompleteTarget(row)}>
<CheckCheckIcon />
</Button>
)}
</div>
</div>
</div>
@@ -295,17 +504,21 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
<AddWalkInGuestDialog
open={guestDialogOpen}
onOpenChange={setGuestDialogOpen}
facilitySessionId={sessionId}
onCreated={bookingsFetch.reload}
facilities={scheduledFacilities}
onCreated={reload}
/>
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={bookingsFetch.reload} />
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={reload} />
<ReassignDialog booking={reassignTarget} onOpenChange={(open) => !open && setReassignTarget(null)} onSaved={reload} />
<RescheduleDialog booking={rescheduleTarget} onOpenChange={(open) => !open && setRescheduleTarget(null)} onSaved={reload} />
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this booking?</AlertDialogTitle>
<AlertDialogDescription>This frees up a spot in the session. This cannot be undone.</AlertDialogDescription>
<AlertDialogDescription>This frees up the slot. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
@@ -313,6 +526,34 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!noShowTarget} onOpenChange={(open) => !open && setNoShowTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this booking as no-show?</AlertDialogTitle>
<AlertDialogDescription>Use this when the party never arrived for their slot.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep as Confirmed</AlertDialogCancel>
<AlertDialogAction onClick={confirmNoShow}>Mark No-Show</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!completeTarget} onOpenChange={(open) => !open && setCompleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Complete this booking?</AlertDialogTitle>
<AlertDialogDescription>
Ends the session and releases the RFID tag (if one was assigned) back to the available pool.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not Yet</AlertDialogCancel>
<AlertDialogAction onClick={confirmComplete}>Complete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -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
<DialogHeader>
<DialogTitle>Add Walk-in Guest</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label>
Facility <span className="text-destructive">*</span>
</Label>
<Select
items={facilityOptions}
value={facilityId}
onValueChange={(v) => {
setFacilityId(v ?? "")
setSessionId("")
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select facility" />
</SelectTrigger>
<SelectContent>
{facilityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>
Session <span className="text-destructive">*</span>
</Label>
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={sessionsFetch.loading ? "Loading…" : "Select session"} />
</SelectTrigger>
<SelectContent>
{sessionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="guest-name">
Guest Name <span className="text-destructive">*</span>
@@ -471,4 +768,207 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
)
}
interface ReassignDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
onSaved: () => void
}
function ReassignDialog({ booking, onOpenChange, onSaved }: ReassignDialogProps) {
const [guestName, setGuestName] = React.useState("")
const [guestPhone, setGuestPhone] = React.useState("")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
setGuestName(booking?.guestName ?? "")
setGuestPhone(booking?.guestPhone ?? "")
}
const canSubmit = !!booking && !!guestName.trim() && !!guestPhone.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!booking || !canSubmit) return
try {
await run(() => reassignBooking(booking.bookingId, { guestName: guestName.trim(), guestPhone: guestPhone.trim() }))
toast.success("Booking reassigned")
onOpenChange(false)
onSaved()
} 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">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Reassign Booking</DialogTitle>
<DialogDescription>
Hand this slot to a different person if the original party can&apos;t make it. The
OTP and slot stay the same.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reassign-name">
Name <span className="text-destructive">*</span>
</Label>
<Input id="reassign-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reassign-phone">
Mobile Number <span className="text-destructive">*</span>
</Label>
<Input id="reassign-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Reassign
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface RescheduleDialogProps {
booking: Booking | null
onOpenChange: (open: boolean) => void
onSaved: () => void
}
function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) {
const isAdhoc = booking?.bookingMode === "AdHoc"
const [startTime, setStartTime] = React.useState("")
const [duration, setDuration] = React.useState("60")
const [sessionId, setSessionId] = React.useState("")
const { busy, run } = useMutation()
const [prevBooking, setPrevBooking] = React.useState(booking)
if (booking !== prevBooking) {
setPrevBooking(booking)
if (booking) {
setStartTime(toLocalInputValue(new Date(booking.startTime)))
setDuration(String(Math.round((new Date(booking.endTime).getTime() - new Date(booking.startTime).getTime()) / 60000)))
setSessionId("")
}
}
const sessionsFetch = useFetch(() =>
booking && !isAdhoc ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([])
)
const sessionOptions = (sessionsFetch.data ?? [])
.filter(
(s: FacilitySession) =>
!s.isCancelled && new Date(s.startTime) > new Date() && s.facilitySessionId !== booking?.facilitySessionId
)
.map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId }))
const canSubmit = isAdhoc
? !!booking && !!startTime && Number(duration) >= 1 && !busy
: !!booking && !!sessionId && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!booking || !canSubmit) return
try {
await run(() =>
rescheduleBooking(
booking.bookingId,
isAdhoc
? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) }
: { facilitySessionId: sessionId }
)
)
toast.success("Booking rescheduled")
onOpenChange(false)
onSaved()
} 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">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Reschedule Booking</DialogTitle>
<DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription>
</DialogHeader>
{isAdhoc ? (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="reschedule-start">
Start <span className="text-destructive">*</span>
</Label>
<Input
id="reschedule-start"
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reschedule-duration">
Duration (min) <span className="text-destructive">*</span>
</Label>
<Input
id="reschedule-duration"
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
required
/>
</div>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label>
New Session <span className="text-destructive">*</span>
</Label>
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={sessionsFetch.loading ? "Loading…" : "Select session"} />
</SelectTrigger>
<SelectContent>
{sessionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Reschedule
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { BookingRosterTab }
+21 -375
View File
@@ -1,29 +1,18 @@
"use client"
import * as React from "react"
import {
CalendarPlusIcon,
CheckIcon,
DumbbellIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
import { toast } from "sonner"
import {
cancelFacilitySession,
createFacility,
createFacilitySession,
getFacilities,
getFacilitySessions,
setFacilityStatus,
updateFacility,
} from "@/lib/api/facilities"
import type { Facility, FacilityInput, FacilitySession } from "@/lib/models/facility"
import type { Facility, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { useMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
@@ -34,7 +23,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -55,22 +43,16 @@ import {
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
const FACILITY_TYPE_OPTIONS: { label: string; value: FacilityInput["facilityType"] }[] = [
{ label: "Gym", value: "Gym" },
{ label: "Swimming Pool", value: "Pool" },
{ label: "Basketball Court", value: "Court" },
{ label: "Class (e.g. Karate)", value: "Class" },
]
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
@@ -98,29 +80,30 @@ function BookingsView() {
<div className="flex flex-col gap-4">
<PageHeader
title="Bookings"
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
description="Scheduled sessions, ad-hoc reservations, the booking roster, RFID tags and time-bank cards"
/>
<Tabs defaultValue="facilities" className="gap-4">
<Tabs defaultValue="sessions" className="gap-4">
<TabsList>
<TabsTrigger value="facilities">Facilities</TabsTrigger>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
<TabsTrigger value="roster">Roster &amp; Check-in</TabsTrigger>
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
</TabsList>
<TabsContent value="facilities">
<FacilitiesTab
facilities={facilities}
onReload={facilitiesFetch.reload}
/>
</TabsContent>
<TabsContent value="sessions">
<SessionsTab facilities={facilities} />
</TabsContent>
<TabsContent value="adhoc">
<AdhocBookingTab facilities={facilities} />
</TabsContent>
<TabsContent value="roster">
<BookingRosterTab facilities={facilities} />
</TabsContent>
<TabsContent value="rfid-tags">
<RfidTagsTab />
</TabsContent>
<TabsContent value="time-bank-cards">
<TimeBankCardsTab facilities={facilities} />
</TabsContent>
@@ -129,345 +112,6 @@ function BookingsView() {
)
}
// ---------- Facilities ----------
interface FacilitiesTabProps {
facilities: Facility[]
onReload: () => void
}
function FacilitiesTab({ facilities, onReload }: FacilitiesTabProps) {
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
const [statusTarget, setStatusTarget] = React.useState<Facility | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
async function applyStatus(facility: Facility, isActive: boolean) {
setStatusPendingId(facility.facilityId)
try {
await runMutation(() => setFacilityStatus(facility.facilityId, isActive))
toast.success(isActive ? "Facility activated" : "Facility deactivated", {
description: `"${facility.name}" is now ${isActive ? "active" : "inactive"}.`,
})
onReload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
const columns: DataTableColumn<Facility>[] = [
{
key: "name",
header: "Facility",
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">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && (
<p className="text-xs text-muted-foreground">{row.description}</p>
)}
</div>
</div>
),
},
{ key: "type", header: "Type", cell: (row) => <Badge variant="outline">{row.facilityType}</Badge> },
{ key: "capacity", header: "Default Capacity", cell: (row) => row.defaultCapacity },
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
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="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit facility</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button
onClick={() => {
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Facility
</Button>
</div>
<DataTable
columns={columns}
rows={facilities}
rowKey={(row) => row.facilityId}
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
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">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<Badge variant="outline">{row.facilityType}</Badge>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">Capacity: {row.defaultCapacity}</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<FacilityDialog
open={dialogOpen || !!editTarget}
onOpenChange={(open) => {
if (!open) {
setDialogOpen(false)
setEditTarget(null)
}
}}
editTarget={editTarget}
onSaved={onReload}
/>
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{statusTarget?.isActive ? "Deactivate this facility?" : "Activate this facility?"}
</AlertDialogTitle>
<AlertDialogDescription>
{statusTarget?.isActive
? `"${statusTarget?.name}" will stop accepting new bookings until reactivated.`
: `"${statusTarget?.name}" will accept new bookings again.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const facility = statusTarget
setStatusTarget(null)
if (facility) void applyStatus(facility, !facility.isActive)
}}
>
{statusTarget?.isActive ? "Deactivate" : "Activate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface FacilityDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget: Facility | null
onSaved: () => void
}
function FacilityDialog({ open, onOpenChange, editTarget, onSaved }: FacilityDialogProps) {
const [name, setName] = React.useState("")
const [facilityType, setFacilityType] = React.useState<FacilityInput["facilityType"]>("Gym")
const [description, setDescription] = React.useState("")
const [capacity, setCapacity] = React.useState("1")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setName(editTarget?.name ?? "")
setFacilityType(editTarget?.facilityType ?? "Gym")
setDescription(editTarget?.description ?? "")
setCapacity(String(editTarget?.defaultCapacity ?? 1))
}
}
const canSubmit = !!name.trim() && Number(capacity) >= 1 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
const input: FacilityInput = {
name: name.trim(),
facilityType,
description: description.trim() || undefined,
defaultCapacity: Number(capacity),
}
try {
await run(() =>
editTarget ? updateFacility(editTarget.facilityId, input) : createFacility(input)
)
toast.success(editTarget ? "Facility updated" : "Facility created", {
description: `"${input.name}" was saved.`,
})
onOpenChange(false)
onSaved()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{editTarget ? "Edit Facility" : "Add Facility"}</DialogTitle>
<DialogDescription>
{editTarget ? "Update this facility's details." : "Add the gym, pool, court or a class."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="facility-name"
placeholder="e.g. Main Gym Floor"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select
items={FACILITY_TYPE_OPTIONS}
value={facilityType}
onValueChange={(v) => setFacilityType((v as FacilityInput["facilityType"]) ?? "Gym")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{FACILITY_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-capacity">
Default Capacity <span className="text-destructive">*</span>
</Label>
<Input
id="facility-capacity"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-description">Description</Label>
<Textarea
id="facility-description"
placeholder="Optional short description"
maxLength={300}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{editTarget ? "Save Changes" : "Add Facility"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---------- Sessions ----------
interface SessionsTabProps {
@@ -475,6 +119,8 @@ interface SessionsTabProps {
}
function SessionsTab({ facilities }: SessionsTabProps) {
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled")
const [facilityFilter, setFacilityFilter] = React.useState("all")
const sessionsFetch = useFetch(() =>
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
@@ -487,7 +133,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
const filterOptions = [
{ label: "All facilities", value: "all" },
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
...scheduledFacilities.map((f) => ({ label: f.name, value: f.facilityId })),
]
async function confirmCancel() {
@@ -567,7 +213,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
</SelectContent>
</Select>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={facilities.length === 0}>
<Button onClick={() => setDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
<CalendarPlusIcon />
Schedule Session
</Button>
@@ -616,7 +262,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
<ScheduleSessionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
facilities={facilities}
facilities={scheduledFacilities}
onScheduled={sessionsFetch.reload}
/>
@@ -698,7 +344,7 @@ function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }:
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Schedule Session</DialogTitle>
<DialogDescription>Open a bookable time slot for a facility.</DialogDescription>
<DialogDescription>Open a bookable time slot for a Scheduled-mode facility.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
@@ -0,0 +1,511 @@
"use client"
import * as React from "react"
import { CheckIcon, DumbbellIcon, PencilIcon, PlusIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { getAccessDevices } from "@/lib/api/access-devices"
import {
assignFacilityDevice,
createFacility,
getFacilities,
setFacilityStatus,
updateFacility,
} from "@/lib/api/facilities"
import type { Facility, FacilityInput } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
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 { Textarea } from "@/components/ui/textarea"
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 { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
const BOOKING_MODE_OPTIONS: { label: string; value: FacilityInput["bookingMode"] }[] = [
{ label: "Scheduled (classes — shared capacity per session)", value: "Scheduled" },
{ label: "Ad-hoc (courts/pool — independently bookable units)", value: "AdHoc" },
]
interface FacilityFormValues {
name: string
bookingMode: FacilityInput["bookingMode"]
description: string
defaultCapacity: string
numberOfUnits: string
pricePerHour: string
}
const EMPTY_VALUES: FacilityFormValues = {
name: "",
bookingMode: "Scheduled",
description: "",
defaultCapacity: "1",
numberOfUnits: "1",
pricePerHour: "0",
}
interface FacilityDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: FacilityFormValues) => Promise<void> | void
mode: "create" | "edit"
initialValues?: Partial<FacilityFormValues>
}
function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: FacilityDialogProps) {
const [values, setValues] = React.useState<FacilityFormValues>(EMPTY_VALUES)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) setValues({ ...EMPTY_VALUES, ...initialValues })
}
function set<K extends keyof FacilityFormValues>(key: K, value: FacilityFormValues[K]) {
setValues((v) => ({ ...v, [key]: value }))
}
const isAdhoc = values.bookingMode === "AdHoc"
const unitsValid = isAdhoc ? Number(values.numberOfUnits) >= 1 : Number(values.defaultCapacity) >= 1
const canSubmit = !!values.name.trim() && unitsValid && Number(values.pricePerHour) >= 0 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() => Promise.resolve(onSubmit(values)))
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Add Facility" : "Edit Facility"}</DialogTitle>
<DialogDescription>
A bookable area (gym floor, pool, court, class) that&apos;s also the access zone for
its fingerprint terminal. Assign the terminal from the table after creating it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="facility-name"
placeholder="e.g. Main Gym Floor"
value={values.name}
onChange={(e) => set("name", e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Booking Mode</Label>
<Select
items={BOOKING_MODE_OPTIONS}
value={values.bookingMode}
onValueChange={(v) => set("bookingMode", (v as FacilityInput["bookingMode"]) ?? "Scheduled")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select booking mode" />
</SelectTrigger>
<SelectContent>
{BOOKING_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
{isAdhoc ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-units">
Number of Units <span className="text-destructive">*</span>
</Label>
<Input
id="facility-units"
type="number"
min={1}
value={values.numberOfUnits}
onChange={(e) => set("numberOfUnits", e.target.value)}
required
/>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-capacity">
Default Capacity <span className="text-destructive">*</span>
</Label>
<Input
id="facility-capacity"
type="number"
min={1}
value={values.defaultCapacity}
onChange={(e) => set("defaultCapacity", e.target.value)}
required
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-price">
Price / Hour (Rs.) <span className="text-destructive">*</span>
</Label>
<Input
id="facility-price"
type="number"
min={0}
step="0.01"
value={values.pricePerHour}
onChange={(e) => set("pricePerHour", e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-description">Description</Label>
<Textarea
id="facility-description"
placeholder="Optional short description"
maxLength={300}
value={values.description}
onChange={(e) => set("description", e.target.value)}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Facility" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function FacilitiesView() {
const facilitiesFetch = useFetch(() => getFacilities(true))
const facilities = facilitiesFetch.data ?? []
const devicesFetch = useFetch(() => getAccessDevices())
const devices = devicesFetch.data?.devices ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
const [devicePendingId, setDevicePendingId] = React.useState<string | null>(null)
async function handleCreate(values: FacilityFormValues) {
const input: FacilityInput = {
name: values.name.trim(),
bookingMode: values.bookingMode,
description: values.description.trim() || undefined,
defaultCapacity: Number(values.defaultCapacity) || 1,
numberOfUnits: Number(values.numberOfUnits) || 1,
pricePerHour: Number(values.pricePerHour) || 0,
}
await createFacility(input)
toast.success("Facility created", { description: `"${input.name}" was added.` })
facilitiesFetch.reload()
}
async function handleEdit(values: FacilityFormValues) {
if (!editTarget) return
await updateFacility(editTarget.facilityId, {
name: values.name.trim(),
bookingMode: values.bookingMode,
description: values.description.trim() || undefined,
defaultCapacity: Number(values.defaultCapacity) || 1,
numberOfUnits: Number(values.numberOfUnits) || 1,
pricePerHour: Number(values.pricePerHour) || 0,
})
toast.success("Facility updated")
facilitiesFetch.reload()
}
function startEdit(facility: Facility) {
setDialogMode("edit")
setEditTarget(facility)
setDialogOpen(true)
}
async function toggleStatus(facility: Facility) {
setStatusPendingId(facility.facilityId)
try {
await runMutation(() => setFacilityStatus(facility.facilityId, !facility.isActive))
toast.success(facility.isActive ? "Facility deactivated" : "Facility activated")
facilitiesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
async function changeDevice(facility: Facility, deviceId: string) {
setDevicePendingId(facility.facilityId)
try {
await runMutation(() => assignFacilityDevice(facility.facilityId, deviceId === "none" ? null : deviceId))
toast.success("Device assignment updated")
facilitiesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setDevicePendingId(null)
}
}
const assignedDeviceIds = new Set(facilities.map((f) => f.deviceId).filter((id): id is string => !!id))
const columns: DataTableColumn<Facility>[] = [
{
key: "name",
header: "Facility",
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">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && <p className="text-xs text-muted-foreground">{row.description}</p>}
</div>
</div>
),
},
{
key: "mode",
header: "Mode",
cell: (row) => (
<div className="flex flex-col gap-0.5">
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge>
<span className="text-xs text-muted-foreground">
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`}
</span>
</div>
),
},
{
key: "price",
header: "Price / Hour",
cell: (row) => `Rs. ${row.pricePerHour.toLocaleString()}`,
},
{
key: "device",
header: "Fingerprint machine",
cell: (row) => (
<Select
items={[{ label: "Unassigned", value: "none" }, ...devices.map((d) => ({ label: d.deviceName, value: d.deviceId }))]}
value={row.deviceId ?? "none"}
onValueChange={(v) => changeDevice(row, v ?? "none")}
>
<SelectTrigger className="w-52" disabled={devicePendingId === row.facilityId}>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Unassigned</SelectItem>
{devices.map((d) => (
<SelectItem
key={d.deviceId}
value={d.deviceId}
disabled={d.deviceId !== row.deviceId && assignedDeviceIds.has(d.deviceId)}
>
{d.deviceName}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
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="Edit facility" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit facility</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
</Tooltip>
</div>
),
},
]
if (facilitiesFetch.loading) {
return <PageSkeleton />
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Facilities</h2>
<p className="text-sm text-muted-foreground">
Bookable areas and access zones — the gym floor, pool, courts, classes — each tied to
one fingerprint machine.
</p>
</div>
<Button
onClick={() => {
setDialogMode("create")
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Facility
</Button>
</div>
<DataTable
columns={columns}
rows={facilities}
rowKey={(row) => row.facilityId}
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
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">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`} · Rs. {row.pricePerHour.toLocaleString()}/hr
</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<InfoBanner>
Members reach only the machine(s) belonging to their plan&apos;s facilities. A plan with
no facilities selected stays unrestricted (every terminal).
</InfoBanner>
<FacilityDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
mode={dialogMode}
initialValues={
dialogMode === "edit" && editTarget
? {
name: editTarget.name,
bookingMode: editTarget.bookingMode,
description: editTarget.description ?? "",
defaultCapacity: String(editTarget.defaultCapacity),
numberOfUnits: String(editTarget.numberOfUnits),
pricePerHour: String(editTarget.pricePerHour),
}
: undefined
}
/>
</div>
)
}
export { FacilitiesView }
+23 -23
View File
@@ -21,7 +21,7 @@ import {
setGymPlanStatus,
updateGymPlan,
} from "@/lib/api/gym-plans"
import { getPlaces } from "@/lib/api/places"
import { getFacilities } from "@/lib/api/facilities"
import type { GymPlan } from "@/lib/models/gym-plan"
import { sortByNewest } from "@/lib/validation"
import { useFetch } from "@/lib/use-fetch"
@@ -114,8 +114,8 @@ interface GymPlansViewProps {
function GymPlansView({ demo = false }: GymPlansViewProps) {
const plansFetch = useFetch(demo ? async () => DEMO_PLANS : () => getGymPlans(true))
const placesFetch = useFetch(demo ? async () => [] : () => getPlaces(false))
const places = placesFetch.data ?? []
const facilitiesFetch = useFetch(demo ? async () => [] : () => getFacilities(false))
const facilities = facilitiesFetch.data ?? []
const [demoPlans, setDemoPlans] = React.useState<GymPlan[]>(DEMO_PLANS)
const plans = React.useMemo(
() => sortByNewest(demo ? demoPlans : (plansFetch.data ?? []), (p) => p.createdAt),
@@ -125,7 +125,7 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
const [search, setSearch] = React.useState("")
const [status, setStatus] = React.useState("all")
const [duration, setDuration] = React.useState("all")
const [placeFilter, setPlaceFilter] = React.useState("all")
const [facilityFilter, setFacilityFilter] = React.useState("all")
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(10)
@@ -149,10 +149,10 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
const matchesDuration =
duration === "all" ||
(duration === "fixed" ? p.durationMode === "FixedRange" : String(p.durationInDays) === duration)
const matchesPlace =
placeFilter === "all" ||
(placeFilter === "unrestricted" ? p.placeIds.length === 0 : p.placeIds.includes(placeFilter))
return matchesSearch && matchesStatus && matchesDuration && matchesPlace
const matchesFacility =
facilityFilter === "all" ||
(facilityFilter === "unrestricted" ? p.placeIds.length === 0 : p.placeIds.includes(facilityFilter))
return matchesSearch && matchesStatus && matchesDuration && matchesFacility
})
const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1)
@@ -206,9 +206,9 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
allowSplitPayments: allowSplit,
installmentCount: installmentCount ?? 1,
placeIds: values.placeIds,
placeNames: places
.filter((p) => values.placeIds.includes(p.placeId))
.map((p) => p.name),
placeNames: facilities
.filter((f) => values.placeIds.includes(f.facilityId))
.map((f) => f.name),
}
return { apiInput, planPatch }
@@ -304,8 +304,8 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
),
},
{
key: "places",
header: "Places",
key: "facilities",
header: "Facilities",
cell: (row) => (
<div className="flex flex-wrap items-center gap-1.5">
{row.placeNames.length ? (
@@ -467,17 +467,17 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
},
},
{
key: "place",
label: "Place",
value: placeFilter,
placeholder: "All Places",
key: "facility",
label: "Facility",
value: facilityFilter,
placeholder: "All Facilities",
options: [
{ label: "All Places", value: "all" },
{ label: "All Facilities", value: "all" },
{ label: "Unrestricted", value: "unrestricted" },
...places.map((p) => ({ label: p.name, value: p.placeId })),
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
],
onChange: (v) => {
setPlaceFilter(v)
setFacilityFilter(v)
setPage(1)
},
},
@@ -488,7 +488,7 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
setSearch("")
setStatus("all")
setDuration("all")
setPlaceFilter("all")
setFacilityFilter("all")
setPage(1)
}}
/>
@@ -581,14 +581,14 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
</InfoBanner>
<DetailPanel open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)} title={viewTarget?.planName ?? "Membership plan"} description="Plan details">
{viewTarget && <div className="grid grid-cols-2 gap-4"><div><p className="text-xs text-muted-foreground">Plan type</p><p className="mt-1 font-medium">{planScopeLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Price</p><p className="mt-1 font-medium">Rs. {viewTarget.planPrice.toLocaleString()}</p></div><div><p className="text-xs text-muted-foreground">Duration</p><p className="mt-1 font-medium">{planDurationLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Status</p><p className="mt-1"><StatusBadge status={viewTarget.isActive ? "success" : "neutral"}>{viewTarget.isActive ? "Active" : "Inactive"}</StatusBadge></p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Description</p><p className="mt-1 text-sm">{viewTarget.planDescription ?? "No description"}</p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Places</p><p className="mt-1 flex flex-wrap gap-1.5">{viewTarget.placeNames.length ? viewTarget.placeNames.map((name) => <Badge key={name} variant="outline">{name}</Badge>) : <span className="text-sm text-muted-foreground">Unrestricted (every terminal)</span>}</p></div>{viewTarget.allowSplitPayments && viewTarget.installmentCount && <div className="col-span-2 rounded-lg bg-muted/50 p-3"><p className="text-sm font-medium">Installment schedule</p><div className="mt-2 grid gap-2 text-sm">{Array.from({ length: viewTarget.installmentCount }, (_, index) => <div key={index} className="flex items-center justify-between border-b pb-2 last:border-0 last:pb-0"><span>Installment {index + 1}</span><span className="font-medium">Rs. {(viewTarget.planPrice / viewTarget.installmentCount).toLocaleString()}</span><StatusBadge status={index === 0 ? "success" : "warning"}>{index === 0 ? "Paid" : "Pending"}</StatusBadge></div>)}</div></div>}</div>}
{viewTarget && <div className="grid grid-cols-2 gap-4"><div><p className="text-xs text-muted-foreground">Plan type</p><p className="mt-1 font-medium">{planScopeLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Price</p><p className="mt-1 font-medium">Rs. {viewTarget.planPrice.toLocaleString()}</p></div><div><p className="text-xs text-muted-foreground">Duration</p><p className="mt-1 font-medium">{planDurationLabel(viewTarget)}</p></div><div><p className="text-xs text-muted-foreground">Status</p><p className="mt-1"><StatusBadge status={viewTarget.isActive ? "success" : "neutral"}>{viewTarget.isActive ? "Active" : "Inactive"}</StatusBadge></p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Description</p><p className="mt-1 text-sm">{viewTarget.planDescription ?? "No description"}</p></div><div className="col-span-2"><p className="text-xs text-muted-foreground">Facilities</p><p className="mt-1 flex flex-wrap gap-1.5">{viewTarget.placeNames.length ? viewTarget.placeNames.map((name) => <Badge key={name} variant="outline">{name}</Badge>) : <span className="text-sm text-muted-foreground">Unrestricted (every terminal)</span>}</p></div>{viewTarget.allowSplitPayments && viewTarget.installmentCount && <div className="col-span-2 rounded-lg bg-muted/50 p-3"><p className="text-sm font-medium">Installment schedule</p><div className="mt-2 grid gap-2 text-sm">{Array.from({ length: viewTarget.installmentCount }, (_, index) => <div key={index} className="flex items-center justify-between border-b pb-2 last:border-0 last:pb-0"><span>Installment {index + 1}</span><span className="font-medium">Rs. {(viewTarget.planPrice / viewTarget.installmentCount).toLocaleString()}</span><StatusBadge status={index === 0 ? "success" : "warning"}>{index === 0 ? "Paid" : "Pending"}</StatusBadge></div>)}</div></div>}</div>}
</DetailPanel>
<AddGymPlanDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
mode={dialogMode}
places={places}
facilities={facilities}
initialValues={
dialogMode === "edit" && editTarget
? {
@@ -1,7 +1,7 @@
"use client"
import { FacilitiesView } from "@/components/hexa/views/facilities-view"
import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
import { PlacesView } from "@/components/hexa/views/places-view"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
function MembershipsView() {
@@ -9,14 +9,14 @@ function MembershipsView() {
<Tabs defaultValue="plans" className="gap-4">
<TabsList>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="places">Places</TabsTrigger>
<TabsTrigger value="facilities">Facilities</TabsTrigger>
</TabsList>
<TabsContent value="plans">
<GymPlansView />
</TabsContent>
<TabsContent value="places">
<PlacesView />
<TabsContent value="facilities">
<FacilitiesView />
</TabsContent>
</Tabs>
)
-378
View File
@@ -1,378 +0,0 @@
"use client"
import * as React from "react"
import { CheckIcon, MapPinIcon, PencilIcon, PlusIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { getAccessDevices } from "@/lib/api/access-devices"
import { assignPlaceDevice, createPlace, getPlaces, setPlaceStatus, updatePlace } from "@/lib/api/places"
import type { Place } from "@/lib/models/place"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
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 { Textarea } from "@/components/ui/textarea"
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 { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
interface PlaceFormValues {
name: string
description: string
}
const EMPTY_VALUES: PlaceFormValues = { name: "", description: "" }
interface PlaceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: PlaceFormValues) => Promise<void> | void
mode: "create" | "edit"
initialValues?: Partial<PlaceFormValues>
}
function PlaceDialog({ open, onOpenChange, onSubmit, mode, initialValues }: PlaceDialogProps) {
const [values, setValues] = React.useState<PlaceFormValues>(EMPTY_VALUES)
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) setValues({ ...EMPTY_VALUES, ...initialValues })
}
const canSubmit = !!values.name.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
await run(() => Promise.resolve(onSubmit(values)))
onOpenChange(false)
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{mode === "create" ? "Add Place" : "Edit Place"}</DialogTitle>
<DialogDescription>
A physical access zone (e.g. &quot;Pool Area&quot;, &quot;Indoor Gym&quot;).
Assign its fingerprint terminal from the table after creating it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="place-name"
placeholder="e.g. Pool Area"
value={values.name}
onChange={(e) => setValues((v) => ({ ...v, name: e.target.value }))}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-description">Description</Label>
<Textarea
id="place-description"
placeholder="Optional notes"
maxLength={300}
value={values.description}
onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
/>
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{mode === "create" ? "Add Place" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function PlacesView() {
const placesFetch = useFetch(() => getPlaces(true))
const places = placesFetch.data ?? []
const devicesFetch = useFetch(() => getAccessDevices())
const devices = devicesFetch.data?.devices ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
const [editTarget, setEditTarget] = React.useState<Place | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
const [devicePendingId, setDevicePendingId] = React.useState<string | null>(null)
async function handleCreate(values: PlaceFormValues) {
await createPlace({ name: values.name.trim(), description: values.description.trim() || undefined })
toast.success("Place created", { description: `"${values.name}" was added.` })
placesFetch.reload()
}
async function handleEdit(values: PlaceFormValues) {
if (!editTarget) return
await updatePlace(editTarget.placeId, {
name: values.name.trim(),
description: values.description.trim() || undefined,
})
toast.success("Place updated")
placesFetch.reload()
}
function startEdit(place: Place) {
setDialogMode("edit")
setEditTarget(place)
setDialogOpen(true)
}
async function toggleStatus(place: Place) {
setStatusPendingId(place.placeId)
try {
await runMutation(() => setPlaceStatus(place.placeId, !place.isActive))
toast.success(place.isActive ? "Place deactivated" : "Place activated")
placesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
async function changeDevice(place: Place, deviceId: string) {
setDevicePendingId(place.placeId)
try {
await runMutation(() => assignPlaceDevice(place.placeId, deviceId === "none" ? null : deviceId))
toast.success("Device assignment updated")
placesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setDevicePendingId(null)
}
}
const assignedDeviceIds = new Set(places.map((p) => p.deviceId).filter((id): id is string => !!id))
const columns: DataTableColumn<Place>[] = [
{
key: "name",
header: "Place",
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">
<MapPinIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && <p className="text-sm text-muted-foreground">{row.description}</p>}
</div>
</div>
),
},
{
key: "device",
header: "Fingerprint machine",
cell: (row) => (
<Select
items={[{ label: "Unassigned", value: "none" }, ...devices.map((d) => ({ label: d.deviceName, value: d.deviceId }))]}
value={row.deviceId ?? "none"}
onValueChange={(v) => changeDevice(row, v ?? "none")}
>
<SelectTrigger className="w-56" disabled={devicePendingId === row.placeId}>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Unassigned</SelectItem>
{devices.map((d) => (
<SelectItem
key={d.deviceId}
value={d.deviceId}
disabled={d.deviceId !== row.deviceId && assignedDeviceIds.has(d.deviceId)}
>
{d.deviceName}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
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="Edit place" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit place</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate place" : "Activate place"}
disabled={statusPendingId === row.placeId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.placeId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate place" : "Activate place"}</TooltipContent>
</Tooltip>
</div>
),
},
]
if (placesFetch.loading) {
return <PageSkeleton />
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Places</h2>
<p className="text-sm text-muted-foreground">
Physical access zones, each tied to one fingerprint machine.
</p>
</div>
<Button
onClick={() => {
setDialogMode("create")
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Place
</Button>
</div>
<DataTable
columns={columns}
rows={places}
rowKey={(row) => row.placeId}
emptyMessage="No places yet. Add one (e.g. Pool Area, Indoor Gym) to get started."
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">
<MapPinIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && <p className="text-sm text-muted-foreground">{row.description}</p>}
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<Badge variant="outline">{row.deviceName ?? "No device assigned"}</Badge>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit place" onClick={() => startEdit(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate place" : "Activate place"}
disabled={statusPendingId === row.placeId}
onClick={() => toggleStatus(row)}
>
{statusPendingId === row.placeId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
) : (
<XIcon className="text-muted-foreground" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<InfoBanner>
Members reach only the machine(s) belonging to their plan&apos;s places. A plan with no
places selected stays unrestricted (every terminal).
</InfoBanner>
<PlaceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
mode={dialogMode}
initialValues={
dialogMode === "edit" && editTarget
? { name: editTarget.name, description: editTarget.description ?? "" }
: undefined
}
/>
</div>
)
}
export { PlacesView }
+336
View File
@@ -0,0 +1,336 @@
"use client"
import * as React from "react"
import { ArchiveIcon, CheckIcon, PencilIcon, PlusIcon, RadioIcon, TriangleAlertIcon } from "lucide-react"
import { toast } from "sonner"
import { createRfidTag, getRfidTags, setRfidTagStatus, updateRfidTag } from "@/lib/api/rfid-tags"
import type { RfidTag } from "@/lib/models/rfid-tag"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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"
function tagStatusTone(status: string) {
if (status === "Available") return "success" as const
if (status === "InUse") return "info" as const
if (status === "Lost") return "danger" as const
return "neutral" as const
}
function RfidTagsTab() {
const tagsFetch = useFetch(() => getRfidTags())
const tags = tagsFetch.data ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editTarget, setEditTarget] = React.useState<RfidTag | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
async function applyStatus(tag: RfidTag, status: "Available" | "Lost" | "Retired") {
setStatusPendingId(tag.rfidTagId)
try {
await runMutation(() => setRfidTagStatus(tag.rfidTagId, status))
toast.success(`Tag marked ${status}`)
tagsFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
const columns: DataTableColumn<RfidTag>[] = [
{
key: "tag",
header: "Tag",
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">
<RadioIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.tagCode}</p>
{row.label && <p className="text-xs text-muted-foreground">{row.label}</p>}
</div>
</div>
),
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={tagStatusTone(row.status)}>{row.status}</StatusBadge>,
},
{
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="Edit tag"
onClick={() => {
setEditTarget(row)
setDialogOpen(true)
}}
>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit tag</TooltipContent>
</Tooltip>
{row.status !== "Available" && row.status !== "InUse" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Reactivate tag"
disabled={statusPendingId === row.rfidTagId}
onClick={() => applyStatus(row, "Available")}
>
{statusPendingId === row.rfidTagId ? <Spinner className="size-4" /> : <CheckIcon className="text-emerald-600 dark:text-emerald-400" />}
</Button>
}
/>
<TooltipContent>Reactivate tag</TooltipContent>
</Tooltip>
)}
{row.status === "Available" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Mark lost"
disabled={statusPendingId === row.rfidTagId}
onClick={() => applyStatus(row, "Lost")}
>
{statusPendingId === row.rfidTagId ? <Spinner className="size-4" /> : <TriangleAlertIcon className="text-amber-600 dark:text-amber-400" />}
</Button>
}
/>
<TooltipContent>Mark lost</TooltipContent>
</Tooltip>
)}
{row.status !== "Retired" && row.status !== "InUse" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Retire tag"
disabled={statusPendingId === row.rfidTagId}
onClick={() => applyStatus(row, "Retired")}
>
<ArchiveIcon className="text-muted-foreground" />
</Button>
}
/>
<TooltipContent>Retire tag</TooltipContent>
</Tooltip>
)}
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button
onClick={() => {
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Tag
</Button>
</div>
{tagsFetch.loading ? (
<div className="flex justify-center py-10">
<Spinner className="size-6" />
</div>
) : (
<DataTable
columns={columns}
rows={tags}
rowKey={(row) => row.rfidTagId}
emptyMessage="No RFID tags yet. Add tags to the pool so staff can hand them out at check-in."
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">
<RadioIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.tagCode}</p>
{row.label && <p className="text-xs text-muted-foreground">{row.label}</p>}
</div>
</div>
<StatusBadge status={tagStatusTone(row.status)}>{row.status}</StatusBadge>
</div>
<div className="flex items-center justify-end gap-1.5 border-t pt-2.5">
<Button
variant="outline"
size="icon-sm"
aria-label="Edit tag"
onClick={() => {
setEditTarget(row)
setDialogOpen(true)
}}
>
<PencilIcon />
</Button>
{row.status !== "Available" && row.status !== "InUse" && (
<Button variant="outline" size="icon-sm" aria-label="Reactivate tag" onClick={() => applyStatus(row, "Available")}>
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
</Button>
)}
{row.status === "Available" && (
<Button variant="outline" size="icon-sm" aria-label="Mark lost" onClick={() => applyStatus(row, "Lost")}>
<TriangleAlertIcon className="text-amber-600 dark:text-amber-400" />
</Button>
)}
{row.status !== "Retired" && row.status !== "InUse" && (
<Button variant="outline" size="icon-sm" aria-label="Retire tag" onClick={() => applyStatus(row, "Retired")}>
<ArchiveIcon className="text-muted-foreground" />
</Button>
)}
</div>
</div>
)}
/>
)}
<InfoBanner>
Tags flip to &quot;In Use&quot; automatically when picked at check-in and back to
&quot;Available&quot; when the booking completes — only Lost/Retired/reactivate are
set here directly.
</InfoBanner>
<RfidTagDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editTarget={editTarget}
onSaved={tagsFetch.reload}
/>
</div>
)
}
interface RfidTagDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget: RfidTag | null
onSaved: () => void
}
function RfidTagDialog({ open, onOpenChange, editTarget, onSaved }: RfidTagDialogProps) {
const [tagCode, setTagCode] = React.useState("")
const [label, setLabel] = React.useState("")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setTagCode(editTarget?.tagCode ?? "")
setLabel(editTarget?.label ?? "")
}
}
const canSubmit = !!tagCode.trim() && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
try {
if (editTarget) {
await run(() => updateRfidTag(editTarget.rfidTagId, { tagCode: tagCode.trim(), label: label.trim() || undefined }))
toast.success("Tag updated")
} else {
await run(() => createRfidTag({ tagCode: tagCode.trim(), label: label.trim() || undefined }))
toast.success("Tag added")
}
onOpenChange(false)
onSaved()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{editTarget ? "Edit Tag" : "Add Tag"}</DialogTitle>
<DialogDescription>
{editTarget ? "Update this tag's code or label." : "Register a physical RFID tag into the pool."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="tag-code">
Tag Code <span className="text-destructive">*</span>
</Label>
<Input
id="tag-code"
placeholder="Scan or type the tag's serial"
value={tagCode}
onChange={(e) => setTagCode(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="tag-label">Label</Label>
<Input
id="tag-label"
placeholder="e.g. Red Band #12"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{editTarget ? "Save Changes" : "Add Tag"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { RfidTagsTab }
+6 -6
View File
@@ -3,10 +3,10 @@ import { setToken } from "@/lib/auth/token-service"
import type { LoginRequest, LoginResult } from "@/lib/models/auth"
async function login(request: LoginRequest): Promise<LoginResult> {
const data = await authRequest<unknown>("loginUser", {
const data = await authRequest<unknown>("auth", {
method: "POST",
body: {
functionName: "loginUser",
functionName: "LoginUser",
payload: {
identifier: request.identifier,
password: request.password,
@@ -28,7 +28,7 @@ async function login(request: LoginRequest): Promise<LoginResult> {
/** Step 1 of forgot-password: sends an OTP to the account's email/phone. */
async function forgotPassword(identifier: string): Promise<{ referenceNumber: string }> {
const data = await authRequest<unknown>("forgotPassword", {
const data = await authRequest<unknown>("auth", {
method: "POST",
body: {
functionName: "forgotPassword",
@@ -45,7 +45,7 @@ async function forgotPassword(identifier: string): Promise<{ referenceNumber: st
* the generic recovery RPC endpoint (auth-service dispatches by functionName), same as
* resetPassword/resetPasswordWithToken below. */
async function verifyResetOtp(referenceNumber: string, otpCode: string): Promise<void> {
await authRequest<unknown>("recovery", {
await authRequest<unknown>("auth", {
method: "POST",
body: {
functionName: "VerifyOTP",
@@ -61,7 +61,7 @@ async function resetPassword(
newPassword: string,
confirmPassword: string
): Promise<void> {
await authRequest<unknown>("recovery", {
await authRequest<unknown>("auth", {
method: "POST",
body: {
functionName: "resetPassword",
@@ -77,7 +77,7 @@ async function resetPasswordWithToken(
newPassword: string,
confirmPassword: string
): Promise<void> {
await authRequest<unknown>("recovery", {
await authRequest<unknown>("auth", {
method: "POST",
body: {
functionName: "resetPasswordWithToken",
+55 -3
View File
@@ -1,6 +1,20 @@
import { apiRequest } from "@/lib/api/core-http"
import type { Booking, CreateGuestBookingInput } from "@/lib/models/booking"
import type {
Booking,
CreateAdhocBookingInput,
CreateGuestAdhocBookingInput,
CreateGuestBookingInput,
GetBookingsFilters,
ReassignBookingInput,
RescheduleBookingInput,
} from "@/lib/models/booking"
/** Unified roster across both Scheduled and AdHoc bookings. */
function getBookings(filters: GetBookingsFilters = {}) {
return apiRequest<Booking[]>("client/bookings", { query: filters })
}
/** Kept for the Scheduled-session roster view; prefer getBookings({ facilitySessionId }) elsewhere. */
function getSessionBookings(facilitySessionId: string) {
return apiRequest<Booking[]>(`client/bookings/session/${facilitySessionId}`)
}
@@ -10,8 +24,21 @@ function createGuestBooking(input: CreateGuestBookingInput) {
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
}
function checkInBooking(otpCode: string) {
return apiRequest<Booking>("client/bookings/check-in", { method: "POST", body: { otpCode } })
/** Member self-service AdHoc booking. */
function createAdhocBooking(input: CreateAdhocBookingInput) {
return apiRequest<Booking>("app/bookings/adhoc", { method: "POST", body: input })
}
/** Staff-initiated walk-in AdHoc booking — same public endpoint the guest AdHoc flow uses. */
function createGuestAdhocBooking(input: CreateGuestAdhocBookingInput) {
return apiRequest<Booking>("public/bookings/guest/adhoc", { method: "POST", body: input })
}
function checkInBooking(otpCode: string, rfidTagId?: string) {
return apiRequest<Booking>("client/bookings/check-in", {
method: "POST",
body: { otpCode, rfidTagId },
})
}
function markGuestBookingPaid(id: string, amountCharged: number) {
@@ -25,10 +52,35 @@ function cancelBooking(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/cancel`, { method: "POST" })
}
/** Guest-only — hands a Confirmed booking to a different name/phone before check-in. */
function reassignBooking(id: string, input: ReassignBookingInput) {
return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input })
}
/** Moves a Confirmed booking to a new time (AdHoc) or a new session (Scheduled) — only onto a free slot. */
function rescheduleBooking(id: string, input: RescheduleBookingInput) {
return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input })
}
function markNoShow(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/no-show`, { method: "POST" })
}
function completeBooking(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST" })
}
export {
getBookings,
getSessionBookings,
createGuestBooking,
createAdhocBooking,
createGuestAdhocBooking,
checkInBooking,
markGuestBookingPaid,
cancelBooking,
reassignBooking,
rescheduleBooking,
markNoShow,
completeBooking,
}
+15
View File
@@ -1,6 +1,7 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
Facility,
FacilityAvailability,
FacilityInput,
FacilitySession,
FacilitySessionInput,
@@ -25,6 +26,14 @@ function setFacilityStatus(id: string, isActive: boolean) {
})
}
/** Absorbed from the old PlaceService.AssignDevice — null unassigns the facility's current device. */
function assignFacilityDevice(id: string, deviceId: string | null) {
return apiRequest<Facility>(`client/facilities/${id}/device`, {
method: "PUT",
body: { deviceId },
})
}
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) {
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
}
@@ -37,12 +46,18 @@ function cancelFacilitySession(id: string) {
return apiRequest<unknown>(`client/facilities/sessions/${id}`, { method: "DELETE" })
}
function getFacilityAvailability(params: { facilityId?: string; startTime: string; durationMinutes: number }) {
return apiRequest<FacilityAvailability[]>("client/facilities/availability", { query: params })
}
export {
getFacilities,
createFacility,
updateFacility,
setFacilityStatus,
assignFacilityDevice,
getFacilitySessions,
createFacilitySession,
cancelFacilitySession,
getFacilityAvailability,
}
-30
View File
@@ -1,30 +0,0 @@
import { apiRequest } from "@/lib/api/core-http"
import type { Place, PlaceInput } from "@/lib/models/place"
function getPlaces(includeInactive = true) {
return apiRequest<Place[]>("client/places", { query: { includeInactive } })
}
function createPlace(input: PlaceInput) {
return apiRequest<Place>("client/places", { method: "POST", body: input })
}
function updatePlace(id: string, input: Partial<PlaceInput>) {
return apiRequest<Place>(`client/places/${id}`, { method: "PUT", body: input })
}
function setPlaceStatus(id: string, isActive: boolean) {
return apiRequest<Place>(`client/places/${id}/status`, {
method: "PATCH",
body: { isActive },
})
}
function assignPlaceDevice(id: string, deviceId: string | null) {
return apiRequest<Place>(`client/places/${id}/device`, {
method: "PUT",
body: { deviceId },
})
}
export { getPlaces, createPlace, updatePlace, setPlaceStatus, assignPlaceDevice }
+21
View File
@@ -0,0 +1,21 @@
import { apiRequest } from "@/lib/api/core-http"
import type { RfidTag, RfidTagInput, RfidTagStatus } from "@/lib/models/rfid-tag"
function getRfidTags(status?: RfidTagStatus) {
return apiRequest<RfidTag[]>("client/rfid-tags", { query: status ? { status } : {} })
}
function createRfidTag(input: RfidTagInput) {
return apiRequest<RfidTag>("client/rfid-tags", { method: "POST", body: input })
}
function updateRfidTag(id: string, input: Partial<RfidTagInput>) {
return apiRequest<RfidTag>(`client/rfid-tags/${id}`, { method: "PUT", body: input })
}
/** Available/Lost/Retired only — InUse is only ever reached via a booking check-in. */
function setRfidTagStatus(id: string, status: RfidTagStatus) {
return apiRequest<RfidTag>(`client/rfid-tags/${id}/status`, { method: "PATCH", body: { status } })
}
export { getRfidTags, createRfidTag, updateRfidTag, setRfidTagStatus }
+55 -5
View File
@@ -1,10 +1,18 @@
/** Mirrors BookingDto. */
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
* (facilitySessionId null, exclusive-unit) bookings. */
export interface Booking {
bookingId: string
facilitySessionId: string
/** Null for an AdHoc booking — no pre-created session. */
facilitySessionId?: string | null
facilityId: string
facilityName?: string | null
sessionStartTime?: string | null
sessionEndTime?: string | null
bookingMode: "Scheduled" | "AdHoc"
startTime: string
endTime: string
/** Informational headcount, AdHoc only. */
partySize?: number | null
isGuest: boolean
customerId?: string | null
@@ -15,18 +23,60 @@ export interface Booking {
otpCode?: string | null
otpVerified: boolean
rfidTagId?: string | null
rfidTagLabel?: string | null
rfidAssignedAt?: string | null
rfidReturnedAt?: string | null
paymentStatus: string
amountCharged: number
status: string
status: BookingStatus
bookedAt: string
checkedInAt?: string | null
checkedOutAt?: string | null
/** Public, unauthenticated link to this booking's confirmation page. */
shareUrl?: string | null
}
export type GetBookingsFilters = {
facilityId?: string
facilitySessionId?: string
fromDate?: string
toDate?: string
status?: string
}
export interface CreateGuestBookingInput {
facilitySessionId: string
guestName: string
guestPhone: string
}
export interface CreateAdhocBookingInput {
facilityId: string
startTime: string
durationMinutes: number
partySize?: number
}
export interface CreateGuestAdhocBookingInput {
facilityId: string
startTime: string
durationMinutes: number
guestName: string
guestPhone: string
partySize?: number
}
export interface ReassignBookingInput {
guestName?: string
guestPhone?: string
}
/** Exactly one shape applies, matching the booking's current bookingMode. */
export interface RescheduleBookingInput {
startTime?: string
durationMinutes?: number
facilitySessionId?: string
}
+24 -4
View File
@@ -1,21 +1,31 @@
export type FacilityType = "Gym" | "Pool" | "Court" | "Class"
export type BookingMode = "Scheduled" | "AdHoc"
/** Mirrors FacilityDto. */
/** Mirrors FacilityDto. A bookable area (gym floor, pool, court, class) that's also the
* access-control zone for its optional fingerprint/face terminal — merged from the former
* separate Facility/Place concepts. */
export interface Facility {
facilityId: string
name: string
facilityType: FacilityType
bookingMode: BookingMode
description?: string | null
/** Meaningful when bookingMode === "Scheduled" — shared capacity per session. */
defaultCapacity: number
/** Meaningful when bookingMode === "AdHoc" — e.g. 3 for "3 courts", each independently bookable. */
numberOfUnits: number
pricePerHour: number
isActive: boolean
createdAt: string
deviceId?: string | null
deviceName?: string | null
}
export interface FacilityInput {
name: string
facilityType: FacilityType
bookingMode: BookingMode
description?: string
defaultCapacity: number
numberOfUnits: number
pricePerHour: number
}
/** Mirrors FacilitySessionDto. */
@@ -37,3 +47,13 @@ export interface FacilitySessionInput {
/** Overrides the facility's default capacity for this specific session. */
capacity?: number
}
/** Mirrors FacilityAvailabilityDto. */
export interface FacilityAvailability {
facilityId: string
facilityName: string
bookingMode: BookingMode
totalUnits: number
unitsFree: number
pricePerHour: number
}
-16
View File
@@ -1,16 +0,0 @@
/** Mirrors PlaceDto. A physical access zone (e.g. "Pool Area", "Indoor Gym") with one
* fingerprint/face terminal for now. */
export interface Place {
placeId: string
name: string
description?: string | null
isActive: boolean
createdAt: string
deviceId?: string | null
deviceName?: string | null
}
export interface PlaceInput {
name: string
description?: string
}
+17
View File
@@ -0,0 +1,17 @@
export type RfidTagStatus = "Available" | "InUse" | "Lost" | "Retired"
/** Mirrors RfidTagDto. A managed inventory tag handed to a booked party at check-in
* (Booking.rfidTagId) — staff pick from this list, never free-type a tag. */
export interface RfidTag {
rfidTagId: string
tagCode: string
label?: string | null
status: RfidTagStatus
createdAt: string
updatedAt: string
}
export interface RfidTagInput {
tagCode: string
label?: string
}