Merge pull request 'Feat/booking new 0928' (#1) from feat/booking-new-0928 into dev
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
Generated
+65
@@ -9,6 +9,11 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fullcalendar/core": "^6.1.21",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
"@fullcalendar/interaction": "^6.1.21",
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@microsoft/signalr": "^8.0.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -985,6 +990,56 @@
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
|
||||
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
|
||||
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/interaction": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
|
||||
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/react": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz",
|
||||
"integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.21",
|
||||
"react": "^16.7.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timegrid": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz",
|
||||
"integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
@@ -8682,6 +8737,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fullcalendar/core": "^6.1.21",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
"@fullcalendar/interaction": "^6.1.21",
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@microsoft/signalr": "^8.0.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -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,318 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CopyIcon, SearchIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getFacilityAvailability } from "@/lib/api/facilities"
|
||||
import { createGuestAdhocBooking } from "@/lib/api/bookings"
|
||||
import { getMembers } from "@/lib/api/customers"
|
||||
import { ApiError } from "@/lib/api/core-http"
|
||||
import type { FacilityAvailability } from "@/lib/models/facility"
|
||||
import type { Facility } from "@/lib/models/facility"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { useMutation } from "@/lib/use-mutation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||
|
||||
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
|
||||
function toLocalInputValue(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
interface AdhocBookingTabProps {
|
||||
facilities: Facility[]
|
||||
}
|
||||
|
||||
function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
||||
const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive)
|
||||
|
||||
const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "")
|
||||
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
|
||||
const [hours, setHours] = React.useState("1")
|
||||
const [minutes, setMinutes] = React.useState("0")
|
||||
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
|
||||
const [partySize, setPartySize] = React.useState("")
|
||||
const [customerId, setCustomerId] = React.useState("")
|
||||
const [guestName, setGuestName] = React.useState("")
|
||||
const [guestPhone, setGuestPhone] = React.useState("")
|
||||
|
||||
const membersFetch = useFetch(() => getMembers())
|
||||
const members = (membersFetch.data ?? []).filter((m) => !!m.customer)
|
||||
const memberOptions = [
|
||||
{ label: "New guest (not registered)", value: "none" },
|
||||
...members.map((m) => ({ label: m.customer!.cusName ?? m.customer!.primaryPH ?? "Member", value: m.fkCustomerId })),
|
||||
]
|
||||
|
||||
function selectMember(id: string) {
|
||||
setCustomerId(id === "none" ? "" : id)
|
||||
const member = id !== "none" ? members.find((m) => m.fkCustomerId === id) : undefined
|
||||
if (member?.customer) {
|
||||
setGuestName(member.customer.cusName ?? "")
|
||||
setGuestPhone(member.customer.primaryPH ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
const [availability, setAvailability] = React.useState<FacilityAvailability | null>(null)
|
||||
const [availabilityError, setAvailabilityError] = React.useState<string | null>(null)
|
||||
const { busy: checking, run: runCheck } = useMutation()
|
||||
const { busy: booking, run: runBook } = useMutation()
|
||||
|
||||
const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
|
||||
const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60
|
||||
|
||||
const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
|
||||
const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking
|
||||
|
||||
function selectFacility(id: string) {
|
||||
setFacilityId(id)
|
||||
setAvailability(null)
|
||||
const facility = adhocFacilities.find((f) => f.facilityId === id)
|
||||
setPricePerHour(String(facility?.pricePerHour ?? 0))
|
||||
}
|
||||
|
||||
async function handleCheckAvailability() {
|
||||
if (!canCheck) return
|
||||
setAvailabilityError(null)
|
||||
try {
|
||||
const results = await runCheck(() =>
|
||||
getFacilityAvailability({
|
||||
facilityId,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
durationMinutes,
|
||||
})
|
||||
)
|
||||
setAvailability(results[0] ?? null)
|
||||
} catch (err) {
|
||||
setAvailability(null)
|
||||
setAvailabilityError(err instanceof ApiError ? err.message : "Couldn't check availability.")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
try {
|
||||
const bookingResult = await runBook(() =>
|
||||
createGuestAdhocBooking({
|
||||
facilityId,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
durationMinutes,
|
||||
guestName: guestName.trim(),
|
||||
guestPhone: guestPhone.trim(),
|
||||
partySize: partySize ? Number(partySize) : undefined,
|
||||
pricePerHour: Number(pricePerHour),
|
||||
customerId: customerId || undefined,
|
||||
})
|
||||
)
|
||||
if (bookingResult.shareUrl) {
|
||||
navigator.clipboard.writeText(bookingResult.shareUrl)
|
||||
toast.success("Booking created — share link copied", {
|
||||
description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`,
|
||||
})
|
||||
} else {
|
||||
toast.success("Booking created", {
|
||||
description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`,
|
||||
})
|
||||
}
|
||||
setGuestName("")
|
||||
setGuestPhone("")
|
||||
setPartySize("")
|
||||
setCustomerId("")
|
||||
setAvailability(null)
|
||||
handleCheckAvailability()
|
||||
} catch {
|
||||
// run() already toasted the real error; keep the form as-is for retry.
|
||||
}
|
||||
}
|
||||
|
||||
if (adhocFacilities.length === 0) {
|
||||
return (
|
||||
<InfoBanner>
|
||||
No ad-hoc facilities yet. Add a facility with Booking Mode set to "Ad-hoc" under
|
||||
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
|
||||
</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) => selectFacility(v ?? "")}>
|
||||
<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 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>
|
||||
Duration <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
aria-label="Hours"
|
||||
value={hours}
|
||||
onChange={(e) => {
|
||||
setHours(e.target.value)
|
||||
setAvailability(null)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
aria-label="Minutes"
|
||||
value={minutes}
|
||||
onChange={(e) => {
|
||||
setMinutes(e.target.value)
|
||||
setAvailability(null)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="adhoc-price-per-hour">
|
||||
Price / Hour (Rs.) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="adhoc-price-per-hour"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={pricePerHour}
|
||||
onChange={(e) => setPricePerHour(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Price for Booking</Label>
|
||||
<div className="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm font-medium">
|
||||
Rs. {bookingPrice.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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="flex flex-col gap-1.5 border-t pt-3">
|
||||
<Label>Select existing member (optional)</Label>
|
||||
<Select items={memberOptions} value={customerId || "none"} onValueChange={(v) => selectMember(v ?? "none")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={membersFetch.loading ? "Loading…" : "New guest (not registered)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="adhoc-guest-name">
|
||||
Guest Name <span className="text-destructive">*</span>
|
||||
</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 }
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import FullCalendar from "@fullcalendar/react"
|
||||
import dayGridPlugin from "@fullcalendar/daygrid"
|
||||
import timeGridPlugin from "@fullcalendar/timegrid"
|
||||
import interactionPlugin from "@fullcalendar/interaction"
|
||||
import type { DatesSetArg, EventInput } from "@fullcalendar/core"
|
||||
|
||||
import { getBookings } from "@/lib/api/bookings"
|
||||
import { ApiError } from "@/lib/api/core-http"
|
||||
import type { Booking, BookingStatus } from "@/lib/models/booking"
|
||||
|
||||
const STATUS_COLORS: Record<BookingStatus, string> = {
|
||||
Confirmed: "#2563eb",
|
||||
CheckedIn: "#16a34a",
|
||||
Completed: "#6b7280",
|
||||
NoShow: "#dc2626",
|
||||
Cancelled: "#dc2626",
|
||||
}
|
||||
|
||||
function bookingToEvent(booking: Booking): EventInput {
|
||||
const color = STATUS_COLORS[booking.status] ?? "#2563eb"
|
||||
return {
|
||||
id: booking.bookingId,
|
||||
title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`,
|
||||
start: booking.startTime,
|
||||
end: booking.endTime,
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only calendar view of bookings — reuses the existing roster's getBookings query,
|
||||
* refetched for whatever range FullCalendar currently has visible. */
|
||||
function BookingCalendar() {
|
||||
const [events, setEvents] = React.useState<EventInput[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
async function handleDatesSet(arg: DatesSetArg) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const bookings = await getBookings({
|
||||
fromDate: arg.start.toISOString(),
|
||||
toDate: arg.end.toISOString(),
|
||||
})
|
||||
setEvents(bookings.map(bookingToEvent))
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't load bookings for this range.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{loading && <span>Loading…</span>}
|
||||
{error && <span className="text-destructive">{error}</span>}
|
||||
{(Object.keys(STATUS_COLORS) as BookingStatus[]).map((status) => (
|
||||
<span key={status} className="flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-full" style={{ backgroundColor: STATUS_COLORS[status] }} />
|
||||
{status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
|
||||
height="auto"
|
||||
nowIndicator
|
||||
events={events}
|
||||
datesSet={handleDatesSet}
|
||||
eventDisplay="block"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BookingCalendar }
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {
|
||||
CalendarClockIcon,
|
||||
CheckCheckIcon,
|
||||
ClipboardListIcon,
|
||||
DollarSignIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { getBookingDashboardSummary } from "@/lib/api/bookings"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { StatCard } from "@/components/hexa/stat-card"
|
||||
|
||||
// FullCalendar touches the DOM at import time -- must not run during server-side rendering.
|
||||
const BookingCalendar = dynamic(
|
||||
() => import("@/components/hexa/views/booking-calendar").then((m) => m.BookingCalendar),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[500px] items-center justify-center rounded-lg border">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
function BookingDashboardTab() {
|
||||
const summaryFetch = useFetch(() => getBookingDashboardSummary())
|
||||
const summary = summaryFetch.data
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
icon={ClipboardListIcon}
|
||||
tone="blue"
|
||||
label="Bookings Today"
|
||||
value={summaryFetch.loading ? "…" : (summary?.bookingsToday ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSignIcon}
|
||||
tone="green"
|
||||
label="Revenue Today"
|
||||
value={summaryFetch.loading ? "…" : `Rs. ${(summary?.revenueToday ?? 0).toLocaleString()}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCheckIcon}
|
||||
tone="purple"
|
||||
label="Checked In Now"
|
||||
value={summaryFetch.loading ? "…" : (summary?.checkedInNow ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={CalendarClockIcon}
|
||||
tone="amber"
|
||||
label="Upcoming"
|
||||
value={summaryFetch.loading ? "…" : (summary?.upcomingConfirmed ?? 0)}
|
||||
hint="Confirmed, not yet checked in"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TriangleAlertIcon}
|
||||
tone="amber"
|
||||
label="Outstanding"
|
||||
value={summaryFetch.loading ? "…" : `Rs. ${(summary?.outstandingUnpaid ?? 0).toLocaleString()}`}
|
||||
hint="Unpaid guest bookings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BookingCalendar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BookingDashboardTab }
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CopyIcon, ExternalLinkIcon, ReceiptIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getBookingInvoices } from "@/lib/api/booking-invoices"
|
||||
import type { BookingInvoice } from "@/lib/models/booking-invoice"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function BookingInvoicesTab() {
|
||||
const invoicesFetch = useFetch(() => getBookingInvoices())
|
||||
const invoices = invoicesFetch.data ?? []
|
||||
|
||||
function copyReceiptLink(invoice: BookingInvoice) {
|
||||
if (!invoice.shareUrl) return
|
||||
navigator.clipboard.writeText(invoice.shareUrl)
|
||||
toast.success("Receipt link copied")
|
||||
}
|
||||
|
||||
function openReceipt(invoice: BookingInvoice) {
|
||||
if (!invoice.shareUrl) return
|
||||
window.open(invoice.shareUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<BookingInvoice>[] = [
|
||||
{
|
||||
key: "facility",
|
||||
header: "Facility & Session",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ReceiptIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.facilityName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "guest",
|
||||
header: "Guest",
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.guestName}</p>
|
||||
{row.guestPhone && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "Amount",
|
||||
cell: (row) => <span className="font-medium">Rs. {row.amount.toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
key: "method",
|
||||
header: "Payment Method",
|
||||
cell: (row) => <Badge variant="outline">{row.paymentMethod}</Badge>,
|
||||
},
|
||||
{ key: "issued", header: "Issued", cell: (row) => formatDateTime(row.issuedAt) },
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "text-center",
|
||||
cell: (row) => (
|
||||
<div className="flex justify-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Open receipt"
|
||||
disabled={!row.shareUrl}
|
||||
onClick={() => openReceipt(row)}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Open receipt</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Copy receipt link"
|
||||
disabled={!row.shareUrl}
|
||||
onClick={() => copyReceiptLink(row)}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Copy receipt link</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{invoicesFetch.loading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(row) => row.bookingInvoiceId}
|
||||
emptyMessage="No booking payments recorded yet."
|
||||
renderMobileCard={(row) => (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ReceiptIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.facilityName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.bookingStartTime)} - {formatDateTime(row.bookingEndTime)}
|
||||
</p>
|
||||
<p className="text-sm">{row.guestName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{row.paymentMethod}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2.5">
|
||||
<span className="font-medium">Rs. {row.amount.toLocaleString()}</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Open receipt"
|
||||
disabled={!row.shareUrl}
|
||||
onClick={() => openReceipt(row)}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Copy receipt link"
|
||||
disabled={!row.shareUrl}
|
||||
onClick={() => copyReceiptLink(row)}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InfoBanner>
|
||||
A receipt is generated automatically whenever a booking payment is recorded (check-out or
|
||||
Mark as Paid) — separate from membership invoices, which stay on the Customer Payments page.
|
||||
</InfoBanner>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BookingInvoicesTab }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,18 @@ 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 { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab"
|
||||
import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab"
|
||||
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
||||
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
||||
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
||||
|
||||
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 +82,38 @@ function BookingsView() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Bookings"
|
||||
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
|
||||
description="Dashboard, scheduled sessions, ad-hoc reservations, the booking roster, invoices, RFID tags and time-bank cards"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="facilities" className="gap-4">
|
||||
<Tabs defaultValue="dashboard" className="gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
||||
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
||||
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
||||
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
||||
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="facilities">
|
||||
<FacilitiesTab
|
||||
facilities={facilities}
|
||||
onReload={facilitiesFetch.reload}
|
||||
/>
|
||||
<TabsContent value="dashboard">
|
||||
<BookingDashboardTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sessions">
|
||||
<SessionsTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="adhoc">
|
||||
<AdhocBookingTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="roster">
|
||||
<BookingRosterTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="invoices">
|
||||
<BookingInvoicesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="rfid-tags">
|
||||
<RfidTagsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="time-bank-cards">
|
||||
<TimeBankCardsTab facilities={facilities} />
|
||||
</TabsContent>
|
||||
@@ -129,345 +122,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 +129,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 +143,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 +223,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 +272,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
|
||||
<ScheduleSessionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
facilities={facilities}
|
||||
facilities={scheduledFacilities}
|
||||
onScheduled={sessionsFetch.reload}
|
||||
/>
|
||||
|
||||
@@ -698,7 +354,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'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'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 }
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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. "Pool Area", "Indoor Gym").
|
||||
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'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 }
|
||||
@@ -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 "In Use" automatically when picked at check-in and back to
|
||||
"Available" 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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { BookingInvoice, GetBookingInvoicesFilters } from "@/lib/models/booking-invoice"
|
||||
|
||||
/** Booking payment receipts — kept separate from the membership-based Customer Payments view. */
|
||||
function getBookingInvoices(filters: GetBookingInvoicesFilters = {}) {
|
||||
return apiRequest<BookingInvoice[]>("client/bookings/invoices", { query: filters })
|
||||
}
|
||||
|
||||
export { getBookingInvoices }
|
||||
+65
-5
@@ -1,6 +1,23 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type { Booking, CreateGuestBookingInput } from "@/lib/models/booking"
|
||||
import type { BookingDashboardSummary } from "@/lib/models/booking-dashboard"
|
||||
import type {
|
||||
Booking,
|
||||
CompleteBookingInput,
|
||||
CreateAdhocBookingInput,
|
||||
CreateGuestAdhocBookingInput,
|
||||
CreateGuestBookingInput,
|
||||
GetBookingsFilters,
|
||||
MarkGuestBookingPaidInput,
|
||||
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,14 +27,27 @@ 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 })
|
||||
}
|
||||
|
||||
function markGuestBookingPaid(id: string, amountCharged: number) {
|
||||
/** 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, input: MarkGuestBookingPaidInput) {
|
||||
return apiRequest<Booking>(`client/bookings/${id}/mark-paid`, {
|
||||
method: "POST",
|
||||
body: { bookingId: id, amountCharged },
|
||||
body: { bookingId: id, ...input },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,10 +55,40 @@ 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, input: CompleteBookingInput = {}) {
|
||||
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function getBookingDashboardSummary() {
|
||||
return apiRequest<BookingDashboardSummary>("client/bookings/dashboard-summary")
|
||||
}
|
||||
|
||||
export {
|
||||
getBookings,
|
||||
getSessionBookings,
|
||||
createGuestBooking,
|
||||
createAdhocBooking,
|
||||
createGuestAdhocBooking,
|
||||
checkInBooking,
|
||||
markGuestBookingPaid,
|
||||
cancelBooking,
|
||||
reassignBooking,
|
||||
rescheduleBooking,
|
||||
markNoShow,
|
||||
completeBooking,
|
||||
getBookingDashboardSummary,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Mirrors BookingDashboardSummaryDto. */
|
||||
export interface BookingDashboardSummary {
|
||||
bookingsToday: number
|
||||
revenueToday: number
|
||||
checkedInNow: number
|
||||
upcomingConfirmed: number
|
||||
outstandingUnpaid: number
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PaymentMethod } from "@/lib/models/booking"
|
||||
|
||||
/** Mirrors BookingInvoiceDto. A booking payment receipt — deliberately separate from
|
||||
* membership-based Customer Payments/invoices. */
|
||||
export interface BookingInvoice {
|
||||
bookingInvoiceId: string
|
||||
bookingId: string
|
||||
amount: number
|
||||
paymentMethod: PaymentMethod
|
||||
guestName: string
|
||||
guestPhone?: string | null
|
||||
facilityName: string
|
||||
bookingStartTime: string
|
||||
bookingEndTime: string
|
||||
issuedAt: string
|
||||
/** Public, unauthenticated link to this receipt's printable page. */
|
||||
shareUrl?: string | null
|
||||
}
|
||||
|
||||
export type GetBookingInvoicesFilters = {
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
/** Mirrors BookingDto. */
|
||||
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
|
||||
export type PaymentMethod = "Cash" | "Bank"
|
||||
|
||||
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
|
||||
* (facilitySessionId null, exclusive-unit) bookings. */
|
||||
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 +24,82 @@ 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
|
||||
/** Set once paymentStatus becomes "Paid". */
|
||||
paymentMethod?: PaymentMethod | null
|
||||
paidAt?: string | null
|
||||
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
|
||||
/** Optional link to an existing registered customer — identification/auto-fill only. */
|
||||
customerId?: 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
|
||||
/** Overrides the facility's own price per hour for this booking's charge only. */
|
||||
pricePerHour?: number
|
||||
/** Optional link to an existing registered customer — identification/auto-fill only. */
|
||||
customerId?: string
|
||||
}
|
||||
|
||||
export interface ReassignBookingInput {
|
||||
guestName?: string
|
||||
guestPhone?: string
|
||||
}
|
||||
|
||||
/** Exactly one shape applies, matching the booking's current bookingMode. */
|
||||
export interface RescheduleBookingInput {
|
||||
startTime?: string
|
||||
durationMinutes?: number
|
||||
facilitySessionId?: string
|
||||
}
|
||||
|
||||
/** Only required when the target booking is a guest booking still Unpaid — the backend
|
||||
* rejects checkout otherwise. Ignored for a member booking or a guest booking already Paid. */
|
||||
export interface CompleteBookingInput {
|
||||
markPaid?: boolean
|
||||
amountCollected?: number
|
||||
paymentMethod?: PaymentMethod
|
||||
}
|
||||
|
||||
export interface MarkGuestBookingPaidInput {
|
||||
amountCharged: number
|
||||
paymentMethod?: PaymentMethod
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user