From fd7b31e64da41dd06f88d535a57c7d99d588e25f Mon Sep 17 00:00:00 2001 From: "Claude (review-bot)" Date: Wed, 24 Jun 2026 16:09:54 +0530 Subject: [PATCH] feat(reports): drill from cost centre / accounting code into PO History (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 2 + App/app/(portal)/history/history-filters.tsx | 17 ++- App/app/(portal)/history/page.tsx | 38 ++---- .../reports/accounting-codes/[id]/page.tsx | 22 ++- .../reports/cost-centres/[id]/page.tsx | 16 ++- App/app/api/reports/export/route.ts | 40 ++---- App/lib/history-filter.ts | 68 ++++++++++ App/lib/reports.ts | 62 +++++++++ .../history-account-filter.test.ts | 128 ++++++++++++++++++ App/tests/unit/reports.test.ts | 45 ++++++ CHANGELOG.md | 1 + 11 files changed, 370 insertions(+), 69 deletions(-) create mode 100644 App/lib/history-filter.ts create mode 100644 App/tests/integration/history-account-filter.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7a40344..3dfd093 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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 `` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `` (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) diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx index 6ee2598..fe7db66 100644 --- a/App/app/(portal)/history/history-filters.tsx +++ b/App/app/(portal)/history/history-filters.tsx @@ -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(sp.getAll("status")); const [statusOpen, setStatusOpen] = useState(false); const statusRef = useRef(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) => )} +
+ + +
- +
diff --git a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx index b8033a4..837053b 100644 --- a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx +++ b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx @@ -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} /> - - ← Back to Accounting Codes - +
+ + ← Back to Accounting Codes + + + View POs · {periodLabel} → + +
@@ -93,9 +98,14 @@ export default async function CostCentreDetail({ exportHref={exportHref} /> - - ← Back to Cost Centres - +
+ + ← Back to Cost Centres + + + View POs · {periodLabel} → + +
diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index ee82eae..cdc3f0a 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -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 = { 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[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, diff --git a/App/lib/history-filter.ts b/App/lib/history-filter.ts new file mode 100644 index 0000000..cfaf1e6 --- /dev/null +++ b/App/lib/history-filter.ts @@ -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[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 { + const where: NonNullable = {}; + + 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; +} diff --git a/App/lib/reports.ts b/App/lib/reports.ts index 7ece38c..24c439c 100644 --- a/App/lib/reports.ts +++ b/App/lib/reports.ts @@ -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(); + 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 (Apr–Mar) + * - 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 0–11 + const year = fy + (month >= 9 ? 1 : 0); // Jan–Mar 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) }; +} diff --git a/App/tests/integration/history-account-filter.test.ts b/App/tests/integration/history-account-filter.test.ts new file mode 100644 index 0000000..275073a --- /dev/null +++ b/App/tests/integration/history-account-filter.test.ts @@ -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 2025–26 + +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); + }); +}); diff --git a/App/tests/unit/reports.test.ts b/App/tests/unit/reports.test.ts index 0eb63ec..3b7723d 100644 --- a/App/tests/unit/reports.test.ts +++ b/App/tests/unit/reports.test.ts @@ -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 (Apr–Mar)", () => { + 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 Jan–Mar 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 Apr–Mar to the Indian FY start year", () => { expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr diff --git a/CHANGELOG.md b/CHANGELOG.md index 855b5f8..1a14401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.