Files
Client_Fe/src/components/hexa/views/places-view.tsx
T
Dhananjaya99 a1b870fa8a first commit
2026-09-28 11:48:14 +05:30

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. &quot;Pool Area&quot;, &quot;Indoor Gym&quot;).
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&apos;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 }