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

feat: add RfidTagsTab component for managing RFID tags

refactor: update auth API endpoints to use unified auth endpoint

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

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

delete: remove obsolete places API and model definitions

feat: introduce RFID tag management API and model definitions
This commit is contained in:
2026-09-29 13:02:16 +05:30
parent a1b870fa8a
commit 23ae7b39d0
18 changed files with 1906 additions and 915 deletions
+21 -375
View File
@@ -1,29 +1,18 @@
"use client"
import * as React from "react"
import {
CalendarPlusIcon,
CheckIcon,
DumbbellIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
import { toast } from "sonner"
import {
cancelFacilitySession,
createFacility,
createFacilitySession,
getFacilities,
getFacilitySessions,
setFacilityStatus,
updateFacility,
} from "@/lib/api/facilities"
import type { Facility, FacilityInput, FacilitySession } from "@/lib/models/facility"
import type { Facility, FacilitySession } from "@/lib/models/facility"
import { useFetch } from "@/lib/use-fetch"
import { useMutation, runMutation } from "@/lib/use-mutation"
import { useMutation } from "@/lib/use-mutation"
import {
AlertDialog,
AlertDialogAction,
@@ -34,7 +23,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -55,22 +43,16 @@ import {
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageHeader } from "@/components/hexa/page-header"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
import { StatusBadge } from "@/components/hexa/status-badge"
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
const FACILITY_TYPE_OPTIONS: { label: string; value: FacilityInput["facilityType"] }[] = [
{ label: "Gym", value: "Gym" },
{ label: "Swimming Pool", value: "Pool" },
{ label: "Basketball Court", value: "Court" },
{ label: "Class (e.g. Karate)", value: "Class" },
]
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
@@ -98,29 +80,30 @@ function BookingsView() {
<div className="flex flex-col gap-4">
<PageHeader
title="Bookings"
description="Facilities, scheduled sessions, the booking roster and time-bank cards"
description="Scheduled sessions, ad-hoc reservations, the booking roster, RFID tags and time-bank cards"
/>
<Tabs defaultValue="facilities" className="gap-4">
<Tabs defaultValue="sessions" className="gap-4">
<TabsList>
<TabsTrigger value="facilities">Facilities</TabsTrigger>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
<TabsTrigger value="roster">Roster &amp; Check-in</TabsTrigger>
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
<TabsTrigger value="time-bank-cards">Time Bank Cards</TabsTrigger>
</TabsList>
<TabsContent value="facilities">
<FacilitiesTab
facilities={facilities}
onReload={facilitiesFetch.reload}
/>
</TabsContent>
<TabsContent value="sessions">
<SessionsTab facilities={facilities} />
</TabsContent>
<TabsContent value="adhoc">
<AdhocBookingTab facilities={facilities} />
</TabsContent>
<TabsContent value="roster">
<BookingRosterTab facilities={facilities} />
</TabsContent>
<TabsContent value="rfid-tags">
<RfidTagsTab />
</TabsContent>
<TabsContent value="time-bank-cards">
<TimeBankCardsTab facilities={facilities} />
</TabsContent>
@@ -129,345 +112,6 @@ function BookingsView() {
)
}
// ---------- Facilities ----------
interface FacilitiesTabProps {
facilities: Facility[]
onReload: () => void
}
function FacilitiesTab({ facilities, onReload }: FacilitiesTabProps) {
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editTarget, setEditTarget] = React.useState<Facility | null>(null)
const [statusTarget, setStatusTarget] = React.useState<Facility | null>(null)
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
async function applyStatus(facility: Facility, isActive: boolean) {
setStatusPendingId(facility.facilityId)
try {
await runMutation(() => setFacilityStatus(facility.facilityId, isActive))
toast.success(isActive ? "Facility activated" : "Facility deactivated", {
description: `"${facility.name}" is now ${isActive ? "active" : "inactive"}.`,
})
onReload()
} catch {
// runMutation() already toasted the real error.
} finally {
setStatusPendingId(null)
}
}
const columns: DataTableColumn<Facility>[] = [
{
key: "name",
header: "Facility",
cell: (row) => (
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
{row.description && (
<p className="text-xs text-muted-foreground">{row.description}</p>
)}
</div>
</div>
),
},
{ key: "type", header: "Type", cell: (row) => <Badge variant="outline">{row.facilityType}</Badge> },
{ key: "capacity", header: "Default Capacity", cell: (row) => row.defaultCapacity },
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => (
<div className="flex justify-center gap-1.5">
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
}
/>
<TooltipContent>Edit facility</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
}
/>
<TooltipContent>{row.isActive ? "Deactivate facility" : "Activate facility"}</TooltipContent>
</Tooltip>
</div>
),
},
]
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button
onClick={() => {
setEditTarget(null)
setDialogOpen(true)
}}
>
<PlusIcon />
Add Facility
</Button>
</div>
<DataTable
columns={columns}
rows={facilities}
rowKey={(row) => row.facilityId}
emptyMessage="No facilities yet. Add the gym, pool, court or a class to get started."
renderMobileCard={(row) => (
<div className="flex flex-col gap-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<DumbbellIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.name}</p>
<Badge variant="outline">{row.facilityType}</Badge>
</div>
</div>
<StatusBadge status={row.isActive ? "success" : "neutral"}>
{row.isActive ? "Active" : "Inactive"}
</StatusBadge>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">Capacity: {row.defaultCapacity}</span>
<div className="flex gap-1.5">
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => setEditTarget(row)}>
<PencilIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={row.isActive ? "Deactivate facility" : "Activate facility"}
disabled={statusPendingId === row.facilityId}
onClick={() => setStatusTarget(row)}
>
{statusPendingId === row.facilityId ? (
<Spinner className="size-4" />
) : row.isActive ? (
<XIcon className="text-muted-foreground" />
) : (
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
)}
</Button>
</div>
</div>
</div>
)}
/>
<FacilityDialog
open={dialogOpen || !!editTarget}
onOpenChange={(open) => {
if (!open) {
setDialogOpen(false)
setEditTarget(null)
}
}}
editTarget={editTarget}
onSaved={onReload}
/>
<AlertDialog open={!!statusTarget} onOpenChange={(open) => !open && setStatusTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{statusTarget?.isActive ? "Deactivate this facility?" : "Activate this facility?"}
</AlertDialogTitle>
<AlertDialogDescription>
{statusTarget?.isActive
? `"${statusTarget?.name}" will stop accepting new bookings until reactivated.`
: `"${statusTarget?.name}" will accept new bookings again.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const facility = statusTarget
setStatusTarget(null)
if (facility) void applyStatus(facility, !facility.isActive)
}}
>
{statusTarget?.isActive ? "Deactivate" : "Activate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
interface FacilityDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget: Facility | null
onSaved: () => void
}
function FacilityDialog({ open, onOpenChange, editTarget, onSaved }: FacilityDialogProps) {
const [name, setName] = React.useState("")
const [facilityType, setFacilityType] = React.useState<FacilityInput["facilityType"]>("Gym")
const [description, setDescription] = React.useState("")
const [capacity, setCapacity] = React.useState("1")
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setName(editTarget?.name ?? "")
setFacilityType(editTarget?.facilityType ?? "Gym")
setDescription(editTarget?.description ?? "")
setCapacity(String(editTarget?.defaultCapacity ?? 1))
}
}
const canSubmit = !!name.trim() && Number(capacity) >= 1 && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
const input: FacilityInput = {
name: name.trim(),
facilityType,
description: description.trim() || undefined,
defaultCapacity: Number(capacity),
}
try {
await run(() =>
editTarget ? updateFacility(editTarget.facilityId, input) : createFacility(input)
)
toast.success(editTarget ? "Facility updated" : "Facility created", {
description: `"${input.name}" was saved.`,
})
onOpenChange(false)
onSaved()
} catch {
// run() already toasted the real error; keep the dialog open for retry.
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{editTarget ? "Edit Facility" : "Add Facility"}</DialogTitle>
<DialogDescription>
{editTarget ? "Update this facility's details." : "Add the gym, pool, court or a class."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="facility-name"
placeholder="e.g. Main Gym Floor"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select
items={FACILITY_TYPE_OPTIONS}
value={facilityType}
onValueChange={(v) => setFacilityType((v as FacilityInput["facilityType"]) ?? "Gym")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{FACILITY_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-capacity">
Default Capacity <span className="text-destructive">*</span>
</Label>
<Input
id="facility-capacity"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
required
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="facility-description">Description</Label>
<Textarea
id="facility-description"
placeholder="Optional short description"
maxLength={300}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
{editTarget ? "Save Changes" : "Add Facility"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ---------- Sessions ----------
interface SessionsTabProps {
@@ -475,6 +119,8 @@ interface SessionsTabProps {
}
function SessionsTab({ facilities }: SessionsTabProps) {
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled")
const [facilityFilter, setFacilityFilter] = React.useState("all")
const sessionsFetch = useFetch(() =>
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
@@ -487,7 +133,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
const filterOptions = [
{ label: "All facilities", value: "all" },
...facilities.map((f) => ({ label: f.name, value: f.facilityId })),
...scheduledFacilities.map((f) => ({ label: f.name, value: f.facilityId })),
]
async function confirmCancel() {
@@ -567,7 +213,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
</SelectContent>
</Select>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={facilities.length === 0}>
<Button onClick={() => setDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
<CalendarPlusIcon />
Schedule Session
</Button>
@@ -616,7 +262,7 @@ function SessionsTab({ facilities }: SessionsTabProps) {
<ScheduleSessionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
facilities={facilities}
facilities={scheduledFacilities}
onScheduled={sessionsFetch.reload}
/>
@@ -698,7 +344,7 @@ function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }:
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Schedule Session</DialogTitle>
<DialogDescription>Open a bookable time slot for a facility.</DialogDescription>
<DialogDescription>Open a bookable time slot for a Scheduled-mode facility.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">