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>
85 lines
3.7 KiB
TypeScript
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 });
|
|
}
|