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
+146 -44
View File
@@ -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<AssetItemRecord>("/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<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
@@ -142,22 +152,106 @@ export default function AssetsPage() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(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<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
// in the table (2026-08-25 -- was previously the literal FK value).
const modelNameById = useMemo(() => {
const map = new Map<string, string>();
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() {
<SelectItem value="all">All</SelectItem>
{modelsForCategoryFilter.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name}
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
</SelectItem>
))}
</SelectContent>
@@ -456,7 +527,12 @@ export default function AssetsPage() {
</div>
<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>
</div>
@@ -483,9 +559,9 @@ export default function AssetsPage() {
</TableRow>
</TableHeader>
<TableBody>
{filteredAssetItems.map((assetItem, index) => (
{assetItems.map((assetItem, index) => (
<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.asset_tag ?? "-"}</TableCell>
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
@@ -508,6 +584,32 @@ export default function AssetsPage() {
</TableBody>
</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 className="flex max-w-2xl flex-col gap-3">
@@ -593,7 +695,7 @@ export default function AssetsPage() {
<SelectContent>
{assetModels.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name}
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
</SelectItem>
))}
</SelectContent>