refactor: replace PlacesView with FacilitiesView in MembershipsView and remove PlacesView component
feat: add RfidTagsTab component for managing RFID tags refactor: update auth API endpoints to use unified auth endpoint feat: enhance bookings API with AdHoc booking functionality and new booking management features refactor: update facilities API to include device assignment and availability checks delete: remove obsolete places API and model definitions feat: introduce RFID tag management API and model definitions
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { useMutation } from "@/lib/use-mutation"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +48,7 @@ interface GymPlanFormValues {
|
|||||||
onboardingFeeAmount: string
|
onboardingFeeAmount: string
|
||||||
allowSplit: boolean
|
allowSplit: boolean
|
||||||
installmentCount: string
|
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[]
|
placeIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +89,8 @@ interface AddGymPlanDialogProps {
|
|||||||
onSubmit: (values: GymPlanFormValues) => Promise<void> | void
|
onSubmit: (values: GymPlanFormValues) => Promise<void> | void
|
||||||
mode?: "create" | "edit"
|
mode?: "create" | "edit"
|
||||||
initialValues?: Partial<GymPlanFormValues>
|
initialValues?: Partial<GymPlanFormValues>
|
||||||
/** Active places to choose from. Empty selection means "unrestricted". */
|
/** Active facilities to choose from. Empty selection means "unrestricted". */
|
||||||
places?: Place[]
|
facilities?: Facility[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddGymPlanDialog({
|
function AddGymPlanDialog({
|
||||||
@@ -99,7 +99,7 @@ function AddGymPlanDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
mode = "create",
|
mode = "create",
|
||||||
initialValues,
|
initialValues,
|
||||||
places = [],
|
facilities = [],
|
||||||
}: AddGymPlanDialogProps) {
|
}: AddGymPlanDialogProps) {
|
||||||
const [values, setValues] = React.useState<GymPlanFormValues>(EMPTY_VALUES)
|
const [values, setValues] = React.useState<GymPlanFormValues>(EMPTY_VALUES)
|
||||||
const [customDuration, setCustomDuration] = React.useState(false)
|
const [customDuration, setCustomDuration] = React.useState(false)
|
||||||
@@ -309,24 +309,24 @@ function AddGymPlanDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Places */}
|
{/* Facilities */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Places</Label>
|
<Label>Facilities</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Which access zones this plan reaches. Leave all unchecked for unrestricted
|
Which access zones this plan reaches. Leave all unchecked for unrestricted
|
||||||
access (every terminal).
|
access (every terminal).
|
||||||
</p>
|
</p>
|
||||||
{places.length === 0 ? (
|
{facilities.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||||
{places.map((place) => {
|
{facilities.map((facility) => {
|
||||||
const checked = values.placeIds.includes(place.placeId)
|
const checked = values.placeIds.includes(facility.facilityId)
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={place.placeId}
|
key={facility.facilityId}
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -335,12 +335,12 @@ function AddGymPlanDialog({
|
|||||||
set(
|
set(
|
||||||
"placeIds",
|
"placeIds",
|
||||||
isChecked
|
isChecked
|
||||||
? [...values.placeIds, place.placeId]
|
? [...values.placeIds, facility.facilityId]
|
||||||
: values.placeIds.filter((id) => id !== place.placeId)
|
: values.placeIds.filter((id) => id !== facility.facilityId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{place.name}
|
{facility.name}
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CopyIcon, SearchIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { getFacilityAvailability } from "@/lib/api/facilities"
|
||||||
|
import { createGuestAdhocBooking } from "@/lib/api/bookings"
|
||||||
|
import { ApiError } from "@/lib/api/core-http"
|
||||||
|
import type { FacilityAvailability } from "@/lib/models/facility"
|
||||||
|
import type { Facility } from "@/lib/models/facility"
|
||||||
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
|
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
|
||||||
|
function toLocalInputValue(date: Date) {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdhocBookingTabProps {
|
||||||
|
facilities: Facility[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
||||||
|
const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive)
|
||||||
|
|
||||||
|
const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "")
|
||||||
|
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
|
||||||
|
const [duration, setDuration] = React.useState("60")
|
||||||
|
const [partySize, setPartySize] = React.useState("")
|
||||||
|
const [guestName, setGuestName] = React.useState("")
|
||||||
|
const [guestPhone, setGuestPhone] = React.useState("")
|
||||||
|
|
||||||
|
const [availability, setAvailability] = React.useState<FacilityAvailability | null>(null)
|
||||||
|
const [availabilityError, setAvailabilityError] = React.useState<string | null>(null)
|
||||||
|
const { busy: checking, run: runCheck } = useMutation()
|
||||||
|
const { busy: booking, run: runBook } = useMutation()
|
||||||
|
|
||||||
|
const selectedFacility = adhocFacilities.find((f) => f.facilityId === facilityId)
|
||||||
|
const durationMinutes = Number(duration) || 0
|
||||||
|
const estimatedPrice = selectedFacility ? (selectedFacility.pricePerHour * durationMinutes) / 60 : 0
|
||||||
|
|
||||||
|
const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||||
|
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
|
||||||
|
const canSubmit = canCheck && !!guestName.trim() && !!guestPhone.trim() && !booking
|
||||||
|
|
||||||
|
async function handleCheckAvailability() {
|
||||||
|
if (!canCheck) return
|
||||||
|
setAvailabilityError(null)
|
||||||
|
try {
|
||||||
|
const results = await runCheck(() =>
|
||||||
|
getFacilityAvailability({
|
||||||
|
facilityId,
|
||||||
|
startTime: new Date(startTime).toISOString(),
|
||||||
|
durationMinutes,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setAvailability(results[0] ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
setAvailability(null)
|
||||||
|
setAvailabilityError(err instanceof ApiError ? err.message : "Couldn't check availability.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
try {
|
||||||
|
const bookingResult = await runBook(() =>
|
||||||
|
createGuestAdhocBooking({
|
||||||
|
facilityId,
|
||||||
|
startTime: new Date(startTime).toISOString(),
|
||||||
|
durationMinutes,
|
||||||
|
guestName: guestName.trim(),
|
||||||
|
guestPhone: guestPhone.trim(),
|
||||||
|
partySize: partySize ? Number(partySize) : undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (bookingResult.shareUrl) {
|
||||||
|
navigator.clipboard.writeText(bookingResult.shareUrl)
|
||||||
|
toast.success("Booking created — share link copied", {
|
||||||
|
description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.success("Booking created", {
|
||||||
|
description: `OTP ${bookingResult.otpCode} for ${bookingResult.guestName}.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setGuestName("")
|
||||||
|
setGuestPhone("")
|
||||||
|
setPartySize("")
|
||||||
|
setAvailability(null)
|
||||||
|
handleCheckAvailability()
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the form as-is for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adhocFacilities.length === 0) {
|
||||||
|
return (
|
||||||
|
<InfoBanner>
|
||||||
|
No ad-hoc facilities yet. Add a facility with Booking Mode set to "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) => {
|
||||||
|
setFacilityId(v ?? "")
|
||||||
|
setAvailability(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select facility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{facilityOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="adhoc-start">
|
||||||
|
Start <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="adhoc-start"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartTime(e.target.value)
|
||||||
|
setAvailability(null)
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="adhoc-duration">
|
||||||
|
Duration (minutes) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="adhoc-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDuration(e.target.value)
|
||||||
|
setAvailability(null)
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="adhoc-party-size">Party Size</Label>
|
||||||
|
<Input
|
||||||
|
id="adhoc-party-size"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Optional headcount"
|
||||||
|
value={partySize}
|
||||||
|
onChange={(e) => setPartySize(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Estimated Price</Label>
|
||||||
|
<div className="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm font-medium">
|
||||||
|
Rs. {estimatedPrice.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCheckAvailability} disabled={!canCheck}>
|
||||||
|
{checking ? <Spinner className="size-4" /> : <SearchIcon />}
|
||||||
|
Check Availability
|
||||||
|
</Button>
|
||||||
|
{availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>}
|
||||||
|
{availability && (
|
||||||
|
<StatusBadge status={availability.unitsFree > 0 ? "success" : "danger"}>
|
||||||
|
{availability.unitsFree} / {availability.totalUnits} units free
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-t pt-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="adhoc-guest-name">
|
||||||
|
Guest Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="adhoc-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="adhoc-guest-phone">
|
||||||
|
Mobile Number <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="adhoc-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{booking ? <Spinner className="size-4" /> : <CopyIcon />}
|
||||||
|
Book Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<InfoBanner>
|
||||||
|
Booking is recorded as pending check-in. Give the party an RFID tag and check them in from
|
||||||
|
the Roster tab when they arrive — the slot stays reserved until then.
|
||||||
|
</InfoBanner>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AdhocBookingTab }
|
||||||
@@ -1,18 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CheckIcon, CopyIcon, PlusIcon, Trash2Icon, WalletIcon } from "lucide-react"
|
import {
|
||||||
|
ArrowRightLeftIcon,
|
||||||
|
CalendarClockIcon,
|
||||||
|
CheckCheckIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UserXIcon,
|
||||||
|
WalletIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
checkInBooking,
|
checkInBooking,
|
||||||
|
completeBooking,
|
||||||
createGuestBooking,
|
createGuestBooking,
|
||||||
getSessionBookings,
|
getBookings,
|
||||||
markGuestBookingPaid,
|
markGuestBookingPaid,
|
||||||
|
markNoShow,
|
||||||
|
reassignBooking,
|
||||||
|
rescheduleBooking,
|
||||||
} from "@/lib/api/bookings"
|
} from "@/lib/api/bookings"
|
||||||
import { getFacilitySessions } from "@/lib/api/facilities"
|
import { getFacilitySessions } from "@/lib/api/facilities"
|
||||||
import type { Booking } from "@/lib/models/booking"
|
import { getRfidTags } from "@/lib/api/rfid-tags"
|
||||||
|
import type { Booking, BookingStatus } from "@/lib/models/booking"
|
||||||
import type { Facility, FacilitySession } from "@/lib/models/facility"
|
import type { Facility, FacilitySession } from "@/lib/models/facility"
|
||||||
import { useFetch } from "@/lib/use-fetch"
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
import { useMutation, runMutation } from "@/lib/use-mutation"
|
import { useMutation, runMutation } from "@/lib/use-mutation"
|
||||||
@@ -47,9 +62,17 @@ import {
|
|||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { label: string; value: string }[] = [
|
||||||
|
{ label: "All statuses", value: "all" },
|
||||||
|
{ label: "Confirmed", value: "Confirmed" },
|
||||||
|
{ label: "Checked In", value: "CheckedIn" },
|
||||||
|
{ label: "Cancelled", value: "Cancelled" },
|
||||||
|
{ label: "No Show", value: "NoShow" },
|
||||||
|
{ label: "Completed", value: "Completed" },
|
||||||
|
]
|
||||||
|
|
||||||
function formatDateTime(iso: string) {
|
function formatDateTime(iso: string) {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -59,9 +82,15 @@ function formatDateTime(iso: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookingStatusTone(status: string) {
|
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
|
||||||
if (status === "Cancelled") return "danger" as const
|
function toLocalInputValue(date: Date) {
|
||||||
if (status === "CheckedIn") return "success" as const
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookingStatusTone(status: BookingStatus | string) {
|
||||||
|
if (status === "Cancelled" || status === "NoShow") return "danger" as const
|
||||||
|
if (status === "CheckedIn" || status === "Completed") return "success" as const
|
||||||
return "info" as const
|
return "info" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,37 +105,53 @@ interface BookingRosterTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
||||||
const sessionsFetch = useFetch(() => getFacilitySessions({}))
|
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled" && f.isActive)
|
||||||
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled)
|
|
||||||
|
|
||||||
const [sessionId, setSessionId] = React.useState("")
|
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
||||||
React.useEffect(() => {
|
const [statusFilter, setStatusFilter] = React.useState("all")
|
||||||
if (!sessionId && sessions.length > 0) setSessionId(sessions[0].facilitySessionId)
|
const bookingsFetch = useFetch(() =>
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only auto-select once sessions load
|
getBookings({
|
||||||
}, [sessions.length])
|
facilityId: facilityFilter === "all" ? undefined : facilityFilter,
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
const bookingsFetch = useFetch(() => (sessionId ? getSessionBookings(sessionId) : Promise.resolve([])))
|
})
|
||||||
|
)
|
||||||
const bookings = bookingsFetch.data ?? []
|
const bookings = bookingsFetch.data ?? []
|
||||||
|
|
||||||
|
const availableTagsFetch = useFetch(() => getRfidTags("Available"))
|
||||||
|
const availableTags = availableTagsFetch.data ?? []
|
||||||
|
|
||||||
const [otpCode, setOtpCode] = React.useState("")
|
const [otpCode, setOtpCode] = React.useState("")
|
||||||
|
const [rfidTagId, setRfidTagId] = React.useState("")
|
||||||
const { busy: checkingIn, run: runCheckIn } = useMutation()
|
const { busy: checkingIn, run: runCheckIn } = useMutation()
|
||||||
|
|
||||||
const [payTarget, setPayTarget] = React.useState<Booking | null>(null)
|
const [payTarget, setPayTarget] = React.useState<Booking | null>(null)
|
||||||
const [cancelTarget, setCancelTarget] = React.useState<Booking | null>(null)
|
const [cancelTarget, setCancelTarget] = React.useState<Booking | null>(null)
|
||||||
|
const [noShowTarget, setNoShowTarget] = React.useState<Booking | null>(null)
|
||||||
|
const [completeTarget, setCompleteTarget] = React.useState<Booking | null>(null)
|
||||||
|
const [reassignTarget, setReassignTarget] = React.useState<Booking | null>(null)
|
||||||
|
const [rescheduleTarget, setRescheduleTarget] = React.useState<Booking | null>(null)
|
||||||
const [guestDialogOpen, setGuestDialogOpen] = React.useState(false)
|
const [guestDialogOpen, setGuestDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
const sessionOptions = sessions.map((s) => ({
|
const facilityFilterOptions = [
|
||||||
label: `${s.facilityName ?? "-"} · ${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
|
{ label: "All facilities", value: "all" },
|
||||||
value: s.facilitySessionId,
|
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
|
||||||
}))
|
]
|
||||||
|
|
||||||
|
const tagOptions = [
|
||||||
|
{ label: "No tag", value: "none" },
|
||||||
|
...availableTags.map((t) => ({ label: t.label ? `${t.label} (${t.tagCode})` : t.tagCode, value: t.rfidTagId })),
|
||||||
|
]
|
||||||
|
|
||||||
async function handleCheckIn(e: React.FormEvent) {
|
async function handleCheckIn(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!otpCode.trim()) return
|
if (!otpCode.trim()) return
|
||||||
try {
|
try {
|
||||||
await runCheckIn(() => checkInBooking(otpCode.trim()))
|
await runCheckIn(() => checkInBooking(otpCode.trim(), rfidTagId && rfidTagId !== "none" ? rfidTagId : undefined))
|
||||||
toast.success("Checked in")
|
toast.success("Checked in")
|
||||||
setOtpCode("")
|
setOtpCode("")
|
||||||
|
setRfidTagId("")
|
||||||
bookingsFetch.reload()
|
bookingsFetch.reload()
|
||||||
|
availableTagsFetch.reload()
|
||||||
} catch {
|
} catch {
|
||||||
// run() already toasted the real error.
|
// run() already toasted the real error.
|
||||||
}
|
}
|
||||||
@@ -125,13 +170,56 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmNoShow() {
|
||||||
|
if (!noShowTarget) return
|
||||||
|
const target = noShowTarget
|
||||||
|
setNoShowTarget(null)
|
||||||
|
try {
|
||||||
|
await runMutation(() => markNoShow(target.bookingId))
|
||||||
|
toast.success("Marked as no-show")
|
||||||
|
bookingsFetch.reload()
|
||||||
|
} catch {
|
||||||
|
// runMutation() already toasted the real error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmComplete() {
|
||||||
|
if (!completeTarget) return
|
||||||
|
const target = completeTarget
|
||||||
|
setCompleteTarget(null)
|
||||||
|
try {
|
||||||
|
await runMutation(() => completeBooking(target.bookingId))
|
||||||
|
toast.success("Booking completed")
|
||||||
|
bookingsFetch.reload()
|
||||||
|
availableTagsFetch.reload()
|
||||||
|
} catch {
|
||||||
|
// runMutation() already toasted the real error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyShareLink(booking: Booking) {
|
function copyShareLink(booking: Booking) {
|
||||||
if (!booking.shareUrl) return
|
if (!booking.shareUrl) return
|
||||||
navigator.clipboard.writeText(booking.shareUrl)
|
navigator.clipboard.writeText(booking.shareUrl)
|
||||||
toast.success("Link copied", { description: "Share it with the guest so they have their receipt." })
|
toast.success("Link copied", { description: "Share it with the guest so they have their receipt." })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
bookingsFetch.reload()
|
||||||
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<Booking>[] = [
|
const columns: DataTableColumn<Booking>[] = [
|
||||||
|
{
|
||||||
|
key: "facility",
|
||||||
|
header: "Facility & Time",
|
||||||
|
cell: (row) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.facilityName ?? "-"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "who",
|
key: "who",
|
||||||
header: "Booked By",
|
header: "Booked By",
|
||||||
@@ -139,13 +227,19 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
||||||
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
|
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
|
||||||
|
{row.partySize != null && <p className="text-xs text-muted-foreground">Party of {row.partySize}</p>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: (row) => <StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>,
|
cell: (row) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
|
||||||
|
{row.rfidTagLabel && <span className="text-xs text-muted-foreground">Tag: {row.rfidTagLabel}</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "payment",
|
key: "payment",
|
||||||
@@ -163,7 +257,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
header: "Actions",
|
header: "Actions",
|
||||||
className: "text-center",
|
className: "text-center",
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{row.shareUrl && (
|
{row.shareUrl && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -176,7 +270,7 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<TooltipContent>Copy share link</TooltipContent>
|
<TooltipContent>Copy share link</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
|
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -188,7 +282,43 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<TooltipContent>Mark as paid</TooltipContent>
|
<TooltipContent>Mark as paid</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{row.status !== "Cancelled" && (
|
{row.status === "Confirmed" && row.isGuest && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Reassign booking" onClick={() => setReassignTarget(row)}>
|
||||||
|
<ArrowRightLeftIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Reassign to someone else</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Reschedule booking" onClick={() => setRescheduleTarget(row)}>
|
||||||
|
<CalendarClockIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Reschedule</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Mark no-show" onClick={() => setNoShowTarget(row)}>
|
||||||
|
<UserXIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Mark no-show</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
@@ -200,6 +330,18 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<TooltipContent>Cancel booking</TooltipContent>
|
<TooltipContent>Cancel booking</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{row.status === "CheckedIn" && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Complete booking" onClick={() => setCompleteTarget(row)}>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Complete (release tag)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -218,21 +360,14 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
className="w-56"
|
className="w-56"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
|
|
||||||
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
|
|
||||||
Check In
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Session</Label>
|
<Label>RFID Tag</Label>
|
||||||
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
|
<Select items={tagOptions} value={rfidTagId || "none"} onValueChange={(v) => setRfidTagId(v ?? "none")}>
|
||||||
<SelectTrigger className="w-96 max-w-full">
|
<SelectTrigger className="w-56">
|
||||||
<SelectValue placeholder="Select a session" />
|
<SelectValue placeholder="No tag" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sessionOptions.map((opt) => (
|
{tagOptions.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -240,15 +375,66 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={!sessionId}>
|
<Button type="submit" disabled={!otpCode.trim() || checkingIn}>
|
||||||
|
{checkingIn ? <Spinner className="size-4" /> : <CheckIcon />}
|
||||||
|
Check In
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Facility</Label>
|
||||||
|
<Select
|
||||||
|
items={facilityFilterOptions}
|
||||||
|
value={facilityFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setFacilityFilter(v ?? "all")
|
||||||
|
reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue placeholder="All facilities" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{facilityFilterOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
items={STATUS_OPTIONS}
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setStatusFilter(v ?? "all")
|
||||||
|
reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => setGuestDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Add Walk-in Guest
|
Add Walk-in Guest
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sessionId ? (
|
{bookingsFetch.loading ? (
|
||||||
<InfoBanner>Schedule a session first, then come back here to manage its roster.</InfoBanner>
|
|
||||||
) : bookingsFetch.loading ? (
|
|
||||||
<div className="flex justify-center py-10">
|
<div className="flex justify-center py-10">
|
||||||
<Spinner className="size-6" />
|
<Spinner className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -257,34 +443,57 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
rows={bookings}
|
rows={bookings}
|
||||||
rowKey={(row) => row.bookingId}
|
rowKey={(row) => row.bookingId}
|
||||||
emptyMessage="No bookings for this session yet."
|
emptyMessage="No bookings match these filters."
|
||||||
renderMobileCard={(row) => (
|
renderMobileCard={(row) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
<p className="font-medium">{row.facilityName ?? "-"}</p>
|
||||||
{row.isGuest && <p className="text-xs text-muted-foreground">{row.guestPhone}</p>}
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{row.isGuest ? row.guestName ?? "Guest" : "Member"}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
|
<StatusBadge status={bookingStatusTone(row.status)}>{row.status}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-t pt-2">
|
<div className="flex items-center justify-between border-t pt-2">
|
||||||
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>{row.paymentStatus}</StatusBadge>
|
<StatusBadge status={paymentStatusTone(row.paymentStatus)}>{row.paymentStatus}</StatusBadge>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap justify-end gap-1.5">
|
||||||
{row.shareUrl && (
|
{row.shareUrl && (
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
|
<Button variant="outline" size="icon-sm" aria-label="Copy share link" onClick={() => copyShareLink(row)}>
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.isGuest && row.paymentStatus === "Unpaid" && row.status !== "Cancelled" && (
|
{row.status === "Confirmed" && row.isGuest && row.paymentStatus === "Unpaid" && (
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
|
<Button variant="outline" size="icon-sm" aria-label="Mark as paid" onClick={() => setPayTarget(row)}>
|
||||||
<WalletIcon />
|
<WalletIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.status !== "Cancelled" && (
|
{row.status === "Confirmed" && row.isGuest && (
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Reassign booking" onClick={() => setReassignTarget(row)}>
|
||||||
|
<ArrowRightLeftIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Reschedule booking" onClick={() => setRescheduleTarget(row)}>
|
||||||
|
<CalendarClockIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Mark no-show" onClick={() => setNoShowTarget(row)}>
|
||||||
|
<UserXIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.status === "Confirmed" && (
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
|
<Button variant="outline" size="icon-sm" aria-label="Cancel booking" onClick={() => setCancelTarget(row)}>
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{row.status === "CheckedIn" && (
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Complete booking" onClick={() => setCompleteTarget(row)}>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,17 +504,21 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
<AddWalkInGuestDialog
|
<AddWalkInGuestDialog
|
||||||
open={guestDialogOpen}
|
open={guestDialogOpen}
|
||||||
onOpenChange={setGuestDialogOpen}
|
onOpenChange={setGuestDialogOpen}
|
||||||
facilitySessionId={sessionId}
|
facilities={scheduledFacilities}
|
||||||
onCreated={bookingsFetch.reload}
|
onCreated={reload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={bookingsFetch.reload} />
|
<MarkPaidDialog booking={payTarget} onOpenChange={(open) => !open && setPayTarget(null)} onPaid={reload} />
|
||||||
|
|
||||||
|
<ReassignDialog booking={reassignTarget} onOpenChange={(open) => !open && setReassignTarget(null)} onSaved={reload} />
|
||||||
|
|
||||||
|
<RescheduleDialog booking={rescheduleTarget} onOpenChange={(open) => !open && setRescheduleTarget(null)} onSaved={reload} />
|
||||||
|
|
||||||
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
|
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Cancel this booking?</AlertDialogTitle>
|
<AlertDialogTitle>Cancel this booking?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>This frees up a spot in the session. This cannot be undone.</AlertDialogDescription>
|
<AlertDialogDescription>This frees up the slot. This cannot be undone.</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
|
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
|
||||||
@@ -313,6 +526,34 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!noShowTarget} onOpenChange={(open) => !open && setNoShowTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Mark this booking as no-show?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Use this when the party never arrived for their slot.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep as Confirmed</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmNoShow}>Mark No-Show</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!completeTarget} onOpenChange={(open) => !open && setCompleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Complete this booking?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Ends the session and releases the RFID tag (if one was assigned) back to the available pool.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Not Yet</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmComplete}>Complete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -320,11 +561,13 @@ function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
|||||||
interface AddWalkInGuestDialogProps {
|
interface AddWalkInGuestDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
facilitySessionId: string
|
facilities: Facility[]
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated }: AddWalkInGuestDialogProps) {
|
function AddWalkInGuestDialog({ open, onOpenChange, facilities, onCreated }: AddWalkInGuestDialogProps) {
|
||||||
|
const [facilityId, setFacilityId] = React.useState("")
|
||||||
|
const [sessionId, setSessionId] = React.useState("")
|
||||||
const [guestName, setGuestName] = React.useState("")
|
const [guestName, setGuestName] = React.useState("")
|
||||||
const [guestPhone, setGuestPhone] = React.useState("")
|
const [guestPhone, setGuestPhone] = React.useState("")
|
||||||
const { busy, run } = useMutation()
|
const { busy, run } = useMutation()
|
||||||
@@ -333,19 +576,30 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated
|
|||||||
if (open !== prevOpen) {
|
if (open !== prevOpen) {
|
||||||
setPrevOpen(open)
|
setPrevOpen(open)
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setFacilityId(facilities[0]?.facilityId ?? "")
|
||||||
|
setSessionId("")
|
||||||
setGuestName("")
|
setGuestName("")
|
||||||
setGuestPhone("")
|
setGuestPhone("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit = !!guestName.trim() && !!guestPhone.trim() && !busy
|
const sessionsFetch = useFetch(() => (facilityId ? getFacilitySessions({ facilityId }) : Promise.resolve([])))
|
||||||
|
const sessions = (sessionsFetch.data ?? []).filter((s) => !s.isCancelled && new Date(s.startTime) > new Date())
|
||||||
|
|
||||||
|
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||||
|
const sessionOptions = sessions.map((s) => ({
|
||||||
|
label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`,
|
||||||
|
value: s.facilitySessionId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const canSubmit = !!facilityId && !!sessionId && !!guestName.trim() && !!guestPhone.trim() && !busy
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
try {
|
try {
|
||||||
const booking = await run(() =>
|
const booking = await run(() =>
|
||||||
createGuestBooking({ facilitySessionId, guestName: guestName.trim(), guestPhone: guestPhone.trim() })
|
createGuestBooking({ facilitySessionId: sessionId, guestName: guestName.trim(), guestPhone: guestPhone.trim() })
|
||||||
)
|
)
|
||||||
if (booking.shareUrl) {
|
if (booking.shareUrl) {
|
||||||
navigator.clipboard.writeText(booking.shareUrl)
|
navigator.clipboard.writeText(booking.shareUrl)
|
||||||
@@ -369,10 +623,53 @@ function AddWalkInGuestDialog({ open, onOpenChange, facilitySessionId, onCreated
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Walk-in Guest</DialogTitle>
|
<DialogTitle>Add Walk-in Guest</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Books a spot for a non-member and hands them an OTP receipt with a shareable link.
|
Books a spot in a Scheduled session for a non-member and hands them an OTP receipt.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Facility <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
items={facilityOptions}
|
||||||
|
value={facilityId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setFacilityId(v ?? "")
|
||||||
|
setSessionId("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select facility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{facilityOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Session <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={sessionsFetch.loading ? "Loading…" : "Select session"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sessionOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="guest-name">
|
<Label htmlFor="guest-name">
|
||||||
Guest Name <span className="text-destructive">*</span>
|
Guest Name <span className="text-destructive">*</span>
|
||||||
@@ -471,4 +768,207 @@ function MarkPaidDialog({ booking, onOpenChange, onPaid }: MarkPaidDialogProps)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReassignDialogProps {
|
||||||
|
booking: Booking | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReassignDialog({ booking, onOpenChange, onSaved }: ReassignDialogProps) {
|
||||||
|
const [guestName, setGuestName] = React.useState("")
|
||||||
|
const [guestPhone, setGuestPhone] = React.useState("")
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevBooking, setPrevBooking] = React.useState(booking)
|
||||||
|
|
||||||
|
if (booking !== prevBooking) {
|
||||||
|
setPrevBooking(booking)
|
||||||
|
setGuestName(booking?.guestName ?? "")
|
||||||
|
setGuestPhone(booking?.guestPhone ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = !!booking && !!guestName.trim() && !!guestPhone.trim() && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!booking || !canSubmit) return
|
||||||
|
try {
|
||||||
|
await run(() => reassignBooking(booking.bookingId, { guestName: guestName.trim(), guestPhone: guestPhone.trim() }))
|
||||||
|
toast.success("Booking reassigned")
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!booking} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reassign Booking</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Hand this slot to a different person if the original party can't make it. The
|
||||||
|
OTP and slot stay the same.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="reassign-name">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="reassign-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="reassign-phone">
|
||||||
|
Mobile Number <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="reassign-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Reassign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RescheduleDialogProps {
|
||||||
|
booking: Booking | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) {
|
||||||
|
const isAdhoc = booking?.bookingMode === "AdHoc"
|
||||||
|
|
||||||
|
const [startTime, setStartTime] = React.useState("")
|
||||||
|
const [duration, setDuration] = React.useState("60")
|
||||||
|
const [sessionId, setSessionId] = React.useState("")
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevBooking, setPrevBooking] = React.useState(booking)
|
||||||
|
|
||||||
|
if (booking !== prevBooking) {
|
||||||
|
setPrevBooking(booking)
|
||||||
|
if (booking) {
|
||||||
|
setStartTime(toLocalInputValue(new Date(booking.startTime)))
|
||||||
|
setDuration(String(Math.round((new Date(booking.endTime).getTime() - new Date(booking.startTime).getTime()) / 60000)))
|
||||||
|
setSessionId("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsFetch = useFetch(() =>
|
||||||
|
booking && !isAdhoc ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([])
|
||||||
|
)
|
||||||
|
const sessionOptions = (sessionsFetch.data ?? [])
|
||||||
|
.filter(
|
||||||
|
(s: FacilitySession) =>
|
||||||
|
!s.isCancelled && new Date(s.startTime) > new Date() && s.facilitySessionId !== booking?.facilitySessionId
|
||||||
|
)
|
||||||
|
.map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId }))
|
||||||
|
|
||||||
|
const canSubmit = isAdhoc
|
||||||
|
? !!booking && !!startTime && Number(duration) >= 1 && !busy
|
||||||
|
: !!booking && !!sessionId && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!booking || !canSubmit) return
|
||||||
|
try {
|
||||||
|
await run(() =>
|
||||||
|
rescheduleBooking(
|
||||||
|
booking.bookingId,
|
||||||
|
isAdhoc
|
||||||
|
? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) }
|
||||||
|
: { facilitySessionId: sessionId }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
toast.success("Booking rescheduled")
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!booking} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reschedule Booking</DialogTitle>
|
||||||
|
<DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isAdhoc ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="reschedule-start">
|
||||||
|
Start <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="reschedule-start"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="reschedule-duration">
|
||||||
|
Duration (min) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="reschedule-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
New Session <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select items={sessionOptions} value={sessionId} onValueChange={(v) => setSessionId(v ?? "")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={sessionsFetch.loading ? "Loading…" : "Select session"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sessionOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Reschedule
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export { BookingRosterTab }
|
export { BookingRosterTab }
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
|
||||||
CalendarPlusIcon,
|
|
||||||
CheckIcon,
|
|
||||||
DumbbellIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
XIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cancelFacilitySession,
|
cancelFacilitySession,
|
||||||
createFacility,
|
|
||||||
createFacilitySession,
|
createFacilitySession,
|
||||||
getFacilities,
|
getFacilities,
|
||||||
getFacilitySessions,
|
getFacilitySessions,
|
||||||
setFacilityStatus,
|
|
||||||
updateFacility,
|
|
||||||
} from "@/lib/api/facilities"
|
} 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 { useFetch } from "@/lib/use-fetch"
|
||||||
import { useMutation, runMutation } from "@/lib/use-mutation"
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -34,7 +23,6 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -55,22 +43,16 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { PageHeader } from "@/components/hexa/page-header"
|
import { PageHeader } from "@/components/hexa/page-header"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
|
||||||
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
||||||
|
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
||||||
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-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) {
|
function formatDateTime(iso: string) {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -98,29 +80,30 @@ function BookingsView() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Bookings"
|
title="Bookings"
|
||||||
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
|
description="Scheduled sessions, ad-hoc reservations, the booking roster, RFID tags and time-bank cards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="facilities" className="gap-4">
|
<Tabs defaultValue="sessions" className="gap-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
|
||||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||||
|
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
||||||
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
||||||
|
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
||||||
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="facilities">
|
|
||||||
<FacilitiesTab
|
|
||||||
facilities={facilities}
|
|
||||||
onReload={facilitiesFetch.reload}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="sessions">
|
<TabsContent value="sessions">
|
||||||
<SessionsTab facilities={facilities} />
|
<SessionsTab facilities={facilities} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="adhoc">
|
||||||
|
<AdhocBookingTab facilities={facilities} />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="roster">
|
<TabsContent value="roster">
|
||||||
<BookingRosterTab facilities={facilities} />
|
<BookingRosterTab facilities={facilities} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="rfid-tags">
|
||||||
|
<RfidTagsTab />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="time-bank-cards">
|
<TabsContent value="time-bank-cards">
|
||||||
<TimeBankCardsTab facilities={facilities} />
|
<TimeBankCardsTab facilities={facilities} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -129,345 +112,6 @@ function BookingsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Facilities ----------
|
|
||||||
|
|
||||||
interface FacilitiesTabProps {
|
|
||||||
facilities: Facility[]
|
|
||||||
onReload: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function FacilitiesTab({ facilities, onReload }: FacilitiesTabProps) {
|
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
|
||||||
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
|
|
||||||
const [statusTarget, setStatusTarget] = React.useState<Facility | null>(null)
|
|
||||||
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
async function applyStatus(facility: Facility, isActive: boolean) {
|
|
||||||
setStatusPendingId(facility.facilityId)
|
|
||||||
try {
|
|
||||||
await runMutation(() => setFacilityStatus(facility.facilityId, isActive))
|
|
||||||
toast.success(isActive ? "Facility activated" : "Facility deactivated", {
|
|
||||||
description: `"${facility.name}" is now ${isActive ? "active" : "inactive"}.`,
|
|
||||||
})
|
|
||||||
onReload()
|
|
||||||
} catch {
|
|
||||||
// runMutation() already toasted the real error.
|
|
||||||
} finally {
|
|
||||||
setStatusPendingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: DataTableColumn<Facility>[] = [
|
|
||||||
{
|
|
||||||
key: "name",
|
|
||||||
header: "Facility",
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
||||||
<DumbbellIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{row.name}</p>
|
|
||||||
{row.description && (
|
|
||||||
<p className="text-xs text-muted-foreground">{row.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: "type", header: "Type", cell: (row) => <Badge variant="outline">{row.facilityType}</Badge> },
|
|
||||||
{ key: "capacity", header: "Default Capacity", cell: (row) => row.defaultCapacity },
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: (row) => (
|
|
||||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
|
||||||
{row.isActive ? "Active" : "Inactive"}
|
|
||||||
</StatusBadge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
className: "text-center",
|
|
||||||
cell: (row) => (
|
|
||||||
<div className="flex justify-center gap-1.5">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
|
|
||||||
<PencilIcon />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>Edit facility</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
|
|
||||||
disabled={statusPendingId === row.facilityId}
|
|
||||||
onClick={() => setStatusTarget(row)}
|
|
||||||
>
|
|
||||||
{statusPendingId === row.facilityId ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : row.isActive ? (
|
|
||||||
<XIcon className="text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEditTarget(null)
|
|
||||||
setDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
Add Facility
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={facilities}
|
|
||||||
rowKey={(row) => row.facilityId}
|
|
||||||
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
|
|
||||||
renderMobileCard={(row) => (
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
||||||
<DumbbellIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{row.name}</p>
|
|
||||||
<Badge variant="outline">{row.facilityType}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
|
||||||
{row.isActive ? "Active" : "Inactive"}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-t pt-2.5">
|
|
||||||
<span className="text-sm text-muted-foreground">Capacity: {row.defaultCapacity}</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
|
|
||||||
<PencilIcon />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
|
|
||||||
disabled={statusPendingId === row.facilityId}
|
|
||||||
onClick={() => setStatusTarget(row)}
|
|
||||||
>
|
|
||||||
{statusPendingId === row.facilityId ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : row.isActive ? (
|
|
||||||
<XIcon className="text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FacilityDialog
|
|
||||||
open={dialogOpen || !!editTarget}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setDialogOpen(false)
|
|
||||||
setEditTarget(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
editTarget={editTarget}
|
|
||||||
onSaved={onReload}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{statusTarget?.isActive ? "Deactivate this facility?" : "Activate this facility?"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{statusTarget?.isActive
|
|
||||||
? `"${statusTarget?.name}" will stop accepting new bookings until reactivated.`
|
|
||||||
: `"${statusTarget?.name}" will accept new bookings again.`}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
const facility = statusTarget
|
|
||||||
setStatusTarget(null)
|
|
||||||
if (facility) void applyStatus(facility, !facility.isActive)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statusTarget?.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FacilityDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
editTarget: Facility | null
|
|
||||||
onSaved: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function FacilityDialog({ open, onOpenChange, editTarget, onSaved }: FacilityDialogProps) {
|
|
||||||
const [name, setName] = React.useState("")
|
|
||||||
const [facilityType, setFacilityType] = React.useState<FacilityInput["facilityType"]>("Gym")
|
|
||||||
const [description, setDescription] = React.useState("")
|
|
||||||
const [capacity, setCapacity] = React.useState("1")
|
|
||||||
const { busy, run } = useMutation()
|
|
||||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
|
||||||
|
|
||||||
if (open !== prevOpen) {
|
|
||||||
setPrevOpen(open)
|
|
||||||
if (open) {
|
|
||||||
setName(editTarget?.name ?? "")
|
|
||||||
setFacilityType(editTarget?.facilityType ?? "Gym")
|
|
||||||
setDescription(editTarget?.description ?? "")
|
|
||||||
setCapacity(String(editTarget?.defaultCapacity ?? 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit = !!name.trim() && Number(capacity) >= 1 && !busy
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canSubmit) return
|
|
||||||
const input: FacilityInput = {
|
|
||||||
name: name.trim(),
|
|
||||||
facilityType,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
defaultCapacity: Number(capacity),
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await run(() =>
|
|
||||||
editTarget ? updateFacility(editTarget.facilityId, input) : createFacility(input)
|
|
||||||
)
|
|
||||||
toast.success(editTarget ? "Facility updated" : "Facility created", {
|
|
||||||
description: `"${input.name}" was saved.`,
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
|
||||||
onSaved()
|
|
||||||
} catch {
|
|
||||||
// run() already toasted the real error; keep the dialog open for retry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editTarget ? "Edit Facility" : "Add Facility"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{editTarget ? "Update this facility's details." : "Add the gym, pool, court or a class."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="facility-name">
|
|
||||||
Name <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="facility-name"
|
|
||||||
placeholder="e.g. Main Gym Floor"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<Select
|
|
||||||
items={FACILITY_TYPE_OPTIONS}
|
|
||||||
value={facilityType}
|
|
||||||
onValueChange={(v) => setFacilityType((v as FacilityInput["facilityType"]) ?? "Gym")}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{FACILITY_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="facility-capacity">
|
|
||||||
Default Capacity <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="facility-capacity"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={capacity}
|
|
||||||
onChange={(e) => setCapacity(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="facility-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="facility-description"
|
|
||||||
placeholder="Optional short description"
|
|
||||||
maxLength={300}
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
|
||||||
{busy && <Spinner className="size-4" />}
|
|
||||||
{editTarget ? "Save Changes" : "Add Facility"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Sessions ----------
|
// ---------- Sessions ----------
|
||||||
|
|
||||||
interface SessionsTabProps {
|
interface SessionsTabProps {
|
||||||
@@ -475,6 +119,8 @@ interface SessionsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionsTab({ facilities }: SessionsTabProps) {
|
function SessionsTab({ facilities }: SessionsTabProps) {
|
||||||
|
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled")
|
||||||
|
|
||||||
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
||||||
const sessionsFetch = useFetch(() =>
|
const sessionsFetch = useFetch(() =>
|
||||||
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
|
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
|
||||||
@@ -487,7 +133,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
|
|||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ label: "All facilities", value: "all" },
|
{ 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() {
|
async function confirmCancel() {
|
||||||
@@ -567,7 +213,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)} disabled={facilities.length === 0}>
|
<Button onClick={() => setDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
|
||||||
<CalendarPlusIcon />
|
<CalendarPlusIcon />
|
||||||
Schedule Session
|
Schedule Session
|
||||||
</Button>
|
</Button>
|
||||||
@@ -616,7 +262,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
|
|||||||
<ScheduleSessionDialog
|
<ScheduleSessionDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
facilities={facilities}
|
facilities={scheduledFacilities}
|
||||||
onScheduled={sessionsFetch.reload}
|
onScheduled={sessionsFetch.reload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -698,7 +344,7 @@ function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }:
|
|||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Session</DialogTitle>
|
<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>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<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,
|
setGymPlanStatus,
|
||||||
updateGymPlan,
|
updateGymPlan,
|
||||||
} from "@/lib/api/gym-plans"
|
} 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 type { GymPlan } from "@/lib/models/gym-plan"
|
||||||
import { sortByNewest } from "@/lib/validation"
|
import { sortByNewest } from "@/lib/validation"
|
||||||
import { useFetch } from "@/lib/use-fetch"
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
@@ -114,8 +114,8 @@ interface GymPlansViewProps {
|
|||||||
|
|
||||||
function GymPlansView({ demo = false }: GymPlansViewProps) {
|
function GymPlansView({ demo = false }: GymPlansViewProps) {
|
||||||
const plansFetch = useFetch(demo ? async () => DEMO_PLANS : () => getGymPlans(true))
|
const plansFetch = useFetch(demo ? async () => DEMO_PLANS : () => getGymPlans(true))
|
||||||
const placesFetch = useFetch(demo ? async () => [] : () => getPlaces(false))
|
const facilitiesFetch = useFetch(demo ? async () => [] : () => getFacilities(false))
|
||||||
const places = placesFetch.data ?? []
|
const facilities = facilitiesFetch.data ?? []
|
||||||
const [demoPlans, setDemoPlans] = React.useState<GymPlan[]>(DEMO_PLANS)
|
const [demoPlans, setDemoPlans] = React.useState<GymPlan[]>(DEMO_PLANS)
|
||||||
const plans = React.useMemo(
|
const plans = React.useMemo(
|
||||||
() => sortByNewest(demo ? demoPlans : (plansFetch.data ?? []), (p) => p.createdAt),
|
() => sortByNewest(demo ? demoPlans : (plansFetch.data ?? []), (p) => p.createdAt),
|
||||||
@@ -125,7 +125,7 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
const [search, setSearch] = React.useState("")
|
const [search, setSearch] = React.useState("")
|
||||||
const [status, setStatus] = React.useState("all")
|
const [status, setStatus] = React.useState("all")
|
||||||
const [duration, setDuration] = 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 [page, setPage] = React.useState(1)
|
||||||
const [pageSize, setPageSize] = React.useState(10)
|
const [pageSize, setPageSize] = React.useState(10)
|
||||||
|
|
||||||
@@ -149,10 +149,10 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
const matchesDuration =
|
const matchesDuration =
|
||||||
duration === "all" ||
|
duration === "all" ||
|
||||||
(duration === "fixed" ? p.durationMode === "FixedRange" : String(p.durationInDays) === duration)
|
(duration === "fixed" ? p.durationMode === "FixedRange" : String(p.durationInDays) === duration)
|
||||||
const matchesPlace =
|
const matchesFacility =
|
||||||
placeFilter === "all" ||
|
facilityFilter === "all" ||
|
||||||
(placeFilter === "unrestricted" ? p.placeIds.length === 0 : p.placeIds.includes(placeFilter))
|
(facilityFilter === "unrestricted" ? p.placeIds.length === 0 : p.placeIds.includes(facilityFilter))
|
||||||
return matchesSearch && matchesStatus && matchesDuration && matchesPlace
|
return matchesSearch && matchesStatus && matchesDuration && matchesFacility
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1)
|
const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1)
|
||||||
@@ -206,9 +206,9 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
allowSplitPayments: allowSplit,
|
allowSplitPayments: allowSplit,
|
||||||
installmentCount: installmentCount ?? 1,
|
installmentCount: installmentCount ?? 1,
|
||||||
placeIds: values.placeIds,
|
placeIds: values.placeIds,
|
||||||
placeNames: places
|
placeNames: facilities
|
||||||
.filter((p) => values.placeIds.includes(p.placeId))
|
.filter((f) => values.placeIds.includes(f.facilityId))
|
||||||
.map((p) => p.name),
|
.map((f) => f.name),
|
||||||
}
|
}
|
||||||
|
|
||||||
return { apiInput, planPatch }
|
return { apiInput, planPatch }
|
||||||
@@ -304,8 +304,8 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "places",
|
key: "facilities",
|
||||||
header: "Places",
|
header: "Facilities",
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{row.placeNames.length ? (
|
{row.placeNames.length ? (
|
||||||
@@ -467,17 +467,17 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "place",
|
key: "facility",
|
||||||
label: "Place",
|
label: "Facility",
|
||||||
value: placeFilter,
|
value: facilityFilter,
|
||||||
placeholder: "All Places",
|
placeholder: "All Facilities",
|
||||||
options: [
|
options: [
|
||||||
{ label: "All Places", value: "all" },
|
{ label: "All Facilities", value: "all" },
|
||||||
{ label: "Unrestricted", value: "unrestricted" },
|
{ label: "Unrestricted", value: "unrestricted" },
|
||||||
...places.map((p) => ({ label: p.name, value: p.placeId })),
|
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
|
||||||
],
|
],
|
||||||
onChange: (v) => {
|
onChange: (v) => {
|
||||||
setPlaceFilter(v)
|
setFacilityFilter(v)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -488,7 +488,7 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
setSearch("")
|
setSearch("")
|
||||||
setStatus("all")
|
setStatus("all")
|
||||||
setDuration("all")
|
setDuration("all")
|
||||||
setPlaceFilter("all")
|
setFacilityFilter("all")
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -581,14 +581,14 @@ function GymPlansView({ demo = false }: GymPlansViewProps) {
|
|||||||
</InfoBanner>
|
</InfoBanner>
|
||||||
|
|
||||||
<DetailPanel open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)} title={viewTarget?.planName ?? "Membership plan"} description="Plan details">
|
<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>
|
</DetailPanel>
|
||||||
<AddGymPlanDialog
|
<AddGymPlanDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
|
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
|
||||||
mode={dialogMode}
|
mode={dialogMode}
|
||||||
places={places}
|
facilities={facilities}
|
||||||
initialValues={
|
initialValues={
|
||||||
dialogMode === "edit" && editTarget
|
dialogMode === "edit" && editTarget
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { FacilitiesView } from "@/components/hexa/views/facilities-view"
|
||||||
import { GymPlansView } from "@/components/hexa/views/gym-plans-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"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
|
||||||
function MembershipsView() {
|
function MembershipsView() {
|
||||||
@@ -9,14 +9,14 @@ function MembershipsView() {
|
|||||||
<Tabs defaultValue="plans" className="gap-4">
|
<Tabs defaultValue="plans" className="gap-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="plans">Plans</TabsTrigger>
|
<TabsTrigger value="plans">Plans</TabsTrigger>
|
||||||
<TabsTrigger value="places">Places</TabsTrigger>
|
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="plans">
|
<TabsContent value="plans">
|
||||||
<GymPlansView />
|
<GymPlansView />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="places">
|
<TabsContent value="facilities">
|
||||||
<PlacesView />
|
<FacilitiesView />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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"
|
import type { LoginRequest, LoginResult } from "@/lib/models/auth"
|
||||||
|
|
||||||
async function login(request: LoginRequest): Promise<LoginResult> {
|
async function login(request: LoginRequest): Promise<LoginResult> {
|
||||||
const data = await authRequest<unknown>("loginUser", {
|
const data = await authRequest<unknown>("auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
functionName: "loginUser",
|
functionName: "LoginUser",
|
||||||
payload: {
|
payload: {
|
||||||
identifier: request.identifier,
|
identifier: request.identifier,
|
||||||
password: request.password,
|
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. */
|
/** Step 1 of forgot-password: sends an OTP to the account's email/phone. */
|
||||||
async function forgotPassword(identifier: string): Promise<{ referenceNumber: string }> {
|
async function forgotPassword(identifier: string): Promise<{ referenceNumber: string }> {
|
||||||
const data = await authRequest<unknown>("forgotPassword", {
|
const data = await authRequest<unknown>("auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
functionName: "forgotPassword",
|
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
|
* the generic recovery RPC endpoint (auth-service dispatches by functionName), same as
|
||||||
* resetPassword/resetPasswordWithToken below. */
|
* resetPassword/resetPasswordWithToken below. */
|
||||||
async function verifyResetOtp(referenceNumber: string, otpCode: string): Promise<void> {
|
async function verifyResetOtp(referenceNumber: string, otpCode: string): Promise<void> {
|
||||||
await authRequest<unknown>("recovery", {
|
await authRequest<unknown>("auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
functionName: "VerifyOTP",
|
functionName: "VerifyOTP",
|
||||||
@@ -61,7 +61,7 @@ async function resetPassword(
|
|||||||
newPassword: string,
|
newPassword: string,
|
||||||
confirmPassword: string
|
confirmPassword: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await authRequest<unknown>("recovery", {
|
await authRequest<unknown>("auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
functionName: "resetPassword",
|
functionName: "resetPassword",
|
||||||
@@ -77,7 +77,7 @@ async function resetPasswordWithToken(
|
|||||||
newPassword: string,
|
newPassword: string,
|
||||||
confirmPassword: string
|
confirmPassword: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await authRequest<unknown>("recovery", {
|
await authRequest<unknown>("auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
functionName: "resetPasswordWithToken",
|
functionName: "resetPasswordWithToken",
|
||||||
|
|||||||
+55
-3
@@ -1,6 +1,20 @@
|
|||||||
import { apiRequest } from "@/lib/api/core-http"
|
import { apiRequest } from "@/lib/api/core-http"
|
||||||
import type { Booking, CreateGuestBookingInput } from "@/lib/models/booking"
|
import type {
|
||||||
|
Booking,
|
||||||
|
CreateAdhocBookingInput,
|
||||||
|
CreateGuestAdhocBookingInput,
|
||||||
|
CreateGuestBookingInput,
|
||||||
|
GetBookingsFilters,
|
||||||
|
ReassignBookingInput,
|
||||||
|
RescheduleBookingInput,
|
||||||
|
} from "@/lib/models/booking"
|
||||||
|
|
||||||
|
/** Unified roster across both Scheduled and AdHoc bookings. */
|
||||||
|
function getBookings(filters: GetBookingsFilters = {}) {
|
||||||
|
return apiRequest<Booking[]>("client/bookings", { query: filters })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept for the Scheduled-session roster view; prefer getBookings({ facilitySessionId }) elsewhere. */
|
||||||
function getSessionBookings(facilitySessionId: string) {
|
function getSessionBookings(facilitySessionId: string) {
|
||||||
return apiRequest<Booking[]>(`client/bookings/session/${facilitySessionId}`)
|
return apiRequest<Booking[]>(`client/bookings/session/${facilitySessionId}`)
|
||||||
}
|
}
|
||||||
@@ -10,8 +24,21 @@ function createGuestBooking(input: CreateGuestBookingInput) {
|
|||||||
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
|
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkInBooking(otpCode: string) {
|
/** Member self-service AdHoc booking. */
|
||||||
return apiRequest<Booking>("client/bookings/check-in", { method: "POST", body: { otpCode } })
|
function createAdhocBooking(input: CreateAdhocBookingInput) {
|
||||||
|
return apiRequest<Booking>("app/bookings/adhoc", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff-initiated walk-in AdHoc booking — same public endpoint the guest AdHoc flow uses. */
|
||||||
|
function createGuestAdhocBooking(input: CreateGuestAdhocBookingInput) {
|
||||||
|
return apiRequest<Booking>("public/bookings/guest/adhoc", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInBooking(otpCode: string, rfidTagId?: string) {
|
||||||
|
return apiRequest<Booking>("client/bookings/check-in", {
|
||||||
|
method: "POST",
|
||||||
|
body: { otpCode, rfidTagId },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function markGuestBookingPaid(id: string, amountCharged: number) {
|
function markGuestBookingPaid(id: string, amountCharged: number) {
|
||||||
@@ -25,10 +52,35 @@ function cancelBooking(id: string) {
|
|||||||
return apiRequest<Booking>(`client/bookings/${id}/cancel`, { method: "POST" })
|
return apiRequest<Booking>(`client/bookings/${id}/cancel`, { method: "POST" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guest-only — hands a Confirmed booking to a different name/phone before check-in. */
|
||||||
|
function reassignBooking(id: string, input: ReassignBookingInput) {
|
||||||
|
return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves a Confirmed booking to a new time (AdHoc) or a new session (Scheduled) — only onto a free slot. */
|
||||||
|
function rescheduleBooking(id: string, input: RescheduleBookingInput) {
|
||||||
|
return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function markNoShow(id: string) {
|
||||||
|
return apiRequest<Booking>(`client/bookings/${id}/no-show`, { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeBooking(id: string) {
|
||||||
|
return apiRequest<Booking>(`client/bookings/${id}/complete`, { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getBookings,
|
||||||
getSessionBookings,
|
getSessionBookings,
|
||||||
createGuestBooking,
|
createGuestBooking,
|
||||||
|
createAdhocBooking,
|
||||||
|
createGuestAdhocBooking,
|
||||||
checkInBooking,
|
checkInBooking,
|
||||||
markGuestBookingPaid,
|
markGuestBookingPaid,
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
|
reassignBooking,
|
||||||
|
rescheduleBooking,
|
||||||
|
markNoShow,
|
||||||
|
completeBooking,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequest } from "@/lib/api/core-http"
|
import { apiRequest } from "@/lib/api/core-http"
|
||||||
import type {
|
import type {
|
||||||
Facility,
|
Facility,
|
||||||
|
FacilityAvailability,
|
||||||
FacilityInput,
|
FacilityInput,
|
||||||
FacilitySession,
|
FacilitySession,
|
||||||
FacilitySessionInput,
|
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 } = {}) {
|
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) {
|
||||||
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
|
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" })
|
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 {
|
export {
|
||||||
getFacilities,
|
getFacilities,
|
||||||
createFacility,
|
createFacility,
|
||||||
updateFacility,
|
updateFacility,
|
||||||
setFacilityStatus,
|
setFacilityStatus,
|
||||||
|
assignFacilityDevice,
|
||||||
getFacilitySessions,
|
getFacilitySessions,
|
||||||
createFacilitySession,
|
createFacilitySession,
|
||||||
cancelFacilitySession,
|
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 }
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/** Mirrors BookingDto. */
|
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
|
||||||
|
|
||||||
|
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
|
||||||
|
* (facilitySessionId null, exclusive-unit) bookings. */
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
bookingId: string
|
bookingId: string
|
||||||
facilitySessionId: string
|
/** Null for an AdHoc booking — no pre-created session. */
|
||||||
|
facilitySessionId?: string | null
|
||||||
|
facilityId: string
|
||||||
facilityName?: string | null
|
facilityName?: string | null
|
||||||
sessionStartTime?: string | null
|
bookingMode: "Scheduled" | "AdHoc"
|
||||||
sessionEndTime?: string | null
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
/** Informational headcount, AdHoc only. */
|
||||||
|
partySize?: number | null
|
||||||
|
|
||||||
isGuest: boolean
|
isGuest: boolean
|
||||||
customerId?: string | null
|
customerId?: string | null
|
||||||
@@ -15,18 +23,60 @@ export interface Booking {
|
|||||||
otpCode?: string | null
|
otpCode?: string | null
|
||||||
otpVerified: boolean
|
otpVerified: boolean
|
||||||
|
|
||||||
|
rfidTagId?: string | null
|
||||||
|
rfidTagLabel?: string | null
|
||||||
|
rfidAssignedAt?: string | null
|
||||||
|
rfidReturnedAt?: string | null
|
||||||
|
|
||||||
paymentStatus: string
|
paymentStatus: string
|
||||||
amountCharged: number
|
amountCharged: number
|
||||||
status: string
|
status: BookingStatus
|
||||||
bookedAt: string
|
bookedAt: string
|
||||||
checkedInAt?: string | null
|
checkedInAt?: string | null
|
||||||
|
checkedOutAt?: string | null
|
||||||
|
|
||||||
/** Public, unauthenticated link to this booking's confirmation page. */
|
/** Public, unauthenticated link to this booking's confirmation page. */
|
||||||
shareUrl?: string | null
|
shareUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GetBookingsFilters = {
|
||||||
|
facilityId?: string
|
||||||
|
facilitySessionId?: string
|
||||||
|
fromDate?: string
|
||||||
|
toDate?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateGuestBookingInput {
|
export interface CreateGuestBookingInput {
|
||||||
facilitySessionId: string
|
facilitySessionId: string
|
||||||
guestName: string
|
guestName: string
|
||||||
guestPhone: string
|
guestPhone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateAdhocBookingInput {
|
||||||
|
facilityId: string
|
||||||
|
startTime: string
|
||||||
|
durationMinutes: number
|
||||||
|
partySize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuestAdhocBookingInput {
|
||||||
|
facilityId: string
|
||||||
|
startTime: string
|
||||||
|
durationMinutes: number
|
||||||
|
guestName: string
|
||||||
|
guestPhone: string
|
||||||
|
partySize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReassignBookingInput {
|
||||||
|
guestName?: string
|
||||||
|
guestPhone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly one shape applies, matching the booking's current bookingMode. */
|
||||||
|
export interface RescheduleBookingInput {
|
||||||
|
startTime?: string
|
||||||
|
durationMinutes?: number
|
||||||
|
facilitySessionId?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
export interface Facility {
|
||||||
facilityId: string
|
facilityId: string
|
||||||
name: string
|
name: string
|
||||||
facilityType: FacilityType
|
bookingMode: BookingMode
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
/** Meaningful when bookingMode === "Scheduled" — shared capacity per session. */
|
||||||
defaultCapacity: number
|
defaultCapacity: number
|
||||||
|
/** Meaningful when bookingMode === "AdHoc" — e.g. 3 for "3 courts", each independently bookable. */
|
||||||
|
numberOfUnits: number
|
||||||
|
pricePerHour: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
deviceId?: string | null
|
||||||
|
deviceName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FacilityInput {
|
export interface FacilityInput {
|
||||||
name: string
|
name: string
|
||||||
facilityType: FacilityType
|
bookingMode: BookingMode
|
||||||
description?: string
|
description?: string
|
||||||
defaultCapacity: number
|
defaultCapacity: number
|
||||||
|
numberOfUnits: number
|
||||||
|
pricePerHour: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors FacilitySessionDto. */
|
/** Mirrors FacilitySessionDto. */
|
||||||
@@ -37,3 +47,13 @@ export interface FacilitySessionInput {
|
|||||||
/** Overrides the facility's default capacity for this specific session. */
|
/** Overrides the facility's default capacity for this specific session. */
|
||||||
capacity?: number
|
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