lost added via ALTER TYPE ... ADD VALUE (hand-written migration, stays an enum -- see the schema.prisma comment on why: status values are read directly by business logic like createAssignment()s in_stock gate, a lookup table would not actually decouple that). IMPORT_STATUS_MAP gains lost/missing -> lost mappings for CSV import/re-sync. /assets page gets a Category filter dropdown (alongside the existing Status/Model/search filters) that also narrows the Model dropdown to that category -- with 135 models after the 2026-08-24 real-data import, an unfiltered Model list is unwieldy. Picking a Category resets the Model filter if the currently-selected model does not belong to it. tsc/lint clean, no Vitest coverage -- same deviation as the rest of this branch, see CLAUDE.md.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||||
|
ALTER TYPE "AssetItemStatus" ADD VALUE 'lost';
|
||||||
@@ -84,14 +84,19 @@ model Employee {
|
|||||||
@@map("employees")
|
@@map("employees")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed, closed set of 4 values tied to the domain (see docs/
|
// Fixed, closed set of values tied to the domain (see docs/
|
||||||
// asset-license-management.md's "Data model (sơ bộ)" table) — native
|
// asset-license-management.md's "Data model (sơ bộ)" table) — native
|
||||||
// Postgres enum, same rationale as RequestType above.
|
// Postgres enum, same rationale as RequestType above. `lost` added
|
||||||
|
// 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the
|
||||||
|
// 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the
|
||||||
|
// values are read directly by business logic, e.g. createAssignment()'s
|
||||||
|
// `status !== "in_stock"` gate, not just display labels).
|
||||||
enum AssetItemStatus {
|
enum AssetItemStatus {
|
||||||
in_stock
|
in_stock
|
||||||
assigned
|
assigned
|
||||||
repair
|
repair
|
||||||
retired
|
retired
|
||||||
|
lost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
||||||
|
|||||||
+68
-4
@@ -30,7 +30,7 @@ interface AssetItemRecord {
|
|||||||
model_id: string;
|
model_id: string;
|
||||||
purchase_date: string | null;
|
purchase_date: string | null;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: "in_stock" | "assigned" | "repair" | "retired";
|
status: "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
cost_currency: string | null;
|
cost_currency: string | null;
|
||||||
purchase_order_id: string | null;
|
purchase_order_id: string | null;
|
||||||
@@ -40,13 +40,24 @@ interface AssetItemRecord {
|
|||||||
|
|
||||||
// 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).
|
// AssetModelRecord for the full shape). category_id added 2026-08-25, used
|
||||||
|
// to narrow the Model filter dropdown once a Category is picked -- with
|
||||||
|
// 135 models after the 2026-08-24 real-data import, an unfiltered list is
|
||||||
|
// unwieldy.
|
||||||
interface AssetModelRecord {
|
interface AssetModelRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
category_id: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shape returned by GET /api/asset-categories — only id/name, used to
|
||||||
|
// populate the Category filter <Select> here.
|
||||||
|
interface AssetCategoryRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Shape returned by GET /api/purchase-orders — only the id, used to populate
|
// Shape returned by GET /api/purchase-orders — only the id, used to populate
|
||||||
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
||||||
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
||||||
@@ -71,6 +82,7 @@ export default function AssetsPage() {
|
|||||||
} = useApiList<AssetItemRecord>("/api/assets");
|
} = useApiList<AssetItemRecord>("/api/assets");
|
||||||
|
|
||||||
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: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
||||||
|
|
||||||
const [serialNumber, setSerialNumber] = useState("");
|
const [serialNumber, setSerialNumber] = useState("");
|
||||||
@@ -91,6 +103,7 @@ export default function AssetsPage() {
|
|||||||
// in memory" territory; no need for a server-side filtered endpoint).
|
// in memory" territory; no need for a server-side filtered endpoint).
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [filterStatus, setFilterStatus] = useState("all");
|
const [filterStatus, setFilterStatus] = useState("all");
|
||||||
|
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
||||||
const [filterModelId, setFilterModelId] = useState("all");
|
const [filterModelId, setFilterModelId] = useState("all");
|
||||||
|
|
||||||
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
||||||
@@ -103,18 +116,51 @@ export default function AssetsPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [assetModels]);
|
}, [assetModels]);
|
||||||
|
|
||||||
|
const modelCategoryById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const m of assetModels) {
|
||||||
|
map.set(m.id, m.category_id);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [assetModels]);
|
||||||
|
|
||||||
|
// Model dropdown options narrow to the picked Category -- with 135
|
||||||
|
// models after the 2026-08-24 real-data import, showing all of them
|
||||||
|
// regardless of category is unwieldy.
|
||||||
|
const modelsForCategoryFilter = useMemo(() => {
|
||||||
|
if (filterCategoryId === "all") return assetModels;
|
||||||
|
return assetModels.filter((m) => m.category_id === filterCategoryId);
|
||||||
|
}, [assetModels, filterCategoryId]);
|
||||||
|
|
||||||
|
function handleFilterCategoryChange(categoryId: string) {
|
||||||
|
setFilterCategoryId(categoryId);
|
||||||
|
// A previously-picked Model may not belong to the newly-picked
|
||||||
|
// Category -- reset rather than silently keep filtering on a model
|
||||||
|
// that's no longer shown in the (now-narrowed) dropdown.
|
||||||
|
if (
|
||||||
|
filterModelId !== "all" &&
|
||||||
|
categoryId !== "all" &&
|
||||||
|
modelCategoryById.get(filterModelId) !== categoryId
|
||||||
|
) {
|
||||||
|
setFilterModelId("all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredAssetItems = useMemo(() => {
|
const filteredAssetItems = useMemo(() => {
|
||||||
const query = searchText.trim().toLowerCase();
|
const query = searchText.trim().toLowerCase();
|
||||||
return assetItems.filter((item) => {
|
return assetItems.filter((item) => {
|
||||||
if (filterStatus !== "all" && item.status !== filterStatus) return false;
|
if (filterStatus !== "all" && item.status !== filterStatus) return false;
|
||||||
if (filterModelId !== "all" && item.model_id !== filterModelId) return false;
|
if (filterModelId !== "all" && item.model_id !== filterModelId) return false;
|
||||||
|
if (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
const haystack = `${item.serial_number} ${item.asset_tag ?? ""}`.toLowerCase();
|
const haystack = `${item.serial_number} ${item.asset_tag ?? ""}`.toLowerCase();
|
||||||
if (!haystack.includes(query)) return false;
|
if (!haystack.includes(query)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [assetItems, searchText, filterStatus, filterModelId]);
|
}, [assetItems, searchText, filterStatus, filterModelId, filterCategoryId, modelCategoryById]);
|
||||||
|
|
||||||
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
||||||
"idle"
|
"idle"
|
||||||
@@ -263,6 +309,24 @@ export default function AssetsPage() {
|
|||||||
<SelectItem value="assigned">assigned</SelectItem>
|
<SelectItem value="assigned">assigned</SelectItem>
|
||||||
<SelectItem value="repair">repair</SelectItem>
|
<SelectItem value="repair">repair</SelectItem>
|
||||||
<SelectItem value="retired">retired</SelectItem>
|
<SelectItem value="retired">retired</SelectItem>
|
||||||
|
<SelectItem value="lost">lost</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="filterCategory">Category</Label>
|
||||||
|
<Select value={filterCategoryId} onValueChange={handleFilterCategoryChange}>
|
||||||
|
<SelectTrigger id="filterCategory" className="w-52">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
{assetCategories.map((assetCategory) => (
|
||||||
|
<SelectItem key={assetCategory.id} value={assetCategory.id}>
|
||||||
|
{assetCategory.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +339,7 @@ export default function AssetsPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
{assetModels.map((assetModel) => (
|
{modelsForCategoryFilter.map((assetModel) => (
|
||||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||||
{assetModel.manufacturer} {assetModel.name}
|
{assetModel.manufacturer} {assetModel.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,7 @@ 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";
|
||||||
|
|
||||||
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
||||||
|
|
||||||
export interface AssetItemRecord {
|
export interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -455,6 +455,8 @@ const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
|||||||
"in repair": "repair",
|
"in repair": "repair",
|
||||||
retired: "retired",
|
retired: "retired",
|
||||||
disposed: "retired",
|
disposed: "retired",
|
||||||
|
lost: "lost",
|
||||||
|
missing: "lost",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusFromImportText(text: string): AssetItemStatus | null {
|
function statusFromImportText(text: string): AssetItemStatus | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user