pelagia-portal/App/app/(portal)/history/page.tsx
Claude (review-bot) d0e43135f8
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 31s
feat(reports): drill from cost centre / accounting code into PO History (#126)
Report detail pages now link to the underlying POs, addressing the PR #126
review comment: drilling into a cost centre or accounting code opens PO
History pre-filtered to that dimension and the period in view.

- Cost Centre / Accounting Code detail pages gain a "View POs" link.
- periodRange() maps the on-screen period onto History's approved-date
  window (weekly→month, monthly→FY, yearly→full span); spend is dated by
  approvedAt.
- PO History gains an accountId filter (any tree node, expanded to leaves
  via accountLeafIds()) matching PO-level OR line-item accounts — the same
  basis the reports use.
- History page + CSV/PDF export share one buildPoHistoryWhere() builder so
  they never diverge.
- Tests: unit (periodRange, accountLeafIds) + integration (History account
  filter across PO-level/line-item, with the approved window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:03:17 +05:30

221 lines
8.9 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 { buildAccountGroups } from "@/lib/cost-centre-groups";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { resolvePagination } from "@/lib/pagination";
import { Suspense } from "react";
import type { Metadata } from "next";
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;
accountId?: 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, accountId, status, page: pageParam, perPage: perPageParam } =
await searchParams;
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
const where = await buildPoHistoryWhere({
dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses,
});
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, leafAccounts] = 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 } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
]);
const accounts = buildAccountGroups(leafAccounts);
// 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);
if (accountId) pageParams.set("accountId", accountId);
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);
if (accountId) exportParams.set("accountId", accountId);
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} accounts={accounts} 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">Accounting Code</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">
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.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>
);
}