Implements the wiki "Reports Mockup" as a Reports → Purchasing sidebar section, wired to real approved-PO spend. Two report families, each index → drill/detail: - Cost Centres (/reports/cost-centres) — spend compared across vessels; row opens a cost-centre report with a Top-accounting-codes breakdown re-pivotable by tier (Heading/Sub/Leaf) + Top-N. - Accounting Codes (/reports/accounting-codes) — drills the Account tree (headings → sub → leaves) via ?parent=; a leaf opens its report broken down by cost centre (or, for a non-leaf, by sub-account). Shared: a pinned filter toolbar (Granularity Monthly/Yearly, Financial Year, Show Top5/Top10/Bottom5/All) whose values live in the URL query so the server component re-renders — no client fetching. KPI tiles, recharts comparison/trend/ breakdown charts, per-row trend sparklines, and CSV export (/api/reports/spend). - lib/reports.ts: the pure, unit-tested aggregation core. Spend = a PO once it reaches POST_APPROVAL_STATUSES, dated by approvedAt, valued at totalAmount (the dashboard's basis); Indian Apr–Mar FY; each PO's leaf accountId rolled up to parents. One query in getReportDataset(), everything else pure. - Sidebar: new collapsible "Reports" section with a "Purchasing" subheading (subgroup support added to the Section model). Gated by view_analytics (Manager/SuperUser/Auditor/Admin); export by the same. Deferred (documented): synthetic Weekly granularity, the "Add to graph" custom multi-select, and line-item-level account allocation (v1 uses the PO-level account). Sites are not cost centres — only vessels. Tests: 11 unit cases for the aggregation core + 3 sidebar cases for the Reports section. Full unit suite 303 green; tsc clean. Smoke-tested all routes end to end against seed data (index/drill/detail/export 200; non-analytics role 307/403). Wiki: "Reports Mockup" marked implemented; "Pages and Navigation" lists the new routes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { db } from "@/lib/db";
|
||
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||
|
||
/**
|
||
* Spend reporting (Reports → Purchasing). Aggregates approved purchase-order
|
||
* spend across two dimensions:
|
||
* • Cost centres — the PO's vessel (`PurchaseOrder.vesselId`).
|
||
* • Accounting codes — the self-referential `Account` tree (Heading →
|
||
* Sub-heading → Leaf); each PO's `accountId` is a leaf, rolled up to parents.
|
||
*
|
||
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
|
||
* dated by `approvedAt` and valued at the full `totalAmount` — the same
|
||
* definition the dashboard's spend tiles use. Financial year is the Indian
|
||
* Apr–Mar year. The heavy lifting is a single query in `getReportDataset()`;
|
||
* everything below is pure functions over that dataset so they're unit-testable.
|
||
*/
|
||
|
||
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
|
||
|
||
/** Indian FY start year for a date (Apr–Mar): Jan–Mar belong to the prior year. */
|
||
export function fyStartYear(d: Date): number {
|
||
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
|
||
}
|
||
/** "FY 2025–26" for start year 2025. */
|
||
export function fyLabel(start: number): string {
|
||
return `FY ${start}–${String((start + 1) % 100).padStart(2, "0")}`;
|
||
}
|
||
/** Month index within the FY: Apr=0 … Mar=11. */
|
||
export function fyMonthIndex(d: Date): number {
|
||
return (d.getMonth() - 3 + 12) % 12;
|
||
}
|
||
|
||
export type Tier = "Heading" | "Sub-heading" | "Leaf";
|
||
|
||
export interface CostCentre {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
}
|
||
export interface AccountNode {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
parentId: string | null;
|
||
tier: Tier;
|
||
}
|
||
/** One row per spend PO. */
|
||
export interface SpendRow {
|
||
vesselId: string;
|
||
accountId: string;
|
||
amount: number;
|
||
fy: number;
|
||
month: number; // 0–11 within the FY
|
||
}
|
||
|
||
export interface ReportDataset {
|
||
rows: SpendRow[];
|
||
vessels: CostCentre[];
|
||
accounts: AccountNode[];
|
||
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
|
||
}
|
||
|
||
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
|
||
export async function getReportDataset(): Promise<ReportDataset> {
|
||
const [pos, vessels, accounts] = await Promise.all([
|
||
db.purchaseOrder.findMany({
|
||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
|
||
select: { vesselId: true, accountId: true, totalAmount: true, approvedAt: true },
|
||
}),
|
||
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
|
||
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
|
||
]);
|
||
|
||
const childCount = new Map<string, number>();
|
||
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
|
||
const accountNodes: AccountNode[] = accounts.map((a) => ({
|
||
id: a.id,
|
||
code: a.code,
|
||
name: a.name,
|
||
parentId: a.parentId,
|
||
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
|
||
}));
|
||
|
||
const rows: SpendRow[] = [];
|
||
for (const po of pos) {
|
||
if (!po.approvedAt) continue;
|
||
rows.push({
|
||
vesselId: po.vesselId,
|
||
accountId: po.accountId,
|
||
amount: Number(po.totalAmount),
|
||
fy: fyStartYear(po.approvedAt),
|
||
month: fyMonthIndex(po.approvedAt),
|
||
});
|
||
}
|
||
|
||
const fySet = new Set(rows.map((r) => r.fy));
|
||
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
|
||
|
||
return { rows, vessels, accounts: accountNodes, fys };
|
||
}
|
||
|
||
// ── Account tree helpers ───────────────────────────────────────────────────
|
||
|
||
export interface AccountIndex {
|
||
byId: Map<string, AccountNode>;
|
||
childrenOf: (parentId: string | null) => AccountNode[];
|
||
leavesUnder: (id: string) => Set<string>;
|
||
isLeaf: (id: string) => boolean;
|
||
pathTo: (id: string) => AccountNode[];
|
||
}
|
||
|
||
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
|
||
const byId = new Map(accounts.map((a) => [a.id, a]));
|
||
const kids = new Map<string | null, AccountNode[]>();
|
||
for (const a of accounts) {
|
||
const k = a.parentId;
|
||
if (!kids.has(k)) kids.set(k, []);
|
||
kids.get(k)!.push(a);
|
||
}
|
||
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
|
||
const isLeaf = (id: string) => childrenOf(id).length === 0;
|
||
|
||
const leafCache = new Map<string, Set<string>>();
|
||
function leavesUnder(id: string): Set<string> {
|
||
const cached = leafCache.get(id);
|
||
if (cached) return cached;
|
||
const out = new Set<string>();
|
||
const children = childrenOf(id);
|
||
if (children.length === 0) out.add(id);
|
||
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
|
||
leafCache.set(id, out);
|
||
return out;
|
||
}
|
||
function pathTo(id: string): AccountNode[] {
|
||
const node = byId.get(id);
|
||
if (!node) return [];
|
||
return node.parentId ? [...pathTo(node.parentId), node] : [node];
|
||
}
|
||
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
|
||
}
|
||
|
||
// ── Aggregations ───────────────────────────────────────────────────────────
|
||
|
||
export interface CostCentreSpend {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
total: number; // selected FY
|
||
months: number[]; // 12 (Apr–Mar) of the selected FY
|
||
poCount: number; // selected FY
|
||
fyTotals: number[]; // aligned to ds.fys
|
||
}
|
||
|
||
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
|
||
const idx = new Map<string, CostCentreSpend>();
|
||
for (const v of ds.vessels) {
|
||
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
|
||
}
|
||
for (const r of ds.rows) {
|
||
const row = idx.get(r.vesselId);
|
||
if (!row) continue;
|
||
const fi = ds.fys.indexOf(r.fy);
|
||
if (fi >= 0) row.fyTotals[fi] += r.amount;
|
||
if (r.fy === fy) {
|
||
row.months[r.month] += r.amount;
|
||
row.total += r.amount;
|
||
row.poCount += 1;
|
||
}
|
||
}
|
||
return [...idx.values()];
|
||
}
|
||
|
||
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
|
||
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
|
||
const leaves = idx.leavesUnder(nodeId);
|
||
const months = Array(12).fill(0);
|
||
const fyTotals = Array(ds.fys.length).fill(0);
|
||
let total = 0;
|
||
let poCount = 0;
|
||
for (const r of ds.rows) {
|
||
if (!leaves.has(r.accountId)) continue;
|
||
const fi = ds.fys.indexOf(r.fy);
|
||
if (fi >= 0) fyTotals[fi] += r.amount;
|
||
if (r.fy === fy) {
|
||
months[r.month] += r.amount;
|
||
total += r.amount;
|
||
poCount += 1;
|
||
}
|
||
}
|
||
return { total, months, fyTotals, poCount };
|
||
}
|
||
|
||
export interface NodeSpend {
|
||
node: AccountNode;
|
||
total: number;
|
||
months: number[];
|
||
fyTotals: number[];
|
||
poCount: number;
|
||
}
|
||
|
||
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
|
||
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
|
||
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
|
||
}
|
||
|
||
export interface Breakdown {
|
||
id: string;
|
||
label: string;
|
||
value: number;
|
||
}
|
||
|
||
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
|
||
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
|
||
return ds.accounts
|
||
.filter((a) => a.tier === tier)
|
||
.map((a) => {
|
||
const leaves = idx.leavesUnder(a.id);
|
||
let value = 0;
|
||
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
|
||
return { id: a.id, label: `${a.code} · ${a.name}`, value };
|
||
})
|
||
.filter((b) => b.value > 0)
|
||
.sort((a, b) => b.value - a.value);
|
||
}
|
||
|
||
/** For an account-node detail: which cost centres drive its spend, this FY. */
|
||
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||
const leaves = idx.leavesUnder(nodeId);
|
||
const byVessel = new Map<string, number>();
|
||
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
|
||
return ds.vessels
|
||
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
|
||
.filter((b) => b.value > 0)
|
||
.sort((a, b) => b.value - a.value);
|
||
}
|
||
|
||
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
|
||
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||
return idx
|
||
.childrenOf(nodeId)
|
||
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
|
||
.filter((b) => b.value > 0)
|
||
.sort((a, b) => b.value - a.value);
|
||
}
|
||
|
||
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
|
||
|
||
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
|
||
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
|
||
|
||
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
|
||
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
|
||
if (scope === "top5") return sortedDesc.slice(0, 5);
|
||
if (scope === "top10") return sortedDesc.slice(0, 10);
|
||
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
|
||
return sortedDesc;
|
||
}
|
||
|
||
export function parseScope(v: string | undefined): ScopeMode {
|
||
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
|
||
}
|
||
export type Granularity = "yearly" | "monthly";
|
||
export function parseGranularity(v: string | undefined): Granularity {
|
||
return v === "yearly" ? "yearly" : "monthly";
|
||
}
|
||
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
|
||
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
|
||
const n = v ? Number(v) : NaN;
|
||
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
|
||
return ds.fys[ds.fys.length - 1];
|
||
}
|