CI / test (push) Failing after 8s
Model ID column now shows "Manufacturer Name" (looked up from the already-fetched asset-models list) instead of the raw model_id UUID. The ID column shows a 1-based row index within the current filtered view instead of the AssetItem UUID -- the id itself was never actionable in the UI (no per-row link/detail view exists), just clutter at this scale (~600+ rows after the real-data import). Added a filter bar: free-text search (matches serial_number or asset_tag, case-insensitive substring), status dropdown, model dropdown -- all client-side (assetItems is already the full list via useApiList, and filtering ~600 rows in memory is trivial, no need for a server-side filtered endpoint at this scale). tsc/lint clean, no Vitest coverage -- same deviation as the rest of this branch, see CLAUDE.md.
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, type FormEvent } from "react";
|
|
import { useApiList } from "@/hooks/use-api-list";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
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/assets — snake_case per this repo's
|
|
// API-boundary convention (see src/lib/assets.ts's toRecord()).
|
|
interface AssetItemRecord {
|
|
id: string;
|
|
serial_number: string;
|
|
asset_tag: string | null;
|
|
model_id: string;
|
|
purchase_date: string | null;
|
|
warranty_end_date: string | null;
|
|
status: "in_stock" | "assigned" | "repair" | "retired";
|
|
cost: number | null;
|
|
cost_currency: string | null;
|
|
purchase_order_id: string | null;
|
|
external_po_number: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/asset-models — only the fields used to populate
|
|
// the modelId <Select> here (see src/app/asset-models/page.tsx's
|
|
// AssetModelRecord for the full shape).
|
|
interface AssetModelRecord {
|
|
id: string;
|
|
manufacturer: string;
|
|
name: string;
|
|
}
|
|
|
|
// Shape returned by GET /api/purchase-orders — only the id, used to populate
|
|
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
|
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
|
// PurchaseOrderRecord for the full shape), so the id itself is shown as the
|
|
// label.
|
|
interface PurchaseOrderRecord {
|
|
id: string;
|
|
}
|
|
|
|
// Radix <Select.Item> can't take an empty-string value, so "no purchase
|
|
// order" is represented by this sentinel in the form state and translated to
|
|
// "omit the key" on submit — same pattern as purchase-orders page's
|
|
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
|
|
const NO_PURCHASE_ORDER_VALUE = "none";
|
|
|
|
export default function AssetsPage() {
|
|
const {
|
|
data: assetItems,
|
|
loading,
|
|
error,
|
|
refetch,
|
|
} = useApiList<AssetItemRecord>("/api/assets");
|
|
|
|
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
|
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
|
|
|
const [serialNumber, setSerialNumber] = useState("");
|
|
const [assetTag, setAssetTag] = useState("");
|
|
const [modelId, setModelId] = useState("");
|
|
const [purchaseDate, setPurchaseDate] = useState("");
|
|
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
|
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
|
const [cost, setCost] = useState("");
|
|
const [costCurrency, setCostCurrency] = useState("");
|
|
const [externalPoNumber, setExternalPoNumber] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
// Filter/search state -- client-side only (assetItems is already the
|
|
// full list from useApiList, and this app's actual scale, ~600 rows
|
|
// after the 2026-08-24 real-data import, is well within "just filter
|
|
// in memory" territory; no need for a server-side filtered endpoint).
|
|
const [searchText, setSearchText] = useState("");
|
|
const [filterStatus, setFilterStatus] = useState("all");
|
|
const [filterModelId, setFilterModelId] = useState("all");
|
|
|
|
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
|
// in the table (2026-08-25 -- was previously the literal FK value).
|
|
const modelNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const m of assetModels) {
|
|
map.set(m.id, `${m.manufacturer} ${m.name}`);
|
|
}
|
|
return map;
|
|
}, [assetModels]);
|
|
|
|
const filteredAssetItems = useMemo(() => {
|
|
const query = searchText.trim().toLowerCase();
|
|
return assetItems.filter((item) => {
|
|
if (filterStatus !== "all" && item.status !== filterStatus) return false;
|
|
if (filterModelId !== "all" && item.model_id !== filterModelId) return false;
|
|
if (query) {
|
|
const haystack = `${item.serial_number} ${item.asset_tag ?? ""}`.toLowerCase();
|
|
if (!haystack.includes(query)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [assetItems, searchText, filterStatus, filterModelId]);
|
|
|
|
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
|
"idle"
|
|
);
|
|
const [importMessage, setImportMessage] = useState<string | null>(null);
|
|
|
|
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = ""; // allow re-selecting the same file name after an error
|
|
if (!file) return;
|
|
|
|
setImportStatus("uploading");
|
|
setImportMessage(null);
|
|
try {
|
|
const text = await file.text();
|
|
const res = await fetch("/api/assets/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "text/csv" },
|
|
body: text,
|
|
});
|
|
const body = await res.json();
|
|
if (!res.ok) {
|
|
setImportStatus("error");
|
|
setImportMessage(body.error ?? "Import thất bại");
|
|
return;
|
|
}
|
|
const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0;
|
|
setImportStatus(errorCount > 0 ? "error" : "done");
|
|
const errorSummary =
|
|
errorCount > 0
|
|
? " Lỗi: " +
|
|
(body.errors as { row: number; serialNumber: string; error: string }[])
|
|
.slice(0, 5)
|
|
.map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`)
|
|
.join("; ") +
|
|
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
|
|
: "";
|
|
setImportMessage(`Tạo mới ${body.created}, cập nhật ${body.updated} asset.` + errorSummary);
|
|
refetch();
|
|
} catch {
|
|
setImportStatus("error");
|
|
setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setSubmitting(true);
|
|
setSubmitError(null);
|
|
|
|
const hasAssetTag = assetTag.trim() !== "";
|
|
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
|
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
|
const hasCost = cost.trim() !== "";
|
|
const hasCostCurrency = costCurrency.trim() !== "";
|
|
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
|
|
|
if (hasCost && Number.isNaN(Number(cost))) {
|
|
setSubmitError("Cost must be a number");
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/assets", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
serialNumber,
|
|
modelId,
|
|
purchaseDate,
|
|
// Omit optional keys entirely when left blank, rather than
|
|
// sending null — matches the create-form pattern used on other
|
|
// pages (e.g. purchase-orders page's receivedAt/receivedById).
|
|
...(hasAssetTag ? { assetTag } : {}),
|
|
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
|
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
|
...(hasCost ? { cost: Number(cost) } : {}),
|
|
...(hasCostCurrency ? { costCurrency } : {}),
|
|
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
|
}),
|
|
});
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
body = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message =
|
|
body !== null &&
|
|
typeof body === "object" &&
|
|
"error" in body &&
|
|
typeof (body as { error: unknown }).error === "string"
|
|
? (body as { error: string }).error
|
|
: "Request failed";
|
|
setSubmitError(message);
|
|
return;
|
|
}
|
|
|
|
setSerialNumber("");
|
|
setAssetTag("");
|
|
setModelId("");
|
|
setPurchaseDate("");
|
|
setWarrantyEndDate("");
|
|
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
|
setCost("");
|
|
setCostCurrency("");
|
|
setExternalPoNumber("");
|
|
refetch();
|
|
} catch {
|
|
setSubmitError("Request failed");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8 p-6">
|
|
<h1 className="text-xl font-semibold">Assets</h1>
|
|
|
|
<section className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="search">Search (serial / asset tag)</Label>
|
|
<Input
|
|
id="search"
|
|
value={searchText}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
placeholder="e.g. DANLAP001"
|
|
className="w-64"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterStatus">Status</Label>
|
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
|
<SelectTrigger id="filterStatus" className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="in_stock">in_stock</SelectItem>
|
|
<SelectItem value="assigned">assigned</SelectItem>
|
|
<SelectItem value="repair">repair</SelectItem>
|
|
<SelectItem value="retired">retired</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="filterModel">Model</Label>
|
|
<Select value={filterModelId} onValueChange={setFilterModelId}>
|
|
<SelectTrigger id="filterModel" className="w-64">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
{assetModels.map((assetModel) => (
|
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
|
{assetModel.manufacturer} {assetModel.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="pb-2 text-sm text-muted-foreground">
|
|
{filteredAssetItems.length} / {assetItems.length} assets
|
|
</p>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
) : error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>#</TableHead>
|
|
<TableHead>Serial Number</TableHead>
|
|
<TableHead>Asset Tag</TableHead>
|
|
<TableHead>Model</TableHead>
|
|
<TableHead>Purchase Date</TableHead>
|
|
<TableHead>Warranty End Date</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Cost</TableHead>
|
|
<TableHead>Purchase Order ID</TableHead>
|
|
<TableHead>External PO Number</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredAssetItems.map((assetItem, index) => (
|
|
<TableRow key={assetItem.id}>
|
|
<TableCell>{index + 1}</TableCell>
|
|
<TableCell>{assetItem.serial_number}</TableCell>
|
|
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
|
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
|
<TableCell>{assetItem.purchase_date ?? "-"}</TableCell>
|
|
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
|
|
<TableCell>{assetItem.status}</TableCell>
|
|
<TableCell>
|
|
{assetItem.cost !== null
|
|
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
|
|
: "-"}
|
|
</TableCell>
|
|
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
|
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
|
<TableCell>{assetItem.created_at}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</section>
|
|
|
|
<section className="flex max-w-2xl flex-col gap-3">
|
|
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
|
|
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
|
|
<a href="/asset-models" className="underline">
|
|
/asset-models
|
|
</a>
|
|
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
|
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
|
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
|
<code>In use</code> → assigned, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
|
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
|
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
|
hệ thống). Dòng có <code>asset tag</code> hoặc <code>serial number</code> đã tồn tại sẽ{" "}
|
|
<b>cập nhật</b> thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ liệu.
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<a href="/api/assets/export">
|
|
<Button type="button" variant="outline">
|
|
Export CSV
|
|
</Button>
|
|
</a>
|
|
|
|
<input
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
onChange={handleImportChange}
|
|
disabled={importStatus === "uploading"}
|
|
className="hidden"
|
|
id="assets-csv-input"
|
|
/>
|
|
<Button asChild>
|
|
<label htmlFor="assets-csv-input" className="cursor-pointer">
|
|
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
|
|
</label>
|
|
</Button>
|
|
</div>
|
|
{importMessage ? (
|
|
<p
|
|
className={
|
|
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
|
|
}
|
|
>
|
|
{importMessage}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="flex max-w-sm flex-col gap-4">
|
|
<h2 className="text-lg font-semibold">New Asset</h2>
|
|
|
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="serialNumber">Serial Number</Label>
|
|
<Input
|
|
id="serialNumber"
|
|
value={serialNumber}
|
|
onChange={(event) => setSerialNumber(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="assetTag">Asset Tag (optional)</Label>
|
|
<Input
|
|
id="assetTag"
|
|
value={assetTag}
|
|
onChange={(event) => setAssetTag(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="modelId">Model</Label>
|
|
<Select value={modelId} onValueChange={setModelId} required>
|
|
<SelectTrigger id="modelId" className="w-full">
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{assetModels.map((assetModel) => (
|
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
|
{assetModel.manufacturer} {assetModel.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="purchaseDate">Purchase Date</Label>
|
|
<Input
|
|
id="purchaseDate"
|
|
type="date"
|
|
value={purchaseDate}
|
|
onChange={(event) => setPurchaseDate(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="warrantyEndDate">Warranty End Date (optional)</Label>
|
|
<Input
|
|
id="warrantyEndDate"
|
|
type="date"
|
|
value={warrantyEndDate}
|
|
onChange={(event) => setWarrantyEndDate(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
|
|
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
|
|
<SelectTrigger id="purchaseOrderId" className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
|
|
{purchaseOrders.map((purchaseOrder) => (
|
|
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
|
|
{purchaseOrder.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="cost">Cost (optional)</Label>
|
|
<Input
|
|
id="cost"
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={cost}
|
|
onChange={(event) => setCost(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="costCurrency">Cost Currency (optional)</Label>
|
|
<Input
|
|
id="costCurrency"
|
|
value={costCurrency}
|
|
onChange={(event) => setCostCurrency(event.target.value)}
|
|
placeholder="e.g. VND, USD"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="externalPoNumber">External PO Number (optional)</Label>
|
|
<Input
|
|
id="externalPoNumber"
|
|
value={externalPoNumber}
|
|
onChange={(event) => setExternalPoNumber(event.target.value)}
|
|
placeholder="PO number from an external system, if any"
|
|
/>
|
|
</div>
|
|
|
|
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
|
|
|
<Button type="submit" disabled={submitting}>
|
|
{submitting ? "Creating..." : "Create Asset"}
|
|
</Button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|