diff --git a/prisma/migrations/20260825075218_add_asset_item_area/migration.sql b/prisma/migrations/20260825075218_add_asset_item_area/migration.sql new file mode 100644 index 0000000..6d66ed7 --- /dev/null +++ b/prisma/migrations/20260825075218_add_asset_item_area/migration.sql @@ -0,0 +1,4 @@ +-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md). +ALTER TABLE "asset_items" ADD COLUMN "area_id" UUID; +ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_area_id_fkey" + FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 80a0d8f..8ae421d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,6 +151,17 @@ model AssetItem { // amounts in the millions with room to spare. cost Decimal? @db.Decimal(14, 2) costCurrency String? @map("cost_currency") + // Reuses the Area entity built for InventoryScan/the office-move feature + // (2026-08-25) rather than a free-text location string -- same "share + // the lookup entity across tables" precedent floated for Department. + // Nullable: real-world coverage is thin (the ITAM export's own location + // data was nearly useless -- "Physical location" was the literal string + // "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried + // any signal, and only for the ~71 in_stock items -- an assigned item is + // with a person, not sitting in a stockroom bin, so it has no area here + // by design, not by data gap). + areaId String? @map("area_id") @db.Uuid + area Area? @relation(fields: [areaId], references: [id], onDelete: SetNull) purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) // Free-text PO number from an external system (e.g. the real ITAM @@ -548,6 +559,8 @@ model Area { team String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) inventoryScans InventoryScan[] + // Back-relation for AssetItem.areaId -- see that field's comment. + assetItems AssetItem[] @@map("areas") } diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 486fa20..7e0af6a 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -32,6 +32,7 @@ export async function POST(request: Request): Promise { cost, costCurrency, externalPoNumber, + areaId, } = body as Record; const validation = validateCreateAssetItemInput({ serialNumber, @@ -43,6 +44,7 @@ export async function POST(request: Request): Promise { cost, costCurrency, externalPoNumber, + areaId, }); if (!validation.ok) { diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx index 9fc4bb0..7115a71 100644 --- a/src/app/assets/page.tsx +++ b/src/app/assets/page.tsx @@ -33,11 +33,34 @@ interface AssetItemRecord { 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 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}`; +} + export default function AssetsPage() { const { data: assetItems, @@ -84,6 +125,9 @@ 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(""); @@ -91,6 +135,7 @@ export default function AssetsPage() { 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(""); @@ -105,6 +150,7 @@ export default function AssetsPage() { const [filterStatus, setFilterStatus] = useState("all"); const [filterCategoryId, setFilterCategoryId] = useState("all"); const [filterModelId, setFilterModelId] = useState("all"); + const [filterAreaId, setFilterAreaId] = useState("all"); // modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID // in the table (2026-08-25 -- was previously the literal FK value). @@ -146,6 +192,36 @@ export default function AssetsPage() { } } + 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]); + const filteredAssetItems = useMemo(() => { const query = searchText.trim().toLowerCase(); return assetItems.filter((item) => { @@ -154,13 +230,24 @@ export default function AssetsPage() { if (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) { return false; } + if (filterAreaId !== "all" && item.area_id !== filterAreaId) return false; if (query) { - const haystack = `${item.serial_number} ${item.asset_tag ?? ""}`.toLowerCase(); + const assignee = assigneeNameByAssetItemId.get(item.id) ?? ""; + const haystack = `${item.serial_number} ${item.asset_tag ?? ""} ${assignee}`.toLowerCase(); if (!haystack.includes(query)) return false; } return true; }); - }, [assetItems, searchText, filterStatus, filterModelId, filterCategoryId, modelCategoryById]); + }, [ + assetItems, + searchText, + filterStatus, + filterModelId, + filterCategoryId, + filterAreaId, + modelCategoryById, + assigneeNameByAssetItemId, + ]); const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">( "idle" @@ -214,6 +301,7 @@ export default function AssetsPage() { 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() !== ""; @@ -238,6 +326,7 @@ export default function AssetsPage() { ...(hasAssetTag ? { assetTag } : {}), ...(hasWarrantyEndDate ? { warrantyEndDate } : {}), ...(hasPurchaseOrderId ? { purchaseOrderId } : {}), + ...(hasAreaId ? { areaId } : {}), ...(hasCost ? { cost: Number(cost) } : {}), ...(hasCostCurrency ? { costCurrency } : {}), ...(hasExternalPoNumber ? { externalPoNumber } : {}), @@ -269,6 +358,7 @@ export default function AssetsPage() { setPurchaseDate(""); setWarrantyEndDate(""); setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE); + setAreaId(NO_AREA_VALUE); setCost(""); setCostCurrency(""); setExternalPoNumber(""); @@ -287,7 +377,7 @@ export default function AssetsPage() {
- +
+
+ + +
+

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

@@ -365,13 +472,14 @@ export default function AssetsPage() { Serial Number Asset Tag Model + Area + Assigned To Purchase Date Warranty End Date Status Cost Purchase Order ID External PO Number - Created At @@ -381,8 +489,12 @@ export default function AssetsPage() { {assetItem.serial_number} {assetItem.asset_tag ?? "-"} {modelNameById.get(assetItem.model_id) ?? assetItem.model_id} - {assetItem.purchase_date ?? "-"} - {assetItem.warranty_end_date ?? "-"} + + {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 @@ -391,7 +503,6 @@ export default function AssetsPage() { {assetItem.purchase_order_id ?? "-"} {assetItem.external_po_number ?? "-"} - {assetItem.created_at} ))} @@ -413,8 +524,10 @@ export default function AssetsPage() { 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. + 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.

+
+ + +
+
= { @@ -350,6 +372,7 @@ const ASSET_ITEM_HEADER_ALIASES: Record = { // actual "PO number" column name 1:1, and keeps the two fields // unambiguous from each other by header name alone. externalPoNumber: ["ponumber", "po number", "external po number", "so po"], + areaName: ["area", "khu vuc", "location", "stockroom", "vi tri"], }; export type ParseAssetItemsCsvResult = @@ -422,6 +445,7 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult { cost: get("cost") || null, costCurrency: get("costCurrency") || null, externalPoNumber: get("externalPoNumber") || null, + areaName: get("areaName") || null, }; if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) { continue; // fully blank row @@ -566,6 +590,22 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise { const rows = await prisma.assetItem.findMany({ - include: { model: true }, + include: { model: true, area: true }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); @@ -658,6 +700,7 @@ export async function exportAssetItemsCsv(): Promise { "status", "cost", "cost_currency", + "area", "purchase_order_id", "external_po_number", ]; @@ -673,6 +716,7 @@ export async function exportAssetItemsCsv(): Promise { row.status, row.cost ? row.cost.toString() : "", row.costCurrency ?? "", + row.area?.name ?? "", row.purchaseOrderId ?? "", row.externalPoNumber ?? "", ];