Add AssetItem.areaId, show assigned-to + area on /assets, mm/dd/yyyy dates
CI / test (push) Failing after 9s
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:
+45
-1
@@ -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 ?? "",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user