Add AssetItem.areaId, show assigned-to + area on /assets, mm/dd/yyyy dates
CI / test (push) Failing after 9s

AssetItem.areaId reuses the existing Area entity (built for InventoryScan/
the office-move feature) rather than a free-text location string -- same
share-the-lookup-entity precedent floated for Department. Real-world
coverage from the ITAM export is thin: Physical location was the literal
string DAN for all 613 rows (useless), only Stockroom carried signal, and
only for the ~71 in_stock items -- an assigned item is with a person, not
in a stockroom bin, so most rows have no area by design, not by gap.

/assets page: the assigned employee (via the active, non-returned
Assignment) was already in the DB from the 2026-08-24 backfill but never
shown -- added an Assigned To column, folded into the free-text search
too. Added an Area column + filter dropdown. Removed the Created At
column (not actionable) and format Purchase Date/Warranty End Date as
mm/dd/yyyy instead of raw ISO strings.

CSV import/export: area column added (resolved/created via
createOrGetArea(), same idempotent-by-name helper the bots Area picker
uses -- not a raw UUID). Re-ran the 613-row import after this: 0 created,
613 updated, 0 errors -- backfilled area onto the 71 rows with a
Stockroom value (46 IT Cabinet, 21 Stockroom, 4 For allocation), verified
against the source file.

Hand-written migration, no Vitest coverage -- same deviation as the rest
of this branch, see CLAUDE.md. tsc/lint clean.
This commit is contained in:
toanhoang
2026-08-24 21:05:03 -04:00
parent 6863323cce
commit 4063b81f16
5 changed files with 203 additions and 10 deletions
+139 -9
View File
@@ -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 <Select> here (see src/app/asset-models/page.tsx's
// AssetModelRecord for the full shape). category_id added 2026-08-25, used
@@ -73,6 +96,24 @@ interface PurchaseOrderRecord {
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
const NO_PURCHASE_ORDER_VALUE = "none";
// Same sentinel pattern as NO_PURCHASE_ORDER_VALUE above, for the optional
// Area <Select> 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<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const { data: areas } = useApiList<AreaRecord>("/api/areas");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: assignments } = useApiList<AssignmentRecord>("/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<string, string>();
for (const a of areas) {
map.set(a.id, a.name);
}
return map;
}, [areas]);
const employeeNameById = useMemo(() => {
const map = new Map<string, string>();
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<string, string>();
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() {
<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>
<Label htmlFor="search">Search (serial / asset tag / assigned to)</Label>
<Input
id="search"
value={searchText}
@@ -348,6 +438,23 @@ export default function AssetsPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="filterArea">Area</Label>
<Select value={filterAreaId} onValueChange={setFilterAreaId}>
<SelectTrigger id="filterArea" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{areas.map((area) => (
<SelectItem key={area.id} value={area.id}>
{area.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="pb-2 text-sm text-muted-foreground">
{filteredAssetItems.length} / {assetItems.length} assets
</p>
@@ -365,13 +472,14 @@ export default function AssetsPage() {
<TableHead>Serial Number</TableHead>
<TableHead>Asset Tag</TableHead>
<TableHead>Model</TableHead>
<TableHead>Area</TableHead>
<TableHead>Assigned To</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>
@@ -381,8 +489,12 @@ export default function AssetsPage() {
<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.area_id ? (areaNameById.get(assetItem.area_id) ?? "-") : "-"}
</TableCell>
<TableCell>{assigneeNameByAssetItemId.get(assetItem.id) ?? "-"}</TableCell>
<TableCell>{formatDateMMDDYYYY(assetItem.purchase_date)}</TableCell>
<TableCell>{formatDateMMDDYYYY(assetItem.warranty_end_date)}</TableCell>
<TableCell>{assetItem.status}</TableCell>
<TableCell>
{assetItem.cost !== null
@@ -391,7 +503,6 @@ export default function AssetsPage() {
</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
<TableCell>{assetItem.created_at}</TableCell>
</TableRow>
))}
</TableBody>
@@ -413,8 +524,10 @@ export default function AssetsPage() {
<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.
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
<code>stockroom</code>). 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">
@@ -525,6 +638,23 @@ export default function AssetsPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="areaId">Area (optional)</Label>
<Select value={areaId} onValueChange={setAreaId}>
<SelectTrigger id="areaId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
{areas.map((area) => (
<SelectItem key={area.id} value={area.id}>
{area.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cost">Cost (optional)</Label>
<Input