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
+66 -5
View File
@@ -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 };
}
// ---------------------------------------------------------------------