diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx
index 54e7f72..6ee2598 100644
--- a/App/app/(portal)/history/history-filters.tsx
+++ b/App/app/(portal)/history/history-filters.tsx
@@ -19,12 +19,18 @@ const STATUSES = [
interface Props {
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 sp = useSearchParams();
+ const perPage = perPageOptions.includes(Number(sp.get("perPage")))
+ ? Number(sp.get("perPage"))
+ : defaultPerPage;
+
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
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();
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
@@ -58,12 +65,24 @@ export function HistoryFilters({ vessels }: Props) {
if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId);
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() {
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;
@@ -139,6 +158,13 @@ export function HistoryFilters({ vessels }: Props) {
Clear
)}
+
+
+
+
);
diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx
index 23d7f17..eb9c996 100644
--- a/App/app/(portal)/history/page.tsx
+++ b/App/app/(portal)/history/page.tsx
@@ -6,12 +6,16 @@ import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters";
+import { resolvePagination } from "@/lib/pagination";
import { Suspense } from "react";
import type { Metadata } from "next";
import type { POStatus } from "@prisma/client";
export const metadata: Metadata = { title: "History" };
+const PER_PAGE_OPTIONS = [25, 50, 100];
+const DEFAULT_PER_PAGE = 25;
+
interface Props {
searchParams: Promise<{
dateFrom?: string;
@@ -20,6 +24,8 @@ interface Props {
approvedTo?: string;
vesselId?: string;
status?: string | string[];
+ page?: string;
+ perPage?: string;
}>;
}
@@ -36,7 +42,8 @@ export default async function HistoryPage({ searchParams }: Props) {
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[0]>["where"] = {};
if (dateFrom || dateTo) {
@@ -63,16 +70,45 @@ export default async function HistoryPage({ searchParams }: Props) {
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
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([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { createdAt: "desc" },
- take: 200,
+ skip,
+ take,
}),
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" });
if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo);
@@ -104,7 +140,7 @@ export default async function HistoryPage({ searchParams }: Props) {
-
+
@@ -149,8 +185,41 @@ export default async function HistoryPage({ searchParams }: Props) {
No purchase orders found.
)}
- {orders.length === 200 && (
- Showing first 200 results — refine filters to narrow results.
+ {total > 0 && (
+
+
+ Showing {firstRow}–{lastRow} of {total}
+
+
+ {page > 1 ? (
+
+ Previous
+
+ ) : (
+
+ Previous
+
+ )}
+
+ Page {page} of {totalPages}
+
+ {page < totalPages ? (
+
+ Next
+
+ ) : (
+
+ Next
+
+ )}
+
+
)}
);
diff --git a/App/lib/pagination.ts b/App/lib/pagination.ts
new file mode 100644
index 0000000..6530bd1
--- /dev/null
+++ b/App/lib/pagination.ts
@@ -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 };
+}
diff --git a/App/tests/unit/pagination.test.ts b/App/tests/unit/pagination.test.ts
new file mode 100644
index 0000000..e74db5d
--- /dev/null
+++ b/App/tests/unit/pagination.test.ts
@@ -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 });
+ });
+});