Files
ubi-dan-itam/src/app/assets/page.tsx
T
toanhoang 6863323cce
CI / test (push) Failing after 8s
Add AssetItemStatus.lost, category filter on /assets page
lost added via ALTER TYPE ... ADD VALUE (hand-written migration, stays an
enum -- see the schema.prisma comment on why: status values are read
directly by business logic like createAssignment()s in_stock gate, a
lookup table would not actually decouple that). IMPORT_STATUS_MAP gains
lost/missing -> lost mappings for CSV import/re-sync.

/assets page gets a Category filter dropdown (alongside the existing
Status/Model/search filters) that also narrows the Model dropdown to that
category -- with 135 models after the 2026-08-24 real-data import, an
unfiltered Model list is unwieldy. Picking a Category resets the Model
filter if the currently-selected model does not belong to it.

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 20:41:50 -04:00

570 lines
21 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" | "lost";
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). category_id added 2026-08-25, used
// to narrow the Model filter dropdown once a Category is picked -- with
// 135 models after the 2026-08-24 real-data import, an unfiltered list is
// unwieldy.
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
}
// Shape returned by GET /api/asset-categories — only id/name, used to
// populate the Category filter <Select> here.
interface AssetCategoryRecord {
id: 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: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
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 [filterCategoryId, setFilterCategoryId] = 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 modelCategoryById = useMemo(() => {
const map = new Map<string, string>();
for (const m of assetModels) {
map.set(m.id, m.category_id);
}
return map;
}, [assetModels]);
// Model dropdown options narrow to the picked Category -- with 135
// models after the 2026-08-24 real-data import, showing all of them
// regardless of category is unwieldy.
const modelsForCategoryFilter = useMemo(() => {
if (filterCategoryId === "all") return assetModels;
return assetModels.filter((m) => m.category_id === filterCategoryId);
}, [assetModels, filterCategoryId]);
function handleFilterCategoryChange(categoryId: string) {
setFilterCategoryId(categoryId);
// A previously-picked Model may not belong to the newly-picked
// Category -- reset rather than silently keep filtering on a model
// that's no longer shown in the (now-narrowed) dropdown.
if (
filterModelId !== "all" &&
categoryId !== "all" &&
modelCategoryById.get(filterModelId) !== categoryId
) {
setFilterModelId("all");
}
}
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 (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) {
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, filterCategoryId, modelCategoryById]);
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>
<SelectItem value="lost">lost</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="filterCategory">Category</Label>
<Select value={filterCategoryId} onValueChange={handleFilterCategoryChange}>
<SelectTrigger id="filterCategory" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</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>
{modelsForCategoryFilter.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>
);
}