Picks up the three pieces deferred from the initial reports PR: #3 Line-item account allocation — allocatePoSpend() splits each PO across the accounting codes its line items carry (line accountId, falling back to the PO-level account), proportionally so per-PO rows sum back to totalAmount. The accounting-code report now attributes multi-account POs correctly. SpendRow gains poId; poCount is now distinct POs, not row count. #2 Custom "Add to graph" — tick rows on either index (SelectCheckbox links write ?sel=id1,id2), then "Compare selected" (?cmp=1) shows a custom comparison of just those entities. Fully server-rendered + shareable; export honours sel. #1 Weekly granularity — a third Granularity that focuses one FY month and buckets spend by week-of-month (W1–W5) from approvedAt, with a Month picker in the toolbar. Real buckets (not the mockup's synthetic split). All three are URL-driven like the rest, so no client fetching. Charts/KPIs/ detail trends all branch on the new mode. Tests: +8 unit cases (allocation proportional/fallback/empty, weekly buckets, sel parse/toggle, month + granularity parsing); fixture updated for poId/week. Full unit suite 311 green; tsc clean. Smoke-tested weekly + custom-compare + exports end-to-end (all 200). Docs + wiki updated to mark them implemented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
4.1 KiB
TypeScript
97 lines
4.1 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,
|
|
accountNodeSpend,
|
|
applyScope,
|
|
parseScope,
|
|
parseGranularity,
|
|
parseSel,
|
|
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);
|
|
|
|
const sel = parseSel(sp.get("sel") ?? undefined);
|
|
|
|
if (dim === "cost-centre") {
|
|
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
|
const picked = sel.length ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
|
|
const rows = picked.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") {
|
|
let ranked;
|
|
if (sel.length) {
|
|
ranked = sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }));
|
|
} else {
|
|
const parent = sp.get("parent");
|
|
const parentId = parent && idx.byId.has(parent) ? parent : null;
|
|
ranked = accountLevelRows(ds, idx, parentId, fy);
|
|
}
|
|
ranked.sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
|
const picked = sel.length ? ranked : applyScope(ranked, scope);
|
|
const rows = picked.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 });
|
|
}
|