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>
221 lines
8.9 KiB
TypeScript
221 lines
8.9 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 { 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>
|
||
);
|
||
}
|