"use client"; import { useEffect, 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; area_id: string | null; purchase_order_id: string | null; external_po_number: string | null; created_at: string; } // Shape returned by GET /api/areas — reused from the office-move feature // (see src/lib/areas.ts). Only id/name needed here. interface AreaRecord { id: string; name: string; } // Shape returned by GET /api/employees — only id/name needed to resolve // the "Assigned To" column/search below. interface EmployeeRecord { id: string; name: string; } // Shape returned by GET /api/assignments — only the fields needed to find // each AssetItem's current (non-returned) assignee. interface AssignmentRecord { employee_id: string; asset_item_id: string | null; returned_at: string | null; } // Shape returned by GET /api/asset-models — only the fields used to populate // the modelId here. interface AssetCategoryRecord { id: string; name: string; } // Shape returned by GET /api/purchase-orders — only the id, used to populate // the purchaseOrderId in the create form. const NO_AREA_VALUE = "none"; // Renders an ISO date(-time) string as mm/dd/yyyy, or "-" if absent. // UTC-based (not local time) to match how purchaseDate/warrantyEndDate are // actually stored -- see src/lib/assets.ts's dateString()/addMonthsClamped() // comments on why this app treats these as UTC-midnight values throughout, // not local-timezone dates. function formatDateMMDDYYYY(isoString: string | null): string { if (!isoString) return "-"; const date = new Date(isoString); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); const yyyy = date.getUTCFullYear(); return `${mm}/${dd}/${yyyy}`; } // "Manufacturer Name" for display -- except a chunk of AssetModel rows // from the 2026-08-24 real-data import already have the manufacturer // baked into `name` (the source file's own "Model" column did this for // some rows, e.g. name="Logitech Group" with manufacturer="Logitech"), // which without this check renders as "Logitech Logitech Group". Skip the // manufacturer prefix when `name` already starts with it, case-insensitive. function formatModelLabel(manufacturer: string, name: string): string { const normalizedName = name.trim().toLowerCase(); const normalizedManufacturer = manufacturer.trim().toLowerCase(); if (normalizedManufacturer && normalizedName.startsWith(normalizedManufacturer)) { return name; } return `${manufacturer} ${name}`; } const ASSET_ITEMS_PAGE_SIZE = 50; export default function AssetsPage() { const { data: assetModels } = useApiList("/api/asset-models"); const { data: assetCategories } = useApiList("/api/asset-categories"); const { data: purchaseOrders } = useApiList("/api/purchase-orders"); const { data: areas } = useApiList("/api/areas"); const { data: employees } = useApiList("/api/employees"); const { data: assignments } = useApiList("/api/assignments"); 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 [areaId, setAreaId] = useState(NO_AREA_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 + pagination state (2026-08-25 -- was client-side-only // filtering over the full ~600+-row table; moved server-side, see // src/lib/assets.ts's listAssetItems() doc comment for why). searchText // is debounced into debouncedSearchText so typing doesn't fire a // request per keystroke. const [searchText, setSearchText] = useState(""); const [debouncedSearchText, setDebouncedSearchText] = useState(""); const [filterStatus, setFilterStatus] = useState("all"); const [filterCategoryId, setFilterCategoryId] = useState("all"); const [filterModelId, setFilterModelId] = useState("all"); const [filterAreaId, setFilterAreaId] = useState("all"); const [page, setPage] = useState(1); const [assetItems, setAssetItems] = useState([]); const [assetItemsTotal, setAssetItemsTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); function refetch() { setRefreshKey((k) => k + 1); } useEffect(() => { const timeout = setTimeout(() => setDebouncedSearchText(searchText.trim()), 300); return () => clearTimeout(timeout); }, [searchText]); // A filter changing invalidates whatever page we were on -- e.g. page 3 // of an unfiltered list may not exist at all once a filter narrows the // result set. Reset to page 1 rather than showing an empty/stale page. useEffect(() => { setPage(1); }, [debouncedSearchText, filterStatus, filterCategoryId, filterModelId, filterAreaId]); useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(null); const params = new URLSearchParams(); params.set("page", String(page)); params.set("pageSize", String(ASSET_ITEMS_PAGE_SIZE)); if (filterStatus !== "all") params.set("status", filterStatus); if (filterCategoryId !== "all") params.set("categoryId", filterCategoryId); if (filterModelId !== "all") params.set("modelId", filterModelId); if (filterAreaId !== "all") params.set("areaId", filterAreaId); if (debouncedSearchText) params.set("search", debouncedSearchText); try { const response = await fetch(`/api/assets?${params.toString()}`); let body: unknown; try { body = await response.json(); } catch { body = null; } if (cancelled) return; 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"; setError(message); setAssetItems([]); setAssetItemsTotal(0); return; } const parsed = body as { items?: AssetItemRecord[]; total?: number }; setAssetItems(Array.isArray(parsed.items) ? parsed.items : []); setAssetItemsTotal(typeof parsed.total === "number" ? parsed.total : 0); } catch { if (!cancelled) { setError("Request failed"); setAssetItems([]); setAssetItemsTotal(0); } } finally { if (!cancelled) setLoading(false); } } void load(); return () => { cancelled = true; }; }, [page, filterStatus, filterCategoryId, filterModelId, filterAreaId, debouncedSearchText, refreshKey]); const totalPages = Math.max(1, Math.ceil(assetItemsTotal / ASSET_ITEMS_PAGE_SIZE)); // 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, formatModelLabel(m.manufacturer, m.name)); } return map; }, [assetModels]); const modelCategoryById = useMemo(() => { const map = new Map(); 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 areaNameById = useMemo(() => { const map = new Map(); for (const a of areas) { map.set(a.id, a.name); } return map; }, [areas]); const employeeNameById = useMemo(() => { const map = new Map(); for (const e of employees) { map.set(e.id, e.name); } return map; }, [employees]); // assetItemId -> employee name, current holder only (returned_at IS // NULL) -- an AssetItem can have multiple historical Assignment rows // over time (see Assignment model's comment), only the active one // matters for "who has this right now". const assigneeNameByAssetItemId = useMemo(() => { const map = new Map(); for (const a of assignments) { if (a.returned_at !== null || !a.asset_item_id) continue; const name = employeeNameById.get(a.employee_id); if (name) map.set(a.asset_item_id, name); } return map; }, [assignments, employeeNameById]); // Filtering + pagination both happen server-side now (see the effect // above) -- `assetItems` here is already exactly the current page's // rows for the active filters, nothing left to compute client-side. 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 hasAreaId = areaId !== NO_AREA_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 } : {}), ...(hasAreaId ? { areaId } : {}), ...(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); setAreaId(NO_AREA_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" />

{assetItemsTotal === 0 ? "0 assets" : `${(page - 1) * ASSET_ITEMS_PAGE_SIZE + 1}-${Math.min( page * ASSET_ITEMS_PAGE_SIZE, assetItemsTotal )} / ${assetItemsTotal} assets`}

{loading ? (

Loading...

) : error ? (

{error}

) : ( # Serial Number Asset Tag Model Area Assigned To Purchase Date Warranty End Date Status Cost Purchase Order ID External PO Number {assetItems.map((assetItem, index) => ( {(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1} {assetItem.serial_number} {assetItem.asset_tag ?? "-"} {modelNameById.get(assetItem.model_id) ?? assetItem.model_id} {assetItem.area_id ? (areaNameById.get(assetItem.area_id) ?? "-") : "-"} {assigneeNameByAssetItemId.get(assetItem.id) ?? "-"} {formatDateMMDDYYYY(assetItem.purchase_date)} {formatDateMMDDYYYY(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 ?? "-"} ))}
)} {!loading && !error && assetItemsTotal > 0 ? (

Page {page} / {totalPages}

) : null}

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), area (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "} stockroom). 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}
); }