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")
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
in_stock
|
||||
assigned
|
||||
repair
|
||||
retired
|
||||
lost
|
||||
}
|
||||
|
||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
||||
|
||||
+68
-4
@@ -30,7 +30,7 @@ interface AssetItemRecord {
|
||||
model_id: string;
|
||||
purchase_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_currency: 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
|
||||
// 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 {
|
||||
id: string;
|
||||
category_id: string;
|
||||
manufacturer: 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
|
||||
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
||||
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
||||
@@ -71,6 +82,7 @@ export default function AssetsPage() {
|
||||
} = useApiList<AssetItemRecord>("/api/assets");
|
||||
|
||||
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 [serialNumber, setSerialNumber] = useState("");
|
||||
@@ -91,6 +103,7 @@ export default function AssetsPage() {
|
||||
// in memory" territory; no need for a server-side filtered endpoint).
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
||||
const [filterModelId, setFilterModelId] = useState("all");
|
||||
|
||||
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
||||
@@ -103,18 +116,51 @@ export default function AssetsPage() {
|
||||
return map;
|
||||
}, [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 query = searchText.trim().toLowerCase();
|
||||
return assetItems.filter((item) => {
|
||||
if (filterStatus !== "all" && item.status !== filterStatus) return false;
|
||||
if (filterModelId !== "all" && item.model_id !== filterModelId) return false;
|
||||
if (filterCategoryId !== "all" && modelCategoryById.get(item.model_id) !== filterCategoryId) {
|
||||
return false;
|
||||
}
|
||||
if (query) {
|
||||
const haystack = `${item.serial_number} ${item.asset_tag ?? ""}`.toLowerCase();
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [assetItems, searchText, filterStatus, filterModelId]);
|
||||
}, [assetItems, searchText, filterStatus, filterModelId, filterCategoryId, modelCategoryById]);
|
||||
|
||||
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
||||
"idle"
|
||||
@@ -263,6 +309,24 @@ export default function AssetsPage() {
|
||||
<SelectItem value="assigned">assigned</SelectItem>
|
||||
<SelectItem value="repair">repair</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>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -275,7 +339,7 @@ export default function AssetsPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{assetModels.map((assetModel) => (
|
||||
{modelsForCategoryFilter.map((assetModel) => (
|
||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||
{assetModel.manufacturer} {assetModel.name}
|
||||
</SelectItem>
|
||||
|
||||
+3
-1
@@ -3,7 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
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 {
|
||||
id: string;
|
||||
@@ -455,6 +455,8 @@ const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
||||
"in repair": "repair",
|
||||
retired: "retired",
|
||||
disposed: "retired",
|
||||
lost: "lost",
|
||||
missing: "lost",
|
||||
};
|
||||
|
||||
function statusFromImportText(text: string): AssetItemStatus | null {
|
||||
|
||||
Reference in New Issue
Block a user