pelagia-portal/App/app/(portal)/history/page.tsx
Claude (auto-fix) 3e8f5fb0c7
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 30s
feat(history): add Accounting Code search filter to PO History
PO History had no way to narrow by accounting code. Add an "Accounting
Code" filter (the shared type-to-search combobox) alongside Cost Centre,
backed by the PO-level account already included in the query.

- history/page.tsx: read `accountId` searchParam, fetch selectable leaf
  accounting codes (active, no children) via buildAccountGroups, apply
  `where.accountId`, thread the param into pagination + export links, and
  surface an Accounting Code column for context.
- history-filters.tsx: new SearchableSelect control wired into
  buildParams/apply/clear/hasFilters like the Cost Centre select.
- api/reports/export: apply the same `accountId` filter so CSV/PDF export
  respects the on-screen filter.
- tests/staging: verify picking a code drives an `accountId` query param.

Fixes #121

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:18:00 +05:30

242 lines
9.7 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 { 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;
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 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;
if (accountId) where.accountId = accountId;
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, 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>
);
}