Server-side pagination for /assets (50/page), fix duplicated manufacturer in model labels
CI / test (push) Failing after 8s
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:
+146
-44
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user