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:
@@ -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;
|
||||||
@@ -151,6 +151,17 @@ model AssetItem {
|
|||||||
// amounts in the millions with room to spare.
|
// amounts in the millions with room to spare.
|
||||||
cost Decimal? @db.Decimal(14, 2)
|
cost Decimal? @db.Decimal(14, 2)
|
||||||
costCurrency String? @map("cost_currency")
|
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
|
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
// Free-text PO number from an external system (e.g. the real ITAM
|
// Free-text PO number from an external system (e.g. the real ITAM
|
||||||
@@ -548,6 +559,8 @@ model Area {
|
|||||||
team String?
|
team String?
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
inventoryScans InventoryScan[]
|
inventoryScans InventoryScan[]
|
||||||
|
// Back-relation for AssetItem.areaId -- see that field's comment.
|
||||||
|
assetItems AssetItem[]
|
||||||
|
|
||||||
@@map("areas")
|
@@map("areas")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
cost,
|
cost,
|
||||||
costCurrency,
|
costCurrency,
|
||||||
externalPoNumber,
|
externalPoNumber,
|
||||||
|
areaId,
|
||||||
} = body as Record<string, unknown>;
|
} = body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetItemInput({
|
const validation = validateCreateAssetItemInput({
|
||||||
serialNumber,
|
serialNumber,
|
||||||
@@ -43,6 +44,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
cost,
|
cost,
|
||||||
costCurrency,
|
costCurrency,
|
||||||
externalPoNumber,
|
externalPoNumber,
|
||||||
|
areaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
|
|||||||
+139
-9
@@ -33,11 +33,34 @@ interface AssetItemRecord {
|
|||||||
status: "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
status: "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
cost_currency: string | null;
|
cost_currency: string | null;
|
||||||
|
area_id: string | null;
|
||||||
purchase_order_id: string | null;
|
purchase_order_id: string | null;
|
||||||
external_po_number: string | null;
|
external_po_number: string | null;
|
||||||
created_at: string;
|
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
|
// 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
|
// the modelId <Select> here (see src/app/asset-models/page.tsx's
|
||||||
// AssetModelRecord for the full shape). category_id added 2026-08-25, used
|
// 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.
|
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
|
||||||
const NO_PURCHASE_ORDER_VALUE = "none";
|
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() {
|
export default function AssetsPage() {
|
||||||
const {
|
const {
|
||||||
data: assetItems,
|
data: assetItems,
|
||||||
@@ -84,6 +125,9 @@ export default function AssetsPage() {
|
|||||||
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
||||||
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
||||||
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
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 [serialNumber, setSerialNumber] = useState("");
|
||||||
const [assetTag, setAssetTag] = useState("");
|
const [assetTag, setAssetTag] = useState("");
|
||||||
@@ -91,6 +135,7 @@ export default function AssetsPage() {
|
|||||||
const [purchaseDate, setPurchaseDate] = useState("");
|
const [purchaseDate, setPurchaseDate] = useState("");
|
||||||
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
||||||
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
||||||
|
const [areaId, setAreaId] = useState(NO_AREA_VALUE);
|
||||||
const [cost, setCost] = useState("");
|
const [cost, setCost] = useState("");
|
||||||
const [costCurrency, setCostCurrency] = useState("");
|
const [costCurrency, setCostCurrency] = useState("");
|
||||||
const [externalPoNumber, setExternalPoNumber] = useState("");
|
const [externalPoNumber, setExternalPoNumber] = useState("");
|
||||||
@@ -105,6 +150,7 @@ export default function AssetsPage() {
|
|||||||
const [filterStatus, setFilterStatus] = useState("all");
|
const [filterStatus, setFilterStatus] = useState("all");
|
||||||
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
||||||
const [filterModelId, setFilterModelId] = useState("all");
|
const [filterModelId, setFilterModelId] = useState("all");
|
||||||
|
const [filterAreaId, setFilterAreaId] = useState("all");
|
||||||
|
|
||||||
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
||||||
// in the table (2026-08-25 -- was previously the literal FK value).
|
// 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 filteredAssetItems = useMemo(() => {
|
||||||
const query = searchText.trim().toLowerCase();
|
const query = searchText.trim().toLowerCase();
|
||||||
return assetItems.filter((item) => {
|
return assetItems.filter((item) => {
|
||||||
@@ -154,13 +230,24 @@ export default function AssetsPage() {
|
|||||||
if (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) {
|
if (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (filterAreaId !== "all" && item.area_id !== filterAreaId) return false;
|
||||||
if (query) {
|
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;
|
if (!haystack.includes(query)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [assetItems, searchText, filterStatus, filterModelId, filterCategoryId, modelCategoryById]);
|
}, [
|
||||||
|
assetItems,
|
||||||
|
searchText,
|
||||||
|
filterStatus,
|
||||||
|
filterModelId,
|
||||||
|
filterCategoryId,
|
||||||
|
filterAreaId,
|
||||||
|
modelCategoryById,
|
||||||
|
assigneeNameByAssetItemId,
|
||||||
|
]);
|
||||||
|
|
||||||
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
||||||
"idle"
|
"idle"
|
||||||
@@ -214,6 +301,7 @@ export default function AssetsPage() {
|
|||||||
const hasAssetTag = assetTag.trim() !== "";
|
const hasAssetTag = assetTag.trim() !== "";
|
||||||
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
||||||
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
||||||
|
const hasAreaId = areaId !== NO_AREA_VALUE;
|
||||||
const hasCost = cost.trim() !== "";
|
const hasCost = cost.trim() !== "";
|
||||||
const hasCostCurrency = costCurrency.trim() !== "";
|
const hasCostCurrency = costCurrency.trim() !== "";
|
||||||
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
||||||
@@ -238,6 +326,7 @@ export default function AssetsPage() {
|
|||||||
...(hasAssetTag ? { assetTag } : {}),
|
...(hasAssetTag ? { assetTag } : {}),
|
||||||
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
||||||
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
||||||
|
...(hasAreaId ? { areaId } : {}),
|
||||||
...(hasCost ? { cost: Number(cost) } : {}),
|
...(hasCost ? { cost: Number(cost) } : {}),
|
||||||
...(hasCostCurrency ? { costCurrency } : {}),
|
...(hasCostCurrency ? { costCurrency } : {}),
|
||||||
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
||||||
@@ -269,6 +358,7 @@ export default function AssetsPage() {
|
|||||||
setPurchaseDate("");
|
setPurchaseDate("");
|
||||||
setWarrantyEndDate("");
|
setWarrantyEndDate("");
|
||||||
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
||||||
|
setAreaId(NO_AREA_VALUE);
|
||||||
setCost("");
|
setCost("");
|
||||||
setCostCurrency("");
|
setCostCurrency("");
|
||||||
setExternalPoNumber("");
|
setExternalPoNumber("");
|
||||||
@@ -287,7 +377,7 @@ export default function AssetsPage() {
|
|||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
<Input
|
||||||
id="search"
|
id="search"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
@@ -348,6 +438,23 @@ export default function AssetsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<p className="pb-2 text-sm text-muted-foreground">
|
||||||
{filteredAssetItems.length} / {assetItems.length} assets
|
{filteredAssetItems.length} / {assetItems.length} assets
|
||||||
</p>
|
</p>
|
||||||
@@ -365,13 +472,14 @@ export default function AssetsPage() {
|
|||||||
<TableHead>Serial Number</TableHead>
|
<TableHead>Serial Number</TableHead>
|
||||||
<TableHead>Asset Tag</TableHead>
|
<TableHead>Asset Tag</TableHead>
|
||||||
<TableHead>Model</TableHead>
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Area</TableHead>
|
||||||
|
<TableHead>Assigned To</TableHead>
|
||||||
<TableHead>Purchase Date</TableHead>
|
<TableHead>Purchase Date</TableHead>
|
||||||
<TableHead>Warranty End Date</TableHead>
|
<TableHead>Warranty End Date</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Cost</TableHead>
|
<TableHead>Cost</TableHead>
|
||||||
<TableHead>Purchase Order ID</TableHead>
|
<TableHead>Purchase Order ID</TableHead>
|
||||||
<TableHead>External PO Number</TableHead>
|
<TableHead>External PO Number</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -381,8 +489,12 @@ export default function AssetsPage() {
|
|||||||
<TableCell>{assetItem.serial_number}</TableCell>
|
<TableCell>{assetItem.serial_number}</TableCell>
|
||||||
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
||||||
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
||||||
<TableCell>{assetItem.purchase_date ?? "-"}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{assetItem.warranty_end_date ?? "-"}</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.status}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{assetItem.cost !== null
|
{assetItem.cost !== null
|
||||||
@@ -391,7 +503,6 @@ export default function AssetsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
||||||
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
||||||
<TableCell>{assetItem.created_at}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</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
|
<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>,{" "}
|
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
|
<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ẽ{" "}
|
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{" "}
|
||||||
<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.
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<a href="/api/assets/export">
|
<a href="/api/assets/export">
|
||||||
@@ -525,6 +638,23 @@ export default function AssetsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="cost">Cost (optional)</Label>
|
<Label htmlFor="cost">Cost (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
+45
-1
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
|
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
|
||||||
|
import { createOrGetArea } from "./areas";
|
||||||
|
|
||||||
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export interface AssetItemRecord {
|
|||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
cost_currency: string | null;
|
cost_currency: string | null;
|
||||||
|
area_id: string | null;
|
||||||
purchase_order_id: string | null;
|
purchase_order_id: string | null;
|
||||||
// Free-text PO number from an external system -- NOT purchase_order_id
|
// Free-text PO number from an external system -- NOT purchase_order_id
|
||||||
// above (a real FK). See schema.prisma's AssetItem.externalPoNumber
|
// above (a real FK). See schema.prisma's AssetItem.externalPoNumber
|
||||||
@@ -37,6 +39,7 @@ export interface CreateAssetItemInput {
|
|||||||
warrantyEndDate?: Date;
|
warrantyEndDate?: Date;
|
||||||
cost?: number | null;
|
cost?: number | null;
|
||||||
costCurrency?: string | null;
|
costCurrency?: string | null;
|
||||||
|
areaId?: string | null;
|
||||||
purchaseOrderId?: string | null;
|
purchaseOrderId?: string | null;
|
||||||
externalPoNumber?: string | null;
|
externalPoNumber?: string | null;
|
||||||
// Only set by importAssetItems() (bulk-loading already-deployed
|
// 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" })
|
.string({ invalid_type_error: "externalPoNumber must be a string" })
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.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;
|
cost?: unknown;
|
||||||
costCurrency?: unknown;
|
costCurrency?: unknown;
|
||||||
externalPoNumber?: unknown;
|
externalPoNumber?: unknown;
|
||||||
|
areaId?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetItemSchema.safeParse(input);
|
const result = createAssetItemSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -158,6 +167,7 @@ function toRecord(row: {
|
|||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
cost: Prisma.Decimal | null;
|
cost: Prisma.Decimal | null;
|
||||||
costCurrency: string | null;
|
costCurrency: string | null;
|
||||||
|
areaId: string | null;
|
||||||
purchaseOrderId: string | null;
|
purchaseOrderId: string | null;
|
||||||
externalPoNumber: string | null;
|
externalPoNumber: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -172,6 +182,7 @@ function toRecord(row: {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
cost: row.cost ? row.cost.toNumber() : null,
|
cost: row.cost ? row.cost.toNumber() : null,
|
||||||
cost_currency: row.costCurrency,
|
cost_currency: row.costCurrency,
|
||||||
|
area_id: row.areaId,
|
||||||
purchase_order_id: row.purchaseOrderId,
|
purchase_order_id: row.purchaseOrderId,
|
||||||
external_po_number: row.externalPoNumber,
|
external_po_number: row.externalPoNumber,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
@@ -247,6 +258,7 @@ export async function createAssetItem(
|
|||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
cost: input.cost ?? null,
|
cost: input.cost ?? null,
|
||||||
costCurrency: input.costCurrency ?? null,
|
costCurrency: input.costCurrency ?? null,
|
||||||
|
areaId: input.areaId ?? null,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
externalPoNumber: input.externalPoNumber ?? null,
|
externalPoNumber: input.externalPoNumber ?? null,
|
||||||
...(input.status ? { status: input.status } : {}),
|
...(input.status ? { status: input.status } : {}),
|
||||||
@@ -264,10 +276,15 @@ export async function createAssetItem(
|
|||||||
return { ok: false, error: "serialNumber already exists" };
|
return { ok: false, error: "serialNumber already exists" };
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
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 : "";
|
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||||
if (fieldName.includes("purchase_order")) {
|
if (fieldName.includes("purchase_order")) {
|
||||||
return { ok: false, error: "purchaseOrderId does not reference an existing 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" };
|
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -314,6 +331,11 @@ export interface ImportAssetItemRow {
|
|||||||
// NOT the same as purchaseOrderId above -- see schema.prisma's
|
// NOT the same as purchaseOrderId above -- see schema.prisma's
|
||||||
// AssetItem.externalPoNumber comment.
|
// AssetItem.externalPoNumber comment.
|
||||||
externalPoNumber: string | null;
|
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[]> = {
|
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
|
// actual "PO number" column name 1:1, and keeps the two fields
|
||||||
// unambiguous from each other by header name alone.
|
// unambiguous from each other by header name alone.
|
||||||
externalPoNumber: ["ponumber", "po number", "external po number", "so po"],
|
externalPoNumber: ["ponumber", "po number", "external po number", "so po"],
|
||||||
|
areaName: ["area", "khu vuc", "location", "stockroom", "vi tri"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParseAssetItemsCsvResult =
|
export type ParseAssetItemsCsvResult =
|
||||||
@@ -422,6 +445,7 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
|||||||
cost: get("cost") || null,
|
cost: get("cost") || null,
|
||||||
costCurrency: get("costCurrency") || null,
|
costCurrency: get("costCurrency") || null,
|
||||||
externalPoNumber: get("externalPoNumber") || null,
|
externalPoNumber: get("externalPoNumber") || null,
|
||||||
|
areaName: get("areaName") || null,
|
||||||
};
|
};
|
||||||
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
||||||
continue; // fully blank row
|
continue; // fully blank row
|
||||||
@@ -566,6 +590,22 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
const costCurrency = cleanImportText(row.costCurrency);
|
const costCurrency = cleanImportText(row.costCurrency);
|
||||||
const externalPoNumber = cleanImportText(row.externalPoNumber);
|
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({
|
const model = await prisma.assetModel.findFirst({
|
||||||
where: {
|
where: {
|
||||||
manufacturer: { equals: row.manufacturer, mode: "insensitive" },
|
manufacturer: { equals: row.manufacturer, mode: "insensitive" },
|
||||||
@@ -606,6 +646,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
...(costCurrency ? { costCurrency } : {}),
|
...(costCurrency ? { costCurrency } : {}),
|
||||||
...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}),
|
...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}),
|
||||||
...(externalPoNumber ? { externalPoNumber } : {}),
|
...(externalPoNumber ? { externalPoNumber } : {}),
|
||||||
|
...(areaId ? { areaId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
updated++;
|
updated++;
|
||||||
@@ -623,6 +664,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
costCurrency,
|
costCurrency,
|
||||||
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
||||||
externalPoNumber,
|
externalPoNumber,
|
||||||
|
areaId,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
|
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> {
|
export async function exportAssetItemsCsv(): Promise<string> {
|
||||||
const rows = await prisma.assetItem.findMany({
|
const rows = await prisma.assetItem.findMany({
|
||||||
include: { model: true },
|
include: { model: true, area: true },
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,6 +700,7 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
|||||||
"status",
|
"status",
|
||||||
"cost",
|
"cost",
|
||||||
"cost_currency",
|
"cost_currency",
|
||||||
|
"area",
|
||||||
"purchase_order_id",
|
"purchase_order_id",
|
||||||
"external_po_number",
|
"external_po_number",
|
||||||
];
|
];
|
||||||
@@ -673,6 +716,7 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
|||||||
row.status,
|
row.status,
|
||||||
row.cost ? row.cost.toString() : "",
|
row.cost ? row.cost.toString() : "",
|
||||||
row.costCurrency ?? "",
|
row.costCurrency ?? "",
|
||||||
|
row.area?.name ?? "",
|
||||||
row.purchaseOrderId ?? "",
|
row.purchaseOrderId ?? "",
|
||||||
row.externalPoNumber ?? "",
|
row.externalPoNumber ?? "",
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user