pelagia-portal/App/app/(portal)/history/page.tsx
Claude (auto-fix) 5cefe8f7ed
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 32s
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
2026-06-24 03:26:47 +05:30

226 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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