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 <[email protected]>
This commit is contained in:
toanhoang
2026-08-19 22:07:25 -04:00
co-authored by Claude Sonnet 5
parent ae84737620
commit 77dbd15450
2 changed files with 348 additions and 0 deletions
+347
View File
@@ -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<string, unknown>;
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<InventoryScanRecord>(
"/api/inventory-scans"
);
const { data: areas } = useApiList<AreaRecord>("/api/areas");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: models } = useApiList<AssetModelRecord>("/api/asset-models");
const [editingId, setEditingId] = useState<string | null>(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<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [rowError, setRowError] = useState<string | null>(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<string, unknown> = {};
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 (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-xl font-semibold">Inventory Scans (Move Project)</h1>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ảnh</TableHead>
<TableHead>Category</TableHead>
<TableHead>Model</TableHead>
<TableHead>Asset Tag</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead>Specs</TableHead>
<TableHead>Area</TableHead>
<TableHead>Assigned to</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scans.map((scan) => {
const isEditing = editingId === scan.id;
const modelsInCategory = models.filter((m) => m.category_id === scan.category_id);
return (
<TableRow key={scan.id}>
<TableCell>
{scan.photo_file_id ? (
<a
href={`/api/inventory-scans/${scan.id}/photo`}
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
Xem ảnh
</a>
) : (
"-"
)}
</TableCell>
<TableCell>{scan.category_name}</TableCell>
<TableCell>
{isEditing ? (
<Select value={editModelId} onValueChange={setEditModelId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelsInCategory.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.manufacturer} {m.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
`${scan.model_manufacturer} ${scan.model_name}`
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
className="h-8 w-28"
value={editAssetTag}
onChange={(e) => setEditAssetTag(e.target.value)}
placeholder="(chưa có)"
/>
) : (
scan.asset_tag ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
className="h-8 w-32"
value={editSerialNumber}
onChange={(e) => setEditSerialNumber(e.target.value)}
placeholder="(chưa có)"
/>
) : (
scan.serial_number ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<div className="flex flex-col gap-1">
{Object.keys(editSpecs).length === 0 ? (
<span className="text-muted-foreground text-xs">(không có)</span>
) : (
Object.entries(editSpecs).map(([key, value]) => (
<div key={key} className="flex items-center gap-1">
<span className="w-16 text-xs text-muted-foreground">{key}</span>
<Input
className="h-7"
value={value}
onChange={(e) =>
setEditSpecs((prev) => ({ ...prev, [key]: e.target.value }))
}
/>
</div>
))
)}
</div>
) : Object.keys(scan.specs).length === 0 ? (
"-"
) : (
Object.entries(scan.specs)
.map(([k, v]) => `${k}: ${v}`)
.join(", ")
)}
</TableCell>
<TableCell>
{isEditing ? (
<Select value={editAreaId} onValueChange={setEditAreaId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
{areas.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
scan.area_name ?? "-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_EMPLOYEE_VALUE}>None</SelectItem>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
scan.assigned_employee_name ?? "-"
)}
</TableCell>
<TableCell>{scan.created_at}</TableCell>
<TableCell>
{isEditing ? (
<div className="flex flex-col gap-1">
<div className="flex gap-1">
<Button size="sm" disabled={saving} onClick={() => saveEdit(scan)}>
Save
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit}>
Cancel
</Button>
</div>
{rowError ? <p className="text-xs text-destructive">{rowError}</p> : null}
</div>
) : (
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => startEdit(scan)}>
Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(scan.id)}
>
Delete
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
);
}
+1
View File
@@ -22,6 +22,7 @@ const ENTITY_LINKS = [
{ href: "/requests", label: "Requests" }, { href: "/requests", label: "Requests" },
{ href: "/assignments", label: "Assignments" }, { href: "/assignments", label: "Assignments" },
{ href: "/maintenance-records", label: "Maintenance Records" }, { href: "/maintenance-records", label: "Maintenance Records" },
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
] as const; ] as const;
export function Nav() { export function Nav() {