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 }); + }); +});