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:
co-authored by
Claude Sonnet 5
parent
ae84737620
commit
77dbd15450
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user