first commit
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"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 }
|
||||
Reference in New Issue
Block a user