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
+45 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
import { createOrGetArea } from "./areas";
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired" | "lost";
@@ -15,6 +16,7 @@ export interface AssetItemRecord {
status: AssetItemStatus;
cost: number | null;
cost_currency: string | null;
area_id: string | null;
purchase_order_id: string | null;
// Free-text PO number from an external system -- NOT purchase_order_id
// above (a real FK). See schema.prisma's AssetItem.externalPoNumber
@@ -37,6 +39,7 @@ export interface CreateAssetItemInput {
warrantyEndDate?: Date;
cost?: number | null;
costCurrency?: string | null;
areaId?: string | null;
purchaseOrderId?: string | null;
externalPoNumber?: string | null;
// Only set by importAssetItems() (bulk-loading already-deployed
@@ -112,6 +115,11 @@ const createAssetItemSchema = z.object({
.string({ invalid_type_error: "externalPoNumber must be a string" })
.optional()
.nullable(),
areaId: z
.string({ invalid_type_error: "areaId must be a valid UUID string" })
.uuid("areaId must be a valid UUID string")
.optional()
.nullable(),
});
/**
@@ -139,6 +147,7 @@ export function validateCreateAssetItemInput(input: {
cost?: unknown;
costCurrency?: unknown;
externalPoNumber?: unknown;
areaId?: unknown;
}): ValidationResult {
const result = createAssetItemSchema.safeParse(input);
if (!result.success) {
@@ -158,6 +167,7 @@ function toRecord(row: {
status: AssetItemStatus;
cost: Prisma.Decimal | null;
costCurrency: string | null;
areaId: string | null;
purchaseOrderId: string | null;
externalPoNumber: string | null;
createdAt: Date;
@@ -172,6 +182,7 @@ function toRecord(row: {
status: row.status,
cost: row.cost ? row.cost.toNumber() : null,
cost_currency: row.costCurrency,
area_id: row.areaId,
purchase_order_id: row.purchaseOrderId,
external_po_number: row.externalPoNumber,
created_at: row.createdAt.toISOString(),
@@ -247,6 +258,7 @@ export async function createAssetItem(
warrantyEndDate,
cost: input.cost ?? null,
costCurrency: input.costCurrency ?? null,
areaId: input.areaId ?? null,
purchaseOrderId: input.purchaseOrderId ?? null,
externalPoNumber: input.externalPoNumber ?? null,
...(input.status ? { status: input.status } : {}),
@@ -264,10 +276,15 @@ export async function createAssetItem(
return { ok: false, error: "serialNumber already exists" };
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
// Three FKs can fail here now (modelId, purchaseOrderId, areaId) --
// field_name says which constraint actually fired.
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
}
if (fieldName.includes("area")) {
return { ok: false, error: "areaId does not reference an existing area" };
}
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error;
@@ -314,6 +331,11 @@ export interface ImportAssetItemRow {
// NOT the same as purchaseOrderId above -- see schema.prisma's
// AssetItem.externalPoNumber comment.
externalPoNumber: string | null;
// Area name, resolved/created via src/lib/areas.ts's createOrGetArea()
// (idempotent by name, same as the bot's Area picker) -- not a raw UUID
// column like the other FK-ish fields here, since a hand-edited
// spreadsheet realistically has area names, not this app's internal ids.
areaName: string | null;
}
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
@@ -350,6 +372,7 @@ const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
// 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<Impo
const costCurrency = cleanImportText(row.costCurrency);
const externalPoNumber = cleanImportText(row.externalPoNumber);
// Resolved via createOrGetArea() (idempotent by name -- same helper
// the bot's Area picker uses), not a raw areaId column: a hand-edited
// spreadsheet realistically has area *names*, and this also means a
// never-before-seen area name just gets created rather than rejecting
// the row.
let areaId: string | undefined;
const areaName = cleanImportText(row.areaName);
if (areaName) {
const areaResult = await createOrGetArea({ name: areaName });
if (!areaResult.ok) {
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Area error: ${areaResult.error}` });
continue;
}
areaId = areaResult.value.id;
}
const model = await prisma.assetModel.findFirst({
where: {
manufacturer: { equals: row.manufacturer, mode: "insensitive" },
@@ -606,6 +646,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
...(costCurrency ? { costCurrency } : {}),
...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}),
...(externalPoNumber ? { externalPoNumber } : {}),
...(areaId ? { areaId } : {}),
},
});
updated++;
@@ -623,6 +664,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
costCurrency,
purchaseOrderId: row.purchaseOrderId ?? undefined,
externalPoNumber,
areaId,
});
if (!result.ok) {
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
@@ -644,7 +686,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
*/
export async function exportAssetItemsCsv(): Promise<string> {
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<string> {
"status",
"cost",
"cost_currency",
"area",
"purchase_order_id",
"external_po_number",
];
@@ -673,6 +716,7 @@ export async function exportAssetItemsCsv(): Promise<string> {
row.status,
row.cost ? row.cost.toString() : "",
row.costCurrency ?? "",
row.area?.name ?? "",
row.purchaseOrderId ?? "",
row.externalPoNumber ?? "",
];