Add AssetItem.assetTag, status-aware bulk import, nullable purchaseDate
CI / test (push) Failing after 8s
CI / test (push) Failing after 8s
Prep work for importing a real company ITAM export (613 real assets): - AssetItem.assetTag (nullable, unique) -- company physical tag, was only on InventoryScan (move feature) before, now on the actual asset record. - AssetItem.purchaseDate is now nullable (was required) -- some legacy rows genuinely have no known purchase date. Manual /assets create form still requires it in the HTML input; this only relaxes the DB/import path for historical data. - importAssetItems() accepts a status column now (free-text "In use"/ "In stock"/etc mapped via IMPORT_STATUS_MAP), unlike manual creation which always defaults in_stock -- bulk-imported inventory is usually already deployed, not freshly purchased, so forcing in_stock would misrepresent reality. Hand-written migrations (asset_tag, purchase_date nullable), no Vitest coverage -- same deviation as the rest of this branch, see CLAUDE.md. tsc/lint clean. Actually imported 613/613 real AssetItem rows from the export with 0 errors after this (not committed here -- thats data, not code; see CLAUDE.md for the seeding/import process and whats deliberately out of scope this round: Cost, PO number, employee Assignment).
This commit is contained in:
@@ -22,10 +22,11 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||
const { serialNumber, assetTag, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetItemInput({
|
||||
serialNumber,
|
||||
assetTag,
|
||||
modelId,
|
||||
purchaseDate,
|
||||
warrantyEndDate,
|
||||
|
||||
+23
-4
@@ -26,8 +26,9 @@ import {
|
||||
interface AssetItemRecord {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
asset_tag: string | null;
|
||||
model_id: string;
|
||||
purchase_date: string;
|
||||
purchase_date: string | null;
|
||||
warranty_end_date: string | null;
|
||||
status: "in_stock" | "assigned" | "repair" | "retired";
|
||||
purchase_order_id: string | null;
|
||||
@@ -70,6 +71,7 @@ export default function AssetsPage() {
|
||||
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
||||
|
||||
const [serialNumber, setSerialNumber] = useState("");
|
||||
const [assetTag, setAssetTag] = useState("");
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
||||
@@ -126,6 +128,7 @@ export default function AssetsPage() {
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const hasAssetTag = assetTag.trim() !== "";
|
||||
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
||||
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
||||
|
||||
@@ -140,6 +143,7 @@ export default function AssetsPage() {
|
||||
// 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 } : {}),
|
||||
}),
|
||||
@@ -165,6 +169,7 @@ export default function AssetsPage() {
|
||||
}
|
||||
|
||||
setSerialNumber("");
|
||||
setAssetTag("");
|
||||
setModelId("");
|
||||
setPurchaseDate("");
|
||||
setWarrantyEndDate("");
|
||||
@@ -192,6 +197,7 @@ export default function AssetsPage() {
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Serial Number</TableHead>
|
||||
<TableHead>Asset Tag</TableHead>
|
||||
<TableHead>Model ID</TableHead>
|
||||
<TableHead>Purchase Date</TableHead>
|
||||
<TableHead>Warranty End Date</TableHead>
|
||||
@@ -205,8 +211,9 @@ export default function AssetsPage() {
|
||||
<TableRow key={assetItem.id}>
|
||||
<TableCell>{assetItem.id}</TableCell>
|
||||
<TableCell>{assetItem.serial_number}</TableCell>
|
||||
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
||||
<TableCell>{assetItem.model_id}</TableCell>
|
||||
<TableCell>{assetItem.purchase_date}</TableCell>
|
||||
<TableCell>{assetItem.purchase_date ?? "-"}</TableCell>
|
||||
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
|
||||
<TableCell>{assetItem.status}</TableCell>
|
||||
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
||||
@@ -226,8 +233,11 @@ export default function AssetsPage() {
|
||||
<a href="/asset-models" className="underline">
|
||||
/asset-models
|
||||
</a>
|
||||
), <code>purchase date</code> (bắt buộc), <code>warranty end date</code>,{" "}
|
||||
<code>purchase order id</code> (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model).
|
||||
), <code>purchase date</code> (bắt buộc -- cũng nhận <code>order received</code>).
|
||||
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
||||
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
||||
<code>In use</code> → assigned, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
||||
in_stock), <code>purchase order id</code>.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a href="/api/assets/export">
|
||||
@@ -275,6 +285,15 @@ export default function AssetsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assetTag">Asset Tag (optional)</Label>
|
||||
<Input
|
||||
id="assetTag"
|
||||
value={assetTag}
|
||||
onChange={(event) => setAssetTag(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="modelId">Model</Label>
|
||||
<Select value={modelId} onValueChange={setModelId} required>
|
||||
|
||||
+149
-38
@@ -8,8 +8,9 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
||||
export interface AssetItemRecord {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
asset_tag: string | null;
|
||||
model_id: string;
|
||||
purchase_date: string;
|
||||
purchase_date: string | null;
|
||||
warranty_end_date: string | null;
|
||||
status: AssetItemStatus;
|
||||
purchase_order_id: string | null;
|
||||
@@ -18,10 +19,25 @@ export interface AssetItemRecord {
|
||||
|
||||
export interface CreateAssetItemInput {
|
||||
serialNumber: string;
|
||||
assetTag?: string | null;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
// Optional as of 2026-08-24 (was required) -- some bulk-imported legacy
|
||||
// rows have no known purchase date at all. The manual /assets create
|
||||
// form still marks its date input required (a NEW purchase should
|
||||
// always have a known date); this only relaxes the underlying type for
|
||||
// historical/imported data. See schema.prisma's AssetItem.purchaseDate
|
||||
// comment.
|
||||
purchaseDate?: Date;
|
||||
warrantyEndDate?: Date;
|
||||
purchaseOrderId?: string | null;
|
||||
// Only set by importAssetItems() (bulk-loading already-deployed
|
||||
// inventory from a real export -- see docs/asset-license-management.md's
|
||||
// 2026-08-24 section). The manual /assets create form never sends this,
|
||||
// so a plain createAssetItem() call keeps defaulting to "in_stock" same
|
||||
// as before this field existed -- see validateCreateAssetItemInput's
|
||||
// comment for why that stays the rule for *new* purchases entered one at
|
||||
// a time.
|
||||
status?: AssetItemStatus;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -48,6 +64,10 @@ function dateString(message: string) {
|
||||
|
||||
const createAssetItemSchema = z.object({
|
||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||
assetTag: z
|
||||
.string({ invalid_type_error: "assetTag must be a string" })
|
||||
.optional()
|
||||
.nullable(),
|
||||
// Required FK to AssetModel — replaced the free-text `model` String (see
|
||||
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
|
||||
// (2)" section). Only a UUID *format* check here, whether it references a
|
||||
@@ -57,7 +77,7 @@ const createAssetItemSchema = z.object({
|
||||
modelId: z
|
||||
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
||||
.uuid("modelId is required and must be a valid UUID string"),
|
||||
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
||||
purchaseDate: dateString("purchaseDate must be a valid date string").optional(),
|
||||
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||
// every AssetItem goes through a formal PO). Only a UUID *format* check
|
||||
@@ -74,15 +94,22 @@ const createAssetItemSchema = z.object({
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
||||
* be a valid date string, warrantyEndDate is optional but must be a valid
|
||||
* date string if present. purchaseOrderId is optional/nullable but must be
|
||||
* a valid UUID if present. status is not accepted here — new assets always
|
||||
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
||||
* states are set via other operations, not at creation.
|
||||
* non-empty string, assetTag is optional/nullable free text (no format
|
||||
* check -- company tag schemes vary), modelId must be a valid UUID string,
|
||||
* purchaseDate must be a valid date string, warrantyEndDate is optional
|
||||
* but must be a valid date string if present. purchaseOrderId is
|
||||
* optional/nullable but must be a valid UUID if present. status is not
|
||||
* accepted here — new assets entered one at a time via the manual /assets
|
||||
* form always start "in_stock" (same pattern as Request always starting
|
||||
* "Draft"). importAssetItems() below is the one exception: it calls
|
||||
* createAssetItem() directly (bypassing this validator) with a real status
|
||||
* resolved from the import file, because it's loading already-deployed
|
||||
* inventory, not new purchases -- see docs/asset-license-management.md's
|
||||
* 2026-08-24 section.
|
||||
*/
|
||||
export function validateCreateAssetItemInput(input: {
|
||||
serialNumber?: unknown;
|
||||
assetTag?: unknown;
|
||||
modelId?: unknown;
|
||||
purchaseDate?: unknown;
|
||||
warrantyEndDate?: unknown;
|
||||
@@ -99,8 +126,9 @@ export function validateCreateAssetItemInput(input: {
|
||||
function toRecord(row: {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
assetTag: string | null;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
purchaseDate: Date | null;
|
||||
warrantyEndDate: Date | null;
|
||||
status: AssetItemStatus;
|
||||
purchaseOrderId: string | null;
|
||||
@@ -109,8 +137,9 @@ function toRecord(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
serial_number: row.serialNumber,
|
||||
asset_tag: row.assetTag,
|
||||
model_id: row.modelId,
|
||||
purchase_date: row.purchaseDate.toISOString(),
|
||||
purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null,
|
||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||
status: row.status,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
@@ -171,7 +200,7 @@ export async function createAssetItem(
|
||||
// added -- this just also opportunistically uses the model row if it's
|
||||
// there.
|
||||
let warrantyEndDate = input.warrantyEndDate ?? null;
|
||||
if (!warrantyEndDate) {
|
||||
if (!warrantyEndDate && input.purchaseDate) {
|
||||
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
|
||||
if (model?.warrantyMonths) {
|
||||
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
|
||||
@@ -181,15 +210,23 @@ export async function createAssetItem(
|
||||
const created = await prisma.assetItem.create({
|
||||
data: {
|
||||
serialNumber: input.serialNumber,
|
||||
assetTag: input.assetTag ?? null,
|
||||
modelId: input.modelId,
|
||||
purchaseDate: input.purchaseDate,
|
||||
purchaseDate: input.purchaseDate ?? null,
|
||||
warrantyEndDate,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// serialNumber and assetTag are both unique now -- error.meta.target
|
||||
// says which constraint actually fired.
|
||||
const target = Array.isArray(error.meta?.target) ? error.meta.target.join(",") : "";
|
||||
if (target.includes("asset_tag")) {
|
||||
return { ok: false, error: "assetTag already exists" };
|
||||
}
|
||||
return { ok: false, error: "serialNumber already exists" };
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
@@ -222,20 +259,47 @@ export async function listAssetItems(): Promise<AssetItemRecord[]> {
|
||||
|
||||
export interface ImportAssetItemRow {
|
||||
serialNumber: string;
|
||||
assetTag: string | null;
|
||||
manufacturer: string;
|
||||
modelName: string;
|
||||
purchaseDate: string;
|
||||
warrantyEndDate: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
// Raw text from the CSV's status-ish column (e.g. a real ServiceNow
|
||||
// export's "State": "In use"/"In stock") -- translated to a real
|
||||
// AssetItemStatus by statusFromImportText() in importAssetItems() below,
|
||||
// not here (parsing stays dumb string-in/string-out, same as every other
|
||||
// field on this row).
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
|
||||
serialNumber: ["serialnumber", "serial number", "serial", "sn"],
|
||||
assetTag: ["assettag", "asset tag", "tag", "matag"],
|
||||
manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"],
|
||||
modelName: ["model", "modelname", "model name", "ten model"],
|
||||
purchaseDate: ["purchasedate", "purchase date", "ngaymua", "ngay mua"],
|
||||
warrantyEndDate: ["warrantyenddate", "warranty end date", "warranty", "baohanh", "bao hanh"],
|
||||
purchaseDate: [
|
||||
"purchasedate",
|
||||
"purchase date",
|
||||
"ngaymua",
|
||||
"ngay mua",
|
||||
// "Order received" is the closest proxy for purchase date in a
|
||||
// ServiceNow-style export -- there's no cleaner "date we bought this"
|
||||
// field there (see docs/asset-license-management.md's 2026-08-24
|
||||
// section for why "Assigned Date" is NOT used here -- that's when it
|
||||
// was handed to whoever has it now, a different date entirely).
|
||||
"order received",
|
||||
],
|
||||
warrantyEndDate: [
|
||||
"warrantyenddate",
|
||||
"warranty end date",
|
||||
"warranty",
|
||||
"baohanh",
|
||||
"bao hanh",
|
||||
"warranty expiration",
|
||||
],
|
||||
purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"],
|
||||
status: ["status", "state", "tinh trang"],
|
||||
};
|
||||
|
||||
export type ParseAssetItemsCsvResult =
|
||||
@@ -245,12 +309,14 @@ export type ParseAssetItemsCsvResult =
|
||||
/**
|
||||
* Parses a CSV export into rows ready for importAssetItems(). Unlike
|
||||
* src/lib/expected-assets.ts's parser (a staging/lookup table with no hard
|
||||
* NOT NULL requirements), the 4 fields an AssetItem actually needs at
|
||||
* creation -- serialNumber/manufacturer/modelName/purchaseDate -- must all
|
||||
* be present as recognized header columns, or this fails fast on the whole
|
||||
* file rather than silently producing rows importAssetItems() can never
|
||||
* create. Individual rows with one of those 4 fields blank still aren't
|
||||
* rejected here though -- that's importAssetItems()'s per-row error, so a
|
||||
* NOT NULL requirements), the fields an AssetItem actually needs at
|
||||
* creation -- serialNumber/manufacturer/modelName (purchaseDate is
|
||||
* optional as of 2026-08-24, see schema.prisma's AssetItem.purchaseDate
|
||||
* comment) -- must all be present as recognized header columns, or this
|
||||
* fails fast on the whole file rather than silently producing rows
|
||||
* importAssetItems() can never create. Individual rows with one of those
|
||||
* required fields blank still aren't rejected here though -- that's
|
||||
* importAssetItems()'s per-row error, so a
|
||||
* typo in row 200 doesn't block rows 1-199.
|
||||
*/
|
||||
export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
@@ -270,12 +336,11 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
columnIndex[field] = idx;
|
||||
}
|
||||
}
|
||||
const required: (keyof ImportAssetItemRow)[] = [
|
||||
"serialNumber",
|
||||
"manufacturer",
|
||||
"modelName",
|
||||
"purchaseDate",
|
||||
];
|
||||
// purchaseDate is NOT required here (as of 2026-08-24 -- see
|
||||
// schema.prisma's AssetItem.purchaseDate comment): some legacy rows in
|
||||
// a real bulk import genuinely have no known purchase date, and that's
|
||||
// fine to import with a null purchaseDate rather than skipping the row.
|
||||
const required: (keyof ImportAssetItemRow)[] = ["serialNumber", "manufacturer", "modelName"];
|
||||
const missing = required.filter((field) => columnIndex[field] === undefined);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
@@ -297,11 +362,13 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
};
|
||||
const row: ImportAssetItemRow = {
|
||||
serialNumber: get("serialNumber"),
|
||||
assetTag: get("assetTag") || null,
|
||||
manufacturer: get("manufacturer"),
|
||||
modelName: get("modelName"),
|
||||
purchaseDate: get("purchaseDate"),
|
||||
warrantyEndDate: get("warrantyEndDate") || null,
|
||||
purchaseOrderId: get("purchaseOrderId") || null,
|
||||
status: get("status") || null,
|
||||
};
|
||||
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
||||
continue; // fully blank row
|
||||
@@ -317,6 +384,29 @@ export interface ImportAssetItemsResult {
|
||||
errors: { row: number; serialNumber: string; error: string }[];
|
||||
}
|
||||
|
||||
// Maps free-text status values from a real export (e.g. ServiceNow's
|
||||
// "State" column: "In use"/"In stock") onto this app's AssetItemStatus
|
||||
// enum -- plus this app's own enum values, in case a re-exported CSV
|
||||
// round-trips one back in. "In use" -> "assigned" is a simplification: it
|
||||
// doesn't require (or create) a matching Assignment row, just reflects
|
||||
// that the item is deployed rather than sitting in stock -- see
|
||||
// docs/asset-license-management.md's 2026-08-24 section for why linking
|
||||
// actual Employee assignments is explicitly out of scope for this import.
|
||||
const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
||||
"in use": "assigned",
|
||||
assigned: "assigned",
|
||||
"in stock": "in_stock",
|
||||
in_stock: "in_stock",
|
||||
repair: "repair",
|
||||
"in repair": "repair",
|
||||
retired: "retired",
|
||||
disposed: "retired",
|
||||
};
|
||||
|
||||
function statusFromImportText(text: string): AssetItemStatus | null {
|
||||
return IMPORT_STATUS_MAP[text.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one AssetItem per row via createAssetItem() (so uniqueness,
|
||||
* warranty auto-fill, and FK handling all stay in one place, not
|
||||
@@ -337,15 +427,20 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
const row = rows[i];
|
||||
const rowNumber = i + 2; // +1 for 0-index, +1 for the header row
|
||||
|
||||
if (!row.serialNumber || !row.manufacturer || !row.modelName || !row.purchaseDate) {
|
||||
if (!row.serialNumber || !row.manufacturer || !row.modelName) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: "Missing serialNumber/manufacturer/model/purchaseDate",
|
||||
error: "Missing serialNumber/manufacturer/model",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (Number.isNaN(Date.parse(row.purchaseDate))) {
|
||||
// purchaseDate is optional (2026-08-24) -- a row with none imports
|
||||
// fine with purchaseDate left null (and consequently no
|
||||
// warrantyMonths auto-fill, which needs a purchase date to add
|
||||
// months to). A row that HAS a purchaseDate value still has to be a
|
||||
// parseable date though, same as before.
|
||||
if (row.purchaseDate && Number.isNaN(Date.parse(row.purchaseDate))) {
|
||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Invalid purchaseDate: "${row.purchaseDate}"` });
|
||||
continue;
|
||||
}
|
||||
@@ -361,6 +456,19 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
}
|
||||
warrantyEndDate = new Date(row.warrantyEndDate);
|
||||
}
|
||||
let status: AssetItemStatus | undefined;
|
||||
if (row.status) {
|
||||
const mapped = statusFromImportText(row.status);
|
||||
if (!mapped) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: `Unrecognized status: "${row.status}" (expected e.g. In use, In stock, Repair, Retired)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
status = mapped;
|
||||
}
|
||||
|
||||
const model = await prisma.assetModel.findFirst({
|
||||
where: {
|
||||
@@ -379,9 +487,11 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
|
||||
const result = await createAssetItem({
|
||||
serialNumber: row.serialNumber,
|
||||
assetTag: row.assetTag ?? undefined,
|
||||
modelId: model.id,
|
||||
purchaseDate: new Date(row.purchaseDate),
|
||||
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
|
||||
warrantyEndDate,
|
||||
status,
|
||||
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -395,13 +505,12 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the current AssetItem table as CSV, using the same
|
||||
* manufacturer/model/purchaseDate/warrantyEndDate/purchaseOrderId columns
|
||||
* Renders the current AssetItem table as CSV, using the same columns
|
||||
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
|
||||
* real round trip, not just a one-way dump. `status` is included for
|
||||
* reference but ignored on import (see validateCreateAssetItemInput's
|
||||
* comment: new items always start in_stock; status changes are a separate
|
||||
* operation, not something a re-import should silently override).
|
||||
* real round trip, not just a one-way dump. `status` round-trips too as of
|
||||
* 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
|
||||
* enum text ("in_stock"/"assigned"/...), which IMPORT_STATUS_MAP also
|
||||
* recognizes on the way back in.
|
||||
*/
|
||||
export async function exportAssetItemsCsv(): Promise<string> {
|
||||
const rows = await prisma.assetItem.findMany({
|
||||
@@ -411,6 +520,7 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
||||
|
||||
const header = [
|
||||
"serial_number",
|
||||
"asset_tag",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"purchase_date",
|
||||
@@ -422,9 +532,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
||||
for (const row of rows) {
|
||||
const cells = [
|
||||
row.serialNumber,
|
||||
row.assetTag ?? "",
|
||||
row.model.manufacturer,
|
||||
row.model.name,
|
||||
row.purchaseDate.toISOString().slice(0, 10),
|
||||
row.purchaseDate ? row.purchaseDate.toISOString().slice(0, 10) : "",
|
||||
row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "",
|
||||
row.status,
|
||||
row.purchaseOrderId ?? "",
|
||||
|
||||
Reference in New Issue
Block a user