feat(reports): drill from cost centre / accounting code into PO History (#126)
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 32s

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>
This commit is contained in:
Claude (review-bot) 2026-06-24 16:09:54 +05:30
parent 4ed27d668b
commit fd7b31e64d
11 changed files with 370 additions and 69 deletions

View file

@ -164,6 +164,8 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep.
Sites are **not** cost centres (only vessels are).
### Crewing (feature-flagged)

View file

@ -19,11 +19,12 @@ const STATUSES = [
interface Props {
vessels: { id: string; name: string }[];
accounts: { id: string; code: string; name: string }[];
perPageOptions: number[];
defaultPerPage: number;
}
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) {
const router = useRouter();
const sp = useSearchParams();
@ -36,6 +37,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(null);
@ -64,6 +66,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
if (approvedFrom) params.set("approvedFrom", approvedFrom);
if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId);
if (accountId) params.set("accountId", accountId);
for (const s of statuses) params.append("status", s);
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
return params;
@ -78,14 +81,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
}
function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
const params = new URLSearchParams();
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
const qs = params.toString();
router.push(qs ? `/history?${qs}` : "/history");
}
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || accountId || statuses.length > 0;
const statusLabel =
statuses.length === 0
@ -125,6 +128,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
<select value={accountId} onChange={(e) => setAccountId(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="">All accounting codes</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.code} · {a.name}</option>)}
</select>
</div>
<div className="relative" ref={statusRef}>
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
<button type="button" onClick={() => setStatusOpen((o) => !o)}

View file

@ -6,10 +6,10 @@ 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 { buildPoHistoryWhere } from "@/lib/history-filter";
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" };
@ -23,6 +23,7 @@ interface Props {
approvedFrom?: string;
approvedTo?: string;
vesselId?: string;
accountId?: string;
status?: string | string[];
page?: string;
perPage?: string;
@ -42,33 +43,13 @@ export default async function HistoryPage({ searchParams }: Props) {
redirect("/dashboard");
}
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
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;
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
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({
@ -79,7 +60,7 @@ export default async function HistoryPage({ searchParams }: Props) {
defaultPerPage: DEFAULT_PER_PAGE,
});
const [orders, vessels] = await Promise.all([
const [orders, vessels, accounts] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
@ -88,6 +69,7 @@ export default async function HistoryPage({ searchParams }: Props) {
take,
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({ orderBy: { code: "asc" }, select: { id: true, code: true, name: true } }),
]);
// Shared filter params for the pagination footer links (everything except `page`).
@ -97,6 +79,7 @@ export default async function HistoryPage({ searchParams }: Props) {
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));
@ -115,6 +98,7 @@ export default async function HistoryPage({ searchParams }: Props) {
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 (
@ -140,7 +124,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div>
<Suspense>
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
<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">

View file

@ -11,6 +11,7 @@ import {
accountNodeWeekly,
costCentresForAccount,
childBreakdown,
periodRange,
parseGranularity,
resolveFy,
resolveMonth,
@ -89,6 +90,10 @@ export default async function AccountingCodeDetail({
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
// Drill into the POs behind this spend: PO History filtered to this accounting
// code (expanded to its leaves) over the period in view (dated by approvedAt).
const { from, to } = periodRange(gran, fy, month, ds.fys);
const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`;
const path = idx.pathTo(id);
const trail = [
@ -111,12 +116,17 @@ export default async function AccountingCodeDetail({
exportHref={exportHref}
/>
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to Accounting Codes
</Link>
<div className="mb-4 flex items-center justify-between gap-3">
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to Accounting Codes
</Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 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">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle
title={`${node.code} · ${node.name}`}

View file

@ -10,6 +10,7 @@ import {
costCentreRows,
costCentreWeekly,
topAccountsForCostCentre,
periodRange,
parseGranularity,
resolveFy,
resolveMonth,
@ -80,6 +81,10 @@ export default async function CostCentreDetail({
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
// Drill into the POs behind this spend: PO History filtered to this cost centre
// over the period currently in view (spend is dated by approvedAt).
const { from, to } = periodRange(gran, fy, month, ds.fys);
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
return (
<div>
@ -93,9 +98,14 @@ export default async function CostCentreDetail({
exportHref={exportHref}
/>
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres
</Link>
<div className="mb-4 flex items-center justify-between gap-3">
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres
</Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 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">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />

View file

@ -1,8 +1,8 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client";
const PO_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
@ -25,36 +25,16 @@ export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const format = sp.get("format") ?? "csv";
const dateFrom = sp.get("dateFrom");
const dateTo = sp.get("dateTo");
const approvedFrom = sp.get("approvedFrom");
const approvedTo = sp.get("approvedTo");
const vesselId = sp.get("vesselId");
const statuses = sp.getAll("status").filter(Boolean);
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 (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const where = await buildPoHistoryWhere({
dateFrom: sp.get("dateFrom"),
dateTo: sp.get("dateTo"),
approvedFrom: sp.get("approvedFrom"),
approvedTo: sp.get("approvedTo"),
vesselId: sp.get("vesselId"),
accountId: sp.get("accountId"),
statuses: sp.getAll("status"),
});
const orders = await db.purchaseOrder.findMany({
where,

68
App/lib/history-filter.ts Normal file
View file

@ -0,0 +1,68 @@
/**
* Shared `where` builder for the PO History list (`/history` page) and its
* CSV/PDF export route, so the two never drift. Filters: created-date range,
* approved-date range, cost centre (vessel), status, and for report
* drill-downs (issue #124 review) an accounting code.
*
* The `accountId` filter accepts any account-tree node (Heading / Sub-heading /
* Leaf); it expands to the leaf codes underneath via `accountLeafIds` and
* matches a PO whose **PO-level account** or **any line item account** is in
* that leaf set the same attribution basis the spend reports use.
*/
import { db } from "@/lib/db";
import { accountLeafIds } from "@/lib/reports";
import type { POStatus } from "@prisma/client";
type PoWhere = NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"];
export interface HistoryFilterParams {
dateFrom?: string | null;
dateTo?: string | null;
approvedFrom?: string | null;
approvedTo?: string | null;
vesselId?: string | null;
accountId?: string | null;
statuses?: string[];
}
export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise<PoWhere> {
const where: NonNullable<PoWhere> = {};
if (p.dateFrom || p.dateTo) {
const createdAt: { gte?: Date; lt?: Date } = {};
if (p.dateFrom) createdAt.gte = new Date(p.dateFrom);
if (p.dateTo) {
const end = new Date(p.dateTo);
end.setDate(end.getDate() + 1);
createdAt.lt = end;
}
where.createdAt = createdAt;
}
if (p.approvedFrom || p.approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom);
if (p.approvedTo) {
const end = new Date(p.approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (p.vesselId) where.vesselId = p.vesselId;
const statuses = (p.statuses ?? []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
if (p.accountId) {
const accounts = await db.account.findMany({ select: { id: true, parentId: true } });
const leaves = accountLeafIds(accounts, p.accountId);
where.OR = [
{ accountId: { in: leaves } },
{ lineItems: { some: { accountId: { in: leaves } } } },
];
}
return where;
}

View file

@ -350,3 +350,65 @@ export function parseSel(v: string | undefined): string[] {
export function toggleSel(sel: string[], id: string): string[] {
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
}
// ── Report → PO drill-down ─────────────────────────────────────────────────
/**
* Leaf account ids under `accountId` (the node itself when it is already a
* leaf), from the raw `{ id, parentId }` account rows. A report drill-down can
* target any tier, but a PO / line item only ever carries a leaf code so this
* translates a drilled node into the concrete leaf set PO History filters by.
* Returns `[]` for an unknown id.
*/
export function accountLeafIds(
accounts: { id: string; parentId: string | null }[],
accountId: string,
): string[] {
const ids = new Set(accounts.map((a) => a.id));
if (!ids.has(accountId)) return [];
const kids = new Map<string, string[]>();
for (const a of accounts) {
if (a.parentId === null) continue;
if (!kids.has(a.parentId)) kids.set(a.parentId, []);
kids.get(a.parentId)!.push(a.id);
}
const out: string[] = [];
const walk = (id: string) => {
const cs = kids.get(id) ?? [];
if (cs.length === 0) out.push(id);
else cs.forEach(walk);
};
walk(accountId);
return out;
}
/**
* The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report
* detail view currently shows, so drilling into the underlying POs carries
* "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by
* `approvedAt`). Mirrors the on-screen period label:
* - weekly the focused FY month
* - monthly the whole selected FY (AprMar)
* - yearly the full span of FYs in the dataset
*/
export function periodRange(
gran: Granularity,
fy: number,
month: number,
fys: number[],
): { from: string; to: string } {
const iso = (y: number, m: number, d: number) =>
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
if (gran === "yearly") {
const first = fys[0] ?? fy;
const last = fys[fys.length - 1] ?? fy;
return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) };
}
if (gran === "weekly") {
const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 011
const year = fy + (month >= 9 ? 1 : 0); // JanMar roll into the next calendar year
const lastDay = new Date(year, cal + 1, 0).getDate();
return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) };
}
return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) };
}

View file

@ -0,0 +1,128 @@
/**
* Integration test for the PO History accounting-code filter (PR #126 review).
*
* Report drill-downs link from a cost-centre / accounting-code detail page into
* `/history` with the code (and period) applied as filters. `buildPoHistoryWhere`
* powers both the History page and its export route. The accounting-code filter
* accepts any tree node and must match a PO whose **PO-level account** OR **any
* line item account** falls under that node's leaves the same attribution
* basis the spend reports use.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { db } from "@/lib/db";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_HIST_ACCT_";
const CODE = `INTTEST_${Date.now()}_`;
let submitterId: string;
let vesselId: string;
let topId: string; // heading
let leafAId: string;
let leafBId: string;
let leafXId: string; // unrelated leaf
const approvedAt = new Date("2025-06-15T12:00:00Z"); // FY 202526
async function makePo(opts: {
title: string;
accountId: string;
lineAccountId?: string | null;
}) {
return db.purchaseOrder.create({
data: {
poNumber: `${PREFIX}${opts.title}`,
title: `${PREFIX}${opts.title}`,
status: "CLOSED",
totalAmount: 1000,
approvedAt,
submitterId,
vesselId,
accountId: opts.accountId,
lineItems: {
create: [
{
name: "Item",
quantity: 1,
unit: "pc",
unitPrice: 1000,
totalPrice: 1000,
accountId: opts.lineAccountId ?? null,
},
],
},
},
});
}
beforeAll(async () => {
const [user, vessel] = await Promise.all([
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
db.vessel.findFirstOrThrow(),
]);
submitterId = user.id;
vesselId = vessel.id;
// Brand-new account subtree (T → S → A, B) + an unrelated leaf X, so no
// pre-existing prod PO references them and counts isolate to our fixtures.
const top = await db.account.create({ data: { code: `${CODE}T`, name: "IntTest Top" } });
const sub = await db.account.create({ data: { code: `${CODE}S`, name: "IntTest Sub", parentId: top.id } });
const a = await db.account.create({ data: { code: `${CODE}A`, name: "IntTest Leaf A", parentId: sub.id } });
const b = await db.account.create({ data: { code: `${CODE}B`, name: "IntTest Leaf B", parentId: sub.id } });
const x = await db.account.create({ data: { code: `${CODE}X`, name: "IntTest Leaf X" } });
topId = top.id;
leafAId = a.id;
leafBId = b.id;
leafXId = x.id;
await makePo({ title: "po1_levelA", accountId: leafAId }); // PO-level A
await makePo({ title: "po2_levelX_lineB", accountId: leafXId, lineAccountId: leafBId }); // line item B
await makePo({ title: "po3_levelX", accountId: leafXId }); // X only
});
afterAll(async () => {
await deletePosByTitle(PREFIX);
await db.account.deleteMany({ where: { code: { startsWith: CODE } } });
});
async function countMine(accountId: string) {
const where = await buildPoHistoryWhere({ accountId });
return db.purchaseOrder.count({ where: { ...where, title: { startsWith: PREFIX } } });
}
describe("PO History accounting-code filter", () => {
it("a heading expands to its leaves and matches PO-level or line-item accounts", async () => {
// T → {A, B}: po1 (PO-level A) and po2 (line item B); not po3 (X only).
expect(await countMine(topId)).toBe(2);
});
it("a leaf matches only POs carrying that exact code (PO-level)", async () => {
expect(await countMine(leafAId)).toBe(1); // po1
});
it("matches a PO via a line-item account even when the PO-level account differs", async () => {
expect(await countMine(leafBId)).toBe(1); // po2 (PO-level X, line item B)
});
it("the unrelated leaf matches its own POs", async () => {
expect(await countMine(leafXId)).toBe(2); // po2 + po3 (both PO-level X)
});
it("combines with the approved-date window", async () => {
const inWindow = await buildPoHistoryWhere({
accountId: topId,
approvedFrom: "2025-04-01",
approvedTo: "2026-03-31",
});
expect(await db.purchaseOrder.count({ where: { ...inWindow, title: { startsWith: PREFIX } } })).toBe(2);
const outOfWindow = await buildPoHistoryWhere({
accountId: topId,
approvedFrom: "2024-04-01",
approvedTo: "2025-03-31",
});
expect(await db.purchaseOrder.count({ where: { ...outOfWindow, title: { startsWith: PREFIX } } })).toBe(0);
});
});

View file

@ -21,6 +21,8 @@ import {
parseSel,
toggleSel,
allocatePoSpend,
accountLeafIds,
periodRange,
type ReportDataset,
type AccountNode,
} from "@/lib/reports";
@ -49,6 +51,49 @@ const DS: ReportDataset = {
],
};
describe("accountLeafIds (report → PO drill-down)", () => {
const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId }));
it("expands a heading to every leaf underneath it", () => {
expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]);
expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]);
});
it("returns a leaf node as itself", () => {
expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]);
});
it("returns [] for an unknown id", () => {
expect(accountLeafIds(RAW, "nope")).toEqual([]);
});
});
describe("periodRange (report → PO History approved window)", () => {
it("monthly → the whole selected FY (AprMar)", () => {
expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({
from: "2025-04-01",
to: "2026-03-31",
});
});
it("yearly → the full span of FYs in the dataset", () => {
expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({
from: "2024-04-01",
to: "2026-03-31",
});
});
it("weekly → the focused FY month (Apr=0)", () => {
expect(periodRange("weekly", 2025, 0, [2025])).toEqual({
from: "2025-04-01",
to: "2025-04-30",
});
});
it("weekly → a JanMar month rolls into the next calendar year", () => {
// FY-month index 9 = Jan, which belongs to calendar year fy+1.
expect(periodRange("weekly", 2025, 9, [2025])).toEqual({
from: "2026-01-01",
to: "2026-01-31",
});
});
});
describe("financial-year helpers", () => {
it("maps AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr

View file

@ -4,6 +4,7 @@
### Added
- **Report → PO drill-down** (#126) — the Cost Centre and Accounting Code report detail pages gain a **"View POs"** link that opens **PO History** pre-filtered to that cost centre / accounting code and the period currently in view (mapped to the approved-date window, since spend is dated by `approvedAt`). PO History gains an **Accounting Code** filter that accepts any tree node and matches a PO whose PO-level account **or** any line-item account falls under that node's leaves. The History page and its CSV/PDF export share one `buildPoHistoryWhere` builder so they never diverge.
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
- **3-level accounting-code hierarchy**`Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.