diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 7e0af6a..c7e5b7b 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; -import { createAssetItem, listAssetItems, validateCreateAssetItemInput } from "@/lib/assets"; +import { + createAssetItem, + listAssetItems, + validateCreateAssetItemInput, + type AssetItemStatus, +} from "@/lib/assets"; // Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes // /api from route protection, so this checks the session itself. Flat @@ -59,12 +64,37 @@ export async function POST(request: Request): Promise { return NextResponse.json(created.value, { status: 201 }); } -export async function GET(): Promise { +// The one entity route in this app whose GET response is NOT a bare array +// (see src/hooks/use-api-list.ts's doc comment on that being the norm +// everywhere else) -- it's `{items, total}` instead, because /assets is +// paginated (2026-08-25, see listAssetItems()'s doc comment) and the +// client needs `total` to render page controls. src/app/assets/page.tsx +// fetches this directly rather than through useApiList for that reason. +export async function GET(request: Request): Promise { const session = await auth(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const assetItems = await listAssetItems(); - return NextResponse.json(assetItems, { status: 200 }); + const url = new URL(request.url); + const params = url.searchParams; + const page = params.has("page") ? Number(params.get("page")) : undefined; + const pageSize = params.has("pageSize") ? Number(params.get("pageSize")) : undefined; + const status = params.get("status") ?? undefined; + const modelId = params.get("modelId") ?? undefined; + const categoryId = params.get("categoryId") ?? undefined; + const areaId = params.get("areaId") ?? undefined; + const search = params.get("search") ?? undefined; + + const validStatuses = ["in_stock", "assigned", "repair", "retired", "lost"]; + const result = await listAssetItems({ + page, + pageSize, + status: status && validStatuses.includes(status) ? (status as AssetItemStatus) : undefined, + modelId, + categoryId, + areaId, + search, + }); + return NextResponse.json(result, { status: 200 }); } diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx index 7115a71..cd72828 100644 --- a/src/app/assets/page.tsx +++ b/src/app/assets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useApiList } from "@/hooks/use-api-list"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -114,14 +114,24 @@ function formatDateMMDDYYYY(isoString: string | null): string { return `${mm}/${dd}/${yyyy}`; } -export default function AssetsPage() { - const { - data: assetItems, - loading, - error, - refetch, - } = useApiList("/api/assets"); +// "Manufacturer Name" for display -- except a chunk of AssetModel rows +// from the 2026-08-24 real-data import already have the manufacturer +// baked into `name` (the source file's own "Model" column did this for +// some rows, e.g. name="Logitech Group" with manufacturer="Logitech"), +// which without this check renders as "Logitech Logitech Group". Skip the +// manufacturer prefix when `name` already starts with it, case-insensitive. +function formatModelLabel(manufacturer: string, name: string): string { + const normalizedName = name.trim().toLowerCase(); + const normalizedManufacturer = manufacturer.trim().toLowerCase(); + if (normalizedManufacturer && normalizedName.startsWith(normalizedManufacturer)) { + return name; + } + return `${manufacturer} ${name}`; +} +const ASSET_ITEMS_PAGE_SIZE = 50; + +export default function AssetsPage() { const { data: assetModels } = useApiList("/api/asset-models"); const { data: assetCategories } = useApiList("/api/asset-categories"); const { data: purchaseOrders } = useApiList("/api/purchase-orders"); @@ -142,22 +152,106 @@ export default function AssetsPage() { const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - // Filter/search state -- client-side only (assetItems is already the - // full list from useApiList, and this app's actual scale, ~600 rows - // after the 2026-08-24 real-data import, is well within "just filter - // in memory" territory; no need for a server-side filtered endpoint). + // Filter/search + pagination state (2026-08-25 -- was client-side-only + // filtering over the full ~600+-row table; moved server-side, see + // src/lib/assets.ts's listAssetItems() doc comment for why). searchText + // is debounced into debouncedSearchText so typing doesn't fire a + // request per keystroke. const [searchText, setSearchText] = useState(""); + const [debouncedSearchText, setDebouncedSearchText] = useState(""); const [filterStatus, setFilterStatus] = useState("all"); const [filterCategoryId, setFilterCategoryId] = useState("all"); const [filterModelId, setFilterModelId] = useState("all"); const [filterAreaId, setFilterAreaId] = useState("all"); + const [page, setPage] = useState(1); + + const [assetItems, setAssetItems] = useState([]); + const [assetItemsTotal, setAssetItemsTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + function refetch() { + setRefreshKey((k) => k + 1); + } + + useEffect(() => { + const timeout = setTimeout(() => setDebouncedSearchText(searchText.trim()), 300); + return () => clearTimeout(timeout); + }, [searchText]); + + // A filter changing invalidates whatever page we were on -- e.g. page 3 + // of an unfiltered list may not exist at all once a filter narrows the + // result set. Reset to page 1 rather than showing an empty/stale page. + useEffect(() => { + setPage(1); + }, [debouncedSearchText, filterStatus, filterCategoryId, filterModelId, filterAreaId]); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("pageSize", String(ASSET_ITEMS_PAGE_SIZE)); + if (filterStatus !== "all") params.set("status", filterStatus); + if (filterCategoryId !== "all") params.set("categoryId", filterCategoryId); + if (filterModelId !== "all") params.set("modelId", filterModelId); + if (filterAreaId !== "all") params.set("areaId", filterAreaId); + if (debouncedSearchText) params.set("search", debouncedSearchText); + + try { + const response = await fetch(`/api/assets?${params.toString()}`); + let body: unknown; + try { + body = await response.json(); + } catch { + body = null; + } + if (cancelled) return; + if (!response.ok) { + const message = + body !== null && + typeof body === "object" && + "error" in body && + typeof (body as { error: unknown }).error === "string" + ? (body as { error: string }).error + : "Request failed"; + setError(message); + setAssetItems([]); + setAssetItemsTotal(0); + return; + } + const parsed = body as { items?: AssetItemRecord[]; total?: number }; + setAssetItems(Array.isArray(parsed.items) ? parsed.items : []); + setAssetItemsTotal(typeof parsed.total === "number" ? parsed.total : 0); + } catch { + if (!cancelled) { + setError("Request failed"); + setAssetItems([]); + setAssetItemsTotal(0); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [page, filterStatus, filterCategoryId, filterModelId, filterAreaId, debouncedSearchText, refreshKey]); + + const totalPages = Math.max(1, Math.ceil(assetItemsTotal / ASSET_ITEMS_PAGE_SIZE)); // modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID // in the table (2026-08-25 -- was previously the literal FK value). const modelNameById = useMemo(() => { const map = new Map(); for (const m of assetModels) { - map.set(m.id, `${m.manufacturer} ${m.name}`); + map.set(m.id, formatModelLabel(m.manufacturer, m.name)); } return map; }, [assetModels]); @@ -222,32 +316,9 @@ export default function AssetsPage() { return map; }, [assignments, employeeNameById]); - 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 (filterAreaId !== "all" && item.area_id !== filterAreaId) return false; - if (query) { - const assignee = assigneeNameByAssetItemId.get(item.id) ?? ""; - const haystack = `${item.serial_number} ${item.asset_tag ?? ""} ${assignee}`.toLowerCase(); - if (!haystack.includes(query)) return false; - } - return true; - }); - }, [ - assetItems, - searchText, - filterStatus, - filterModelId, - filterCategoryId, - filterAreaId, - modelCategoryById, - assigneeNameByAssetItemId, - ]); + // Filtering + pagination both happen server-side now (see the effect + // above) -- `assetItems` here is already exactly the current page's + // rows for the active filters, nothing left to compute client-side. const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">( "idle" @@ -431,7 +502,7 @@ export default function AssetsPage() { All {modelsForCategoryFilter.map((assetModel) => ( - {assetModel.manufacturer} {assetModel.name} + {formatModelLabel(assetModel.manufacturer, assetModel.name)} ))} @@ -456,7 +527,12 @@ export default function AssetsPage() {

- {filteredAssetItems.length} / {assetItems.length} assets + {assetItemsTotal === 0 + ? "0 assets" + : `${(page - 1) * ASSET_ITEMS_PAGE_SIZE + 1}-${Math.min( + page * ASSET_ITEMS_PAGE_SIZE, + assetItemsTotal + )} / ${assetItemsTotal} assets`}

@@ -483,9 +559,9 @@ export default function AssetsPage() { - {filteredAssetItems.map((assetItem, index) => ( + {assetItems.map((assetItem, index) => ( - {index + 1} + {(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1} {assetItem.serial_number} {assetItem.asset_tag ?? "-"} {modelNameById.get(assetItem.model_id) ?? assetItem.model_id} @@ -508,6 +584,32 @@ export default function AssetsPage() { )} + + {!loading && !error && assetItemsTotal > 0 ? ( +
+ +

+ Page {page} / {totalPages} +

+ +
+ ) : null}
@@ -593,7 +695,7 @@ export default function AssetsPage() { {assetModels.map((assetModel) => ( - {assetModel.manufacturer} {assetModel.name} + {formatModelLabel(assetModel.manufacturer, assetModel.name)} ))} diff --git a/src/lib/assets.ts b/src/lib/assets.ts index fa5aa6c..e63d99f 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -291,11 +291,72 @@ export async function createAssetItem( } } -export async function listAssetItems(): Promise { - const rows = await prisma.assetItem.findMany({ - orderBy: [{ createdAt: "desc" }, { id: "desc" }], - }); - return rows.map(toRecord); +export interface ListAssetItemsOptions { + page?: number; + pageSize?: number; + status?: AssetItemStatus; + modelId?: string; + categoryId?: string; + areaId?: string; + // Matches serialNumber, assetTag, or the name of the AssetItem's current + // (non-returned) assignee -- via the assignments relation, not a + // separate query, so this stays one round trip. + search?: string; +} + +export interface ListAssetItemsResult { + items: AssetItemRecord[]; + total: number; +} + +const DEFAULT_PAGE_SIZE = 50; + +/** + * Lists AssetItems with server-side filtering + pagination (2026-08-25 -- + * was a bare unpaginated findMany(); at ~600+ rows post the 2026-08-24 + * real-data import, shipping the whole table to the client on every page + * load was wasteful, and /assets's client-side filtering couldn't scale + * past "the whole table is already in memory" anyway). Filtering and + * pagination both happen in the same WHERE clause -- `total` reflects the + * FILTERED count, not the table's overall size, so pagination controls + * built from it are correct regardless of which filters are active. + */ +export async function listAssetItems( + options: ListAssetItemsOptions = {} +): Promise { + const page = options.page && options.page > 0 ? Math.floor(options.page) : 1; + const pageSize = + options.pageSize && options.pageSize > 0 ? Math.floor(options.pageSize) : DEFAULT_PAGE_SIZE; + + const where: Prisma.AssetItemWhereInput = {}; + if (options.status) where.status = options.status; + if (options.modelId) where.modelId = options.modelId; + if (options.categoryId) where.model = { categoryId: options.categoryId }; + if (options.areaId) where.areaId = options.areaId; + if (options.search && options.search.trim() !== "") { + const q = options.search.trim(); + where.OR = [ + { serialNumber: { contains: q, mode: "insensitive" } }, + { assetTag: { contains: q, mode: "insensitive" } }, + { + assignments: { + some: { returnedAt: null, employee: { name: { contains: q, mode: "insensitive" } } }, + }, + }, + ]; + } + + const [rows, total] = await Promise.all([ + prisma.assetItem.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.assetItem.count({ where }), + ]); + + return { items: rows.map(toRecord), total }; } // ---------------------------------------------------------------------