pelagia-portal/App/app/api/reports/spend/route.ts
Hardik 8c6bbd8304
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
feat(reports): Purchasing spend analytics (Cost Centres + Accounting Codes)
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>
2026-06-24 07:52:23 +05:30

85 lines
3.7 KiB
TypeScript

import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import {
getReportDataset,
buildAccountIndex,
costCentreRows,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
parseGranularity,
resolveFy,
fyLabel,
type Tier,
} from "@/lib/reports";
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const cell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
function csv(headers: string[], rows: (string | number)[][]): string {
return [headers, ...rows].map((r) => r.map(cell).join(",")).join("\n");
}
function file(name: string, body: string) {
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${name}-${Date.now()}.csv"`,
},
});
}
// CSV export for the Reports → Purchasing views. The `dim` query param mirrors
// the page the user is on, so the download matches what's on screen.
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "view_analytics")) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const dim = sp.get("dim") ?? "cost-centre";
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.get("gran") ?? undefined);
const scope = parseScope(sp.get("scope") ?? undefined);
const fy = resolveFy(ds, sp.get("fy") ?? undefined);
const yearly = gran === "yearly";
const fyCols = ds.fys.map(fyLabel);
if (dim === "cost-centre") {
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const rows = applyScope(ranked, scope).map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-cost-centre-spend", csv(["Code", "Cost Centre", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "accounting-code") {
const parent = sp.get("parent");
const parentId = parent && idx.byId.has(parent) ? parent : null;
const ranked = accountLevelRows(ds, idx, parentId, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const rows = applyScope(ranked, scope).map((r) => [r.node.code, r.node.name, r.node.tier, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-accounting-code-spend", csv(["Code", "Name", "Tier", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "cost-centre-detail") {
const id = sp.get("id") ?? "";
const tier = (["Heading", "Sub-heading", "Leaf"] as Tier[]).includes(sp.get("tier") as Tier) ? (sp.get("tier") as Tier) : "Leaf";
const rows = topAccountsForCostCentre(ds, idx, id, fy, tier).map((b) => [b.label, b.value]);
return file("pelagia-cost-centre-detail", csv([tier, `Spend (${fyLabel(fy)})`], rows));
}
if (dim === "accounting-code-detail") {
const id = sp.get("id") ?? "";
const leaf = idx.isLeaf(id);
const mode = leaf || sp.get("break") === "cc" ? "cc" : "children";
const bd = mode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy);
const rows = bd.map((b) => [b.label, b.value]);
return file("pelagia-accounting-code-detail", csv([mode === "cc" ? "Cost centre" : "Sub-account", `Spend (${fyLabel(fy)})`], rows));
}
return NextResponse.json({ error: "Unknown report dimension" }, { status: 400 });
}