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:
@@ -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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user