"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 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 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("/api/assets"); const { data: assetModels } = useApiList("/api/asset-models"); const { data: purchaseOrders } = useApiList("/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(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(); 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(null); async function handleImportChange(event: React.ChangeEvent) { 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) { 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 (

Assets

setSearchText(event.target.value)} placeholder="e.g. DANLAP001" className="w-64" />

{filteredAssetItems.length} / {assetItems.length} assets

{loading ? (

Loading...

) : error ? (

{error}

) : ( # Serial Number Asset Tag Model Purchase Date Warranty End Date Status Cost Purchase Order ID External PO Number Created At {filteredAssetItems.map((assetItem, index) => ( {index + 1} {assetItem.serial_number} {assetItem.asset_tag ?? "-"} {modelNameById.get(assetItem.model_id) ?? assetItem.model_id} {assetItem.purchase_date ?? "-"} {assetItem.warranty_end_date ?? "-"} {assetItem.status} {assetItem.cost !== null ? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}` : "-"} {assetItem.purchase_order_id ?? "-"} {assetItem.external_po_number ?? "-"} {assetItem.created_at} ))}
)}

Import / Export CSV

Cột nhận diện theo tên (không phân biệt hoa/thường): serial number,{" "} manufacturer, model (phải khớp Asset Model đã có sẵn ở{" "} /asset-models ), purchase date (không bắt buộc -- cũng nhận order received). Tuỳ chọn: asset tag, warranty end date (bỏ trống thì tự tính từ warranty của model), status/state (nhận{" "} In use → assigned, In stock → in_stock, bỏ trống thì mặc định in_stock), cost, cost currency,{" "} po number (text tự do, khác purchase order id là UUID trong hệ thống). Dòng có asset tag hoặc serial number đã tồn tại sẽ{" "} cập nhật thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ liệu.

{importMessage ? (

{importMessage}

) : null}

New Asset

setSerialNumber(event.target.value)} required />
setAssetTag(event.target.value)} />
setPurchaseDate(event.target.value)} required />
setWarrantyEndDate(event.target.value)} />
setCost(event.target.value)} />
setCostCurrency(event.target.value)} placeholder="e.g. VND, USD" />
setExternalPoNumber(event.target.value)} placeholder="PO number from an external system, if any" />
{submitError ?

{submitError}

: null}
); }