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 { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
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
|
// Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
|
||||||
// /api from route protection, so this checks the session itself. Flat
|
// /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 });
|
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();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetItems = await listAssetItems();
|
const url = new URL(request.url);
|
||||||
return NextResponse.json(assetItems, { status: 200 });
|
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";
|
"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 { useApiList } from "@/hooks/use-api-list";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -114,14 +114,24 @@ function formatDateMMDDYYYY(isoString: string | null): string {
|
|||||||
return `${mm}/${dd}/${yyyy}`;
|
return `${mm}/${dd}/${yyyy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssetsPage() {
|
// "Manufacturer Name" for display -- except a chunk of AssetModel rows
|
||||||
const {
|
// from the 2026-08-24 real-data import already have the manufacturer
|
||||||
data: assetItems,
|
// baked into `name` (the source file's own "Model" column did this for
|
||||||
loading,
|
// some rows, e.g. name="Logitech Group" with manufacturer="Logitech"),
|
||||||
error,
|
// which without this check renders as "Logitech Logitech Group". Skip the
|
||||||
refetch,
|
// manufacturer prefix when `name` already starts with it, case-insensitive.
|
||||||
} = useApiList<AssetItemRecord>("/api/assets");
|
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: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
||||||
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
||||||
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
||||||
@@ -142,22 +152,106 @@ export default function AssetsPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Filter/search state -- client-side only (assetItems is already the
|
// Filter/search + pagination state (2026-08-25 -- was client-side-only
|
||||||
// full list from useApiList, and this app's actual scale, ~600 rows
|
// filtering over the full ~600+-row table; moved server-side, see
|
||||||
// after the 2026-08-24 real-data import, is well within "just filter
|
// src/lib/assets.ts's listAssetItems() doc comment for why). searchText
|
||||||
// in memory" territory; no need for a server-side filtered endpoint).
|
// is debounced into debouncedSearchText so typing doesn't fire a
|
||||||
|
// request per keystroke.
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [debouncedSearchText, setDebouncedSearchText] = useState("");
|
||||||
const [filterStatus, setFilterStatus] = useState("all");
|
const [filterStatus, setFilterStatus] = useState("all");
|
||||||
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
||||||
const [filterModelId, setFilterModelId] = useState("all");
|
const [filterModelId, setFilterModelId] = useState("all");
|
||||||
const [filterAreaId, setFilterAreaId] = 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
|
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
||||||
// in the table (2026-08-25 -- was previously the literal FK value).
|
// in the table (2026-08-25 -- was previously the literal FK value).
|
||||||
const modelNameById = useMemo(() => {
|
const modelNameById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const m of assetModels) {
|
for (const m of assetModels) {
|
||||||
map.set(m.id, `${m.manufacturer} ${m.name}`);
|
map.set(m.id, formatModelLabel(m.manufacturer, m.name));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [assetModels]);
|
}, [assetModels]);
|
||||||
@@ -222,32 +316,9 @@ export default function AssetsPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [assignments, employeeNameById]);
|
}, [assignments, employeeNameById]);
|
||||||
|
|
||||||
const filteredAssetItems = useMemo(() => {
|
// Filtering + pagination both happen server-side now (see the effect
|
||||||
const query = searchText.trim().toLowerCase();
|
// above) -- `assetItems` here is already exactly the current page's
|
||||||
return assetItems.filter((item) => {
|
// rows for the active filters, nothing left to compute client-side.
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
||||||
"idle"
|
"idle"
|
||||||
@@ -431,7 +502,7 @@ export default function AssetsPage() {
|
|||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
{modelsForCategoryFilter.map((assetModel) => (
|
{modelsForCategoryFilter.map((assetModel) => (
|
||||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||||
{assetModel.manufacturer} {assetModel.name}
|
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -456,7 +527,12 @@ export default function AssetsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="pb-2 text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -483,9 +559,9 @@ export default function AssetsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAssetItems.map((assetItem, index) => (
|
{assetItems.map((assetItem, index) => (
|
||||||
<TableRow key={assetItem.id}>
|
<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.serial_number}</TableCell>
|
||||||
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
||||||
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
||||||
@@ -508,6 +584,32 @@ export default function AssetsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
|
|
||||||
<section className="flex max-w-2xl flex-col gap-3">
|
<section className="flex max-w-2xl flex-col gap-3">
|
||||||
@@ -593,7 +695,7 @@ export default function AssetsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{assetModels.map((assetModel) => (
|
{assetModels.map((assetModel) => (
|
||||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||||
{assetModel.manufacturer} {assetModel.name}
|
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
+65
-4
@@ -291,11 +291,72 @@ export async function createAssetItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAssetItems(): Promise<AssetItemRecord[]> {
|
export interface ListAssetItemsOptions {
|
||||||
const rows = await prisma.assetItem.findMany({
|
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" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
});
|
skip: (page - 1) * pageSize,
|
||||||
return rows.map(toRecord);
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.assetItem.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items: rows.map(toRecord), total };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user