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:
+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