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:
@@ -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<NextResponse> {
|
||||
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();
|
||||
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 });
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
+66
-5
@@ -291,11 +291,72 @@ export async function createAssetItem(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssetItems(): Promise<AssetItemRecord[]> {
|
||||
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<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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user