Add AssetItemStatus.lost, category filter on /assets page
CI / test (push) Failing after 8s

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:
toanhoang
2026-08-24 20:41:50 -04:00
parent 3ebd7efd7f
commit 6863323cce
4 changed files with 80 additions and 7 deletions
@@ -0,0 +1,2 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TYPE "AssetItemStatus" ADD VALUE 'lost';
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {