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
226 lines
8.8 KiB
TypeScript
226 lines
8.8 KiB
TypeScript
import { auth } from "@/auth";
|
||
import { db } from "@/lib/db";
|
||
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
||
import { redirect } from "next/navigation";
|
||
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;
|
||
dateTo?: string;
|
||
approvedFrom?: string;
|
||
approvedTo?: string;
|
||
vesselId?: string;
|
||
status?: string | string[];
|
||
page?: string;
|
||
perPage?: string;
|
||
}>;
|
||
}
|
||
|
||
export default async function HistoryPage({ searchParams }: Props) {
|
||
const session = await auth();
|
||
if (!session?.user) redirect("/login");
|
||
|
||
// Report-export holders see History; submitters get read+export access when the
|
||
// submitter-view-all feature flag is on.
|
||
if (
|
||
!hasPermission(session.user.role, "export_reports") &&
|
||
!submitterCanViewAll(session.user.role)
|
||
) {
|
||
redirect("/dashboard");
|
||
}
|
||
|
||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
||
await searchParams;
|
||
|
||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||
if (dateFrom || dateTo) {
|
||
const createdAt: { gte?: Date; lt?: Date } = {};
|
||
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
||
if (dateTo) {
|
||
const end = new Date(dateTo);
|
||
end.setDate(end.getDate() + 1);
|
||
createdAt.lt = end;
|
||
}
|
||
where.createdAt = createdAt;
|
||
}
|
||
if (approvedFrom || approvedTo) {
|
||
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||
if (approvedTo) {
|
||
const end = new Date(approvedTo);
|
||
end.setDate(end.getDate() + 1);
|
||
approvedAt.lt = end;
|
||
}
|
||
where.approvedAt = approvedAt;
|
||
}
|
||
if (vesselId) where.vesselId = vesselId;
|
||
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" },
|
||
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);
|
||
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||
if (vesselId) exportParams.set("vesselId", vesselId);
|
||
for (const s of statuses) exportParams.append("status", s);
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h1 className="text-2xl font-semibold text-neutral-900">PO History</h1>
|
||
<div className="flex items-center gap-2">
|
||
<a
|
||
href={`/api/reports/export?${exportParams.toString()}&format=pdf`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||
>
|
||
Export PDF
|
||
</a>
|
||
<a
|
||
href={`/api/reports/export?${exportParams.toString()}`}
|
||
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||
>
|
||
Export CSV
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<Suspense>
|
||
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||
</Suspense>
|
||
|
||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
|
||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-neutral-100">
|
||
{orders.map((po) => (
|
||
<tr
|
||
key={po.id}
|
||
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
||
{po.poNumber}
|
||
</Link>
|
||
</td>
|
||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||
<td className="px-4 py-3">
|
||
<PoStatusBadge status={po.status} />
|
||
</td>
|
||
<td className="px-4 py-3 text-right font-mono text-xs">
|
||
{formatCurrency(Number(po.totalAmount), po.currency)}
|
||
</td>
|
||
<td className="px-4 py-3 text-neutral-500">{formatDate(po.createdAt)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{orders.length === 0 && (
|
||
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
||
)}
|
||
</div>
|
||
{total > 0 && (
|
||
<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>
|
||
);
|
||
}
|