feat(history): paginate PO history with items-per-page control
The /history page fetched a fixed first 200 POs in one flat table with no
way to page further and no control over page size. Replace that with real
pagination:
- page.tsx: read page/perPage from searchParams; clamp perPage to 25/50/100
(default 25) and page to [1, totalPages] via a new shared resolvePagination
helper. Swap the fixed take:200 for skip/take + a count() for totals.
Replace the "first 200 results" notice with a footer ("Showing X-Y of N",
Prev/Next, page indicator) that preserves all filters. Export PDF/CSV links
stay on the full filtered set.
- history-filters.tsx: add a Per-page dropdown; changing it or any filter
resets to page 1 while preserving perPage in the URL.
- lib/pagination.ts: dependency-free clamp/skip/take helper, unit-tested.
Verified: type-check clean, 272 unit tests pass (9 new), skip/take windows
and clamping checked against the test DB.
Fixes #104
This commit is contained in:
parent
cc7161d5ed
commit
5cefe8f7ed
4 changed files with 216 additions and 9 deletions
|
|
@ -19,12 +19,18 @@ const STATUSES = [
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
vessels: { id: string; name: string }[];
|
||||||
|
perPageOptions: number[];
|
||||||
|
defaultPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels }: Props) {
|
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
|
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
|
||||||
|
? Number(sp.get("perPage"))
|
||||||
|
: defaultPerPage;
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||||
|
|
@ -50,7 +56,8 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply() {
|
// Changing any filter resets to page 1; perPage is preserved across applies.
|
||||||
|
function buildParams(nextPerPage: number) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
|
|
@ -58,12 +65,24 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
router.push(`/history?${params.toString()}`);
|
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
router.push(`/history?${buildParams(perPage).toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePerPage(next: number) {
|
||||||
|
router.push(`/history?${buildParams(next).toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||||
router.push("/history");
|
const params = new URLSearchParams();
|
||||||
|
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `/history?${qs}` : "/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||||
|
|
@ -139,6 +158,13 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<label htmlFor="perPage" className="text-xs font-medium text-neutral-600">Per page</label>
|
||||||
|
<select id="perPage" value={perPage} onChange={(e) => changePerPage(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-neutral-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
||||||
|
{perPageOptions.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,16 @@ import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { HistoryFilters } from "./history-filters";
|
import { HistoryFilters } from "./history-filters";
|
||||||
|
import { resolvePagination } from "@/lib/pagination";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { POStatus } from "@prisma/client";
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "History" };
|
export const metadata: Metadata = { title: "History" };
|
||||||
|
|
||||||
|
const PER_PAGE_OPTIONS = [25, 50, 100];
|
||||||
|
const DEFAULT_PER_PAGE = 25;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
|
|
@ -20,6 +24,8 @@ interface Props {
|
||||||
approvedTo?: string;
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +42,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
||||||
|
await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -63,16 +70,45 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
|
const total = await db.purchaseOrder.count({ where });
|
||||||
|
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
||||||
|
perPageParam,
|
||||||
|
pageParam,
|
||||||
|
total,
|
||||||
|
options: PER_PAGE_OPTIONS,
|
||||||
|
defaultPerPage: DEFAULT_PER_PAGE,
|
||||||
|
});
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, account: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 200,
|
skip,
|
||||||
|
take,
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Shared filter params for the pagination footer links (everything except `page`).
|
||||||
|
const pageParams = new URLSearchParams();
|
||||||
|
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
||||||
|
if (dateTo) pageParams.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||||
|
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||||
|
for (const s of statuses) pageParams.append("status", s);
|
||||||
|
pageParams.set("perPage", String(perPage));
|
||||||
|
|
||||||
|
const pageHref = (p: number) => {
|
||||||
|
const params = new URLSearchParams(pageParams);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/history?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstRow = total === 0 ? 0 : skip + 1;
|
||||||
|
const lastRow = skip + orders.length;
|
||||||
|
|
||||||
const exportParams = new URLSearchParams({ format: "csv" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
|
|
@ -104,7 +140,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<HistoryFilters vessels={vessels} />
|
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
@ -149,8 +185,41 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{orders.length === 200 && (
|
{total > 0 && (
|
||||||
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results — refine filters to narrow results.</p>
|
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
|
||||||
|
<span>
|
||||||
|
Showing {firstRow}–{lastRow} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={pageHref(page - 1)}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link
|
||||||
|
href={pageHref(page + 1)}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
47
App/lib/pagination.ts
Normal file
47
App/lib/pagination.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Shared, dependency-free pagination math used by list pages (e.g. PO History).
|
||||||
|
// Keeps page-size validation and out-of-range page clamping in one testable place.
|
||||||
|
|
||||||
|
export interface PaginationInput {
|
||||||
|
/** Raw `perPage` value from the query string (may be undefined / invalid). */
|
||||||
|
perPageParam?: string | number;
|
||||||
|
/** Raw `page` value from the query string (may be undefined / invalid). */
|
||||||
|
pageParam?: string | number;
|
||||||
|
/** Total number of rows matching the current filter. */
|
||||||
|
total: number;
|
||||||
|
/** Allowed page sizes; anything else falls back to `defaultPerPage`. */
|
||||||
|
options: number[];
|
||||||
|
defaultPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pagination {
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
/** Rows to skip for the current page (Prisma `skip`). */
|
||||||
|
skip: number;
|
||||||
|
/** Rows to take for the current page (Prisma `take`). */
|
||||||
|
take: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a safe page size and (1-based) page number from untrusted query
|
||||||
|
* params. `perPage` is clamped to the allowed `options`; `page` is clamped to
|
||||||
|
* `[1, totalPages]` so an out-of-range or non-numeric page never paginates past
|
||||||
|
* the last page.
|
||||||
|
*/
|
||||||
|
export function resolvePagination({
|
||||||
|
perPageParam,
|
||||||
|
pageParam,
|
||||||
|
total,
|
||||||
|
options,
|
||||||
|
defaultPerPage,
|
||||||
|
}: PaginationInput): Pagination {
|
||||||
|
const perPage = options.includes(Number(perPageParam)) ? Number(perPageParam) : defaultPerPage;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||||
|
const requested = Number(pageParam);
|
||||||
|
const page = Math.min(
|
||||||
|
Math.max(1, Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 1),
|
||||||
|
totalPages,
|
||||||
|
);
|
||||||
|
return { perPage, page, totalPages, skip: (page - 1) * perPage, take: perPage };
|
||||||
|
}
|
||||||
65
App/tests/unit/pagination.test.ts
Normal file
65
App/tests/unit/pagination.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolvePagination } from "@/lib/pagination";
|
||||||
|
|
||||||
|
const OPTIONS = [25, 50, 100];
|
||||||
|
const DEFAULT = 25;
|
||||||
|
|
||||||
|
function resolve(perPageParam: string | number | undefined, pageParam: string | number | undefined, total: number) {
|
||||||
|
return resolvePagination({ perPageParam, pageParam, total, options: OPTIONS, defaultPerPage: DEFAULT });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolvePagination", () => {
|
||||||
|
it("defaults perPage and page when params are missing", () => {
|
||||||
|
expect(resolve(undefined, undefined, 200)).toEqual({
|
||||||
|
perPage: 25,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 8,
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts allowed page sizes", () => {
|
||||||
|
expect(resolve("50", "1", 200).perPage).toBe(50);
|
||||||
|
expect(resolve("100", "1", 200).perPage).toBe(100);
|
||||||
|
expect(resolve(50, "1", 200).perPage).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default for disallowed or non-numeric page sizes", () => {
|
||||||
|
expect(resolve("10", "1", 200).perPage).toBe(25);
|
||||||
|
expect(resolve("999", "1", 200).perPage).toBe(25);
|
||||||
|
expect(resolve("abc", "1", 200).perPage).toBe(25);
|
||||||
|
expect(resolve("0", "1", 200).perPage).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes skip/take for a middle page", () => {
|
||||||
|
const p = resolve("25", "3", 200);
|
||||||
|
expect(p).toMatchObject({ page: 3, skip: 50, take: 25, totalPages: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps a page beyond the last page to the last page", () => {
|
||||||
|
expect(resolve("25", "99", 200)).toMatchObject({ page: 8, totalPages: 8, skip: 175 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps non-positive / non-numeric page to 1", () => {
|
||||||
|
expect(resolve("25", "0", 200).page).toBe(1);
|
||||||
|
expect(resolve("25", "-5", 200).page).toBe(1);
|
||||||
|
expect(resolve("25", "abc", 200).page).toBe(1);
|
||||||
|
expect(resolve("25", undefined, 200).page).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional page numbers", () => {
|
||||||
|
expect(resolve("25", "2.9", 200).page).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always yields at least one page, even with zero rows", () => {
|
||||||
|
expect(resolve("25", "1", 0)).toMatchObject({ page: 1, totalPages: 1, skip: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a partial final page", () => {
|
||||||
|
// 23 rows, 25 per page -> single page holding all rows
|
||||||
|
expect(resolve("25", "1", 23)).toMatchObject({ page: 1, totalPages: 1, take: 25 });
|
||||||
|
// 60 rows, 25 per page -> 3 pages, last page holds 10
|
||||||
|
expect(resolve("25", "3", 60)).toMatchObject({ page: 3, totalPages: 3, skip: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue