Server-side pagination for /assets (50/page), fix duplicated manufacturer in model labels
CI / test (push) Failing after 8s

listAssetItems() now takes {page, pageSize, status, modelId, categoryId,
areaId, search} and returns {items, total} instead of the whole table --
was a bare unpaginated findMany(), shipping all ~613 rows on every page
load. Filtering moved server-side into the same WHERE clause so 'total'
reflects the filtered count, not the table's overall size -- pagination
controls stay correct regardless of which filters are active. 'search'
matches serialNumber, assetTag, or the current (non-returned) assignee's
name via the assignments relation in one query.

GET /api/assets is now the one entity route whose response is not a bare
array (see use-api-list.ts) -- its {items, total} shape needs 'total' for
page controls, so /assets fetches it directly instead of through
useApiList. Query params: page, pageSize, status, modelId, categoryId,
areaId, search.

/assets page: search is debounced 300ms (was firing a request per
keystroke otherwise), changing any filter resets to page 1, added
Previous/Next controls + "showing X-Y / total" count. Row # is now a
running index across pages, not per-page.

Separately: all 135 AssetModel rows from the 2026-08-24 real-data import
have the manufacturer name baked into 'name' already (the source file's
own Model column did, e.g. name="Bose Soundlink Revolve" manufacturer=
"Bose") -- rendering "manufacturer name" everywhere doubled it
("Bose Bose Soundlink Revolve"). Fixed at the display layer
(formatModelLabel(), skips the manufacturer prefix when name already
starts with it) rather than a data migration -- non-destructive, handles
every affected row uniformly, reversible.

Smoke-tested listAssetItems() directly (tsx): page1/page2 have zero
overlap, status filter count matches (71 in_stock), asset-tag search
finds exactly 1 row, assignee-name search correctly joins through
Assignment (4 rows for one employee), last page (13 of 613/50) has the
expected remainder of 13 rows.

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 21:31:22 -04:00
parent 4063b81f16
commit 10508f9d04
3 changed files with 246 additions and 53 deletions
+34 -4
View File
@@ -1,6 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; 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 // Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat // /api from route protection, so this checks the session itself. Flat
@@ -59,12 +64,37 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json(created.value, { status: 201 }); return NextResponse.json(created.value, { status: 201 });
} }
export async function GET(): Promise<NextResponse> { // 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<NextResponse> {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const assetItems = await listAssetItems(); const url = new URL(request.url);
return NextResponse.json(assetItems, { status: 200 }); 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 });
} }
+146 -44
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useApiList } from "@/hooks/use-api-list";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -114,14 +114,24 @@ function formatDateMMDDYYYY(isoString: string | null): string {
return `${mm}/${dd}/${yyyy}`; return `${mm}/${dd}/${yyyy}`;
} }
export default function AssetsPage() { // "Manufacturer Name" for display -- except a chunk of AssetModel rows
const { // from the 2026-08-24 real-data import already have the manufacturer
data: assetItems, // baked into `name` (the source file's own "Model" column did this for
loading, // some rows, e.g. name="Logitech Group" with manufacturer="Logitech"),
error, // which without this check renders as "Logitech Logitech Group". Skip the
refetch, // manufacturer prefix when `name` already starts with it, case-insensitive.
} = useApiList<AssetItemRecord>("/api/assets"); 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<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");
@@ -142,22 +152,106 @@ export default function AssetsPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
// Filter/search state -- client-side only (assetItems is already the // Filter/search + pagination state (2026-08-25 -- was client-side-only
// full list from useApiList, and this app's actual scale, ~600 rows // filtering over the full ~600+-row table; moved server-side, see
// after the 2026-08-24 real-data import, is well within "just filter // src/lib/assets.ts's listAssetItems() doc comment for why). searchText
// in memory" territory; no need for a server-side filtered endpoint). // is debounced into debouncedSearchText so typing doesn't fire a
// request per keystroke.
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [debouncedSearchText, setDebouncedSearchText] = useState("");
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"); const [filterAreaId, setFilterAreaId] = useState("all");
const [page, setPage] = useState(1);
const [assetItems, setAssetItems] = useState<AssetItemRecord[]>([]);
const [assetItemsTotal, setAssetItemsTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 // 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).
const modelNameById = useMemo(() => { const modelNameById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const m of assetModels) { for (const m of assetModels) {
map.set(m.id, `${m.manufacturer} ${m.name}`); map.set(m.id, formatModelLabel(m.manufacturer, m.name));
} }
return map; return map;
}, [assetModels]); }, [assetModels]);
@@ -222,32 +316,9 @@ export default function AssetsPage() {
return map; return map;
}, [assignments, employeeNameById]); }, [assignments, employeeNameById]);
const filteredAssetItems = useMemo(() => { // Filtering + pagination both happen server-side now (see the effect
const query = searchText.trim().toLowerCase(); // above) -- `assetItems` here is already exactly the current page's
return assetItems.filter((item) => { // rows for the active filters, nothing left to compute client-side.
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,
]);
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">( const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
"idle" "idle"
@@ -431,7 +502,7 @@ export default function AssetsPage() {
<SelectItem value="all">All</SelectItem> <SelectItem value="all">All</SelectItem>
{modelsForCategoryFilter.map((assetModel) => ( {modelsForCategoryFilter.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}> <SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name} {formatModelLabel(assetModel.manufacturer, assetModel.name)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -456,7 +527,12 @@ export default function AssetsPage() {
</div> </div>
<p className="pb-2 text-sm text-muted-foreground"> <p className="pb-2 text-sm text-muted-foreground">
{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`}
</p> </p>
</div> </div>
@@ -483,9 +559,9 @@ export default function AssetsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredAssetItems.map((assetItem, index) => ( {assetItems.map((assetItem, index) => (
<TableRow key={assetItem.id}> <TableRow key={assetItem.id}>
<TableCell>{index + 1}</TableCell> <TableCell>{(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1}</TableCell>
<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>
@@ -508,6 +584,32 @@ export default function AssetsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{!loading && !error && assetItemsTotal > 0 ? (
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<p className="text-sm text-muted-foreground">
Page {page} / {totalPages}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</Button>
</div>
) : null}
</section> </section>
<section className="flex max-w-2xl flex-col gap-3"> <section className="flex max-w-2xl flex-col gap-3">
@@ -593,7 +695,7 @@ export default function AssetsPage() {
<SelectContent> <SelectContent>
{assetModels.map((assetModel) => ( {assetModels.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}> <SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name} {formatModelLabel(assetModel.manufacturer, assetModel.name)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
+66 -5
View File
@@ -291,11 +291,72 @@ export async function createAssetItem(
} }
} }
export async function listAssetItems(): Promise<AssetItemRecord[]> { export interface ListAssetItemsOptions {
const rows = await prisma.assetItem.findMany({ page?: number;
orderBy: [{ createdAt: "desc" }, { id: "desc" }], pageSize?: number;
}); status?: AssetItemStatus;
return rows.map(toRecord); 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<ListAssetItemsResult> {
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 };
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------