From 77dbd154502d689bd0eff81238d7fd41787967fe Mon Sep 17 00:00:00 2001 From: toanhoang Date: Wed, 19 Aug 2026 22:07:25 -0400 Subject: [PATCH] feat: add Inventory Scans review page List + inline edit (model within category, area, assignee, specs, asset tag, serial number) + delete. No create form -- creation only happens via the bot. Co-Authored-By: Claude Sonnet 5 --- src/app/inventory-scans/page.tsx | 347 +++++++++++++++++++++++++++++++ src/components/nav.tsx | 1 + 2 files changed, 348 insertions(+) create mode 100644 src/app/inventory-scans/page.tsx diff --git a/src/app/inventory-scans/page.tsx b/src/app/inventory-scans/page.tsx new file mode 100644 index 0000000..f449b5d --- /dev/null +++ b/src/app/inventory-scans/page.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState } from "react"; +import { useApiList } from "@/hooks/use-api-list"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +// Shape returned by GET /api/inventory-scans — snake_case per this repo's +// API-boundary convention (see src/lib/inventory-scans.ts's toRecord()). +interface InventoryScanRecord { + id: string; + category_id: string; + category_name: string; + model_id: string; + model_name: string; + model_manufacturer: string; + area_id: string | null; + area_name: string | null; + assigned_employee_id: string | null; + assigned_employee_name: string | null; + asset_tag: string | null; + serial_number: string | null; + specs: Record; + photo_file_id: string | null; + created_at: string; +} + +interface AreaRecord { + id: string; + name: string; + team: string | null; +} + +interface EmployeeRecord { + id: string; + name: string; +} + +interface AssetModelRecord { + id: string; + category_id: string; + manufacturer: string; + name: string; +} + +const NO_AREA_VALUE = "none"; +const NO_EMPLOYEE_VALUE = "none"; + +export default function InventoryScansPage() { + const { data: scans, loading, error, refetch } = useApiList( + "/api/inventory-scans" + ); + const { data: areas } = useApiList("/api/areas"); + const { data: employees } = useApiList("/api/employees"); + const { data: models } = useApiList("/api/asset-models"); + + const [editingId, setEditingId] = useState(null); + const [editModelId, setEditModelId] = useState(""); + const [editAreaId, setEditAreaId] = useState(NO_AREA_VALUE); + const [editEmployeeId, setEditEmployeeId] = useState(NO_EMPLOYEE_VALUE); + const [editAssetTag, setEditAssetTag] = useState(""); + const [editSerialNumber, setEditSerialNumber] = useState(""); + const [editSpecs, setEditSpecs] = useState>({}); + const [saving, setSaving] = useState(false); + const [rowError, setRowError] = useState(null); + + function startEdit(scan: InventoryScanRecord) { + setEditingId(scan.id); + setEditModelId(scan.model_id); + setEditAreaId(scan.area_id ?? NO_AREA_VALUE); + setEditEmployeeId(scan.assigned_employee_id ?? NO_EMPLOYEE_VALUE); + setEditAssetTag(scan.asset_tag ?? ""); + setEditSerialNumber(scan.serial_number ?? ""); + setEditSpecs( + Object.fromEntries(Object.entries(scan.specs).map(([k, v]) => [k, String(v ?? "")])) + ); + setRowError(null); + } + + function cancelEdit() { + setEditingId(null); + setRowError(null); + } + + async function saveEdit(scan: InventoryScanRecord) { + setSaving(true); + setRowError(null); + try { + const specs: Record = {}; + for (const [k, v] of Object.entries(editSpecs)) { + const asNumber = Number(v); + specs[k] = v !== "" && !Number.isNaN(asNumber) && /^-?\d+(\.\d+)?$/.test(v) ? asNumber : v; + } + + const response = await fetch(`/api/inventory-scans/${scan.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelId: editModelId, + areaId: editAreaId === NO_AREA_VALUE ? null : editAreaId, + assignedEmployeeId: editEmployeeId === NO_EMPLOYEE_VALUE ? null : editEmployeeId, + assetTag: editAssetTag.trim() === "" ? null : editAssetTag.trim(), + serialNumber: editSerialNumber.trim() === "" ? null : editSerialNumber.trim(), + specs, + }), + }); + + let body: unknown; + try { + body = await response.json(); + } catch { + body = null; + } + + if (!response.ok) { + const message = + body !== null && typeof body === "object" && "error" in body + ? String((body as { error: unknown }).error) + : "Request failed"; + setRowError(message); + return; + } + + setEditingId(null); + refetch(); + } catch { + setRowError("Request failed"); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("Xoá bản ghi này?")) return; + const response = await fetch(`/api/inventory-scans/${id}`, { method: "DELETE" }); + if (response.ok) { + refetch(); + } + } + + return ( +
+

Inventory Scans (Move Project)

+ + {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + Ảnh + Category + Model + Asset Tag + Serial Number + Specs + Area + Assigned to + Created At + Actions + + + + {scans.map((scan) => { + const isEditing = editingId === scan.id; + const modelsInCategory = models.filter((m) => m.category_id === scan.category_id); + + return ( + + + {scan.photo_file_id ? ( + + Xem ảnh + + ) : ( + "-" + )} + + {scan.category_name} + + {isEditing ? ( + + ) : ( + `${scan.model_manufacturer} ${scan.model_name}` + )} + + + {isEditing ? ( + setEditAssetTag(e.target.value)} + placeholder="(chưa có)" + /> + ) : ( + scan.asset_tag ?? "-" + )} + + + {isEditing ? ( + setEditSerialNumber(e.target.value)} + placeholder="(chưa có)" + /> + ) : ( + scan.serial_number ?? "-" + )} + + + {isEditing ? ( +
+ {Object.keys(editSpecs).length === 0 ? ( + (không có) + ) : ( + Object.entries(editSpecs).map(([key, value]) => ( +
+ {key} + + setEditSpecs((prev) => ({ ...prev, [key]: e.target.value })) + } + /> +
+ )) + )} +
+ ) : Object.keys(scan.specs).length === 0 ? ( + "-" + ) : ( + Object.entries(scan.specs) + .map(([k, v]) => `${k}: ${v}`) + .join(", ") + )} +
+ + {isEditing ? ( + + ) : ( + scan.area_name ?? "-" + )} + + + {isEditing ? ( + + ) : ( + scan.assigned_employee_name ?? "-" + )} + + {scan.created_at} + + {isEditing ? ( +
+
+ + +
+ {rowError ?

{rowError}

: null} +
+ ) : ( +
+ + +
+ )} +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/nav.tsx b/src/components/nav.tsx index aacbf2e..5fffc41 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -22,6 +22,7 @@ const ENTITY_LINKS = [ { href: "/requests", label: "Requests" }, { href: "/assignments", label: "Assignments" }, { href: "/maintenance-records", label: "Maintenance Records" }, + { href: "/inventory-scans", label: "Inventory Scans (Move)" }, ] as const; export function Nav() {