379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
"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 }
|