pelagia-portal/App/app/(portal)/reports/cost-centres/page.tsx
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

167 lines
7.5 KiB
TypeScript

import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight } from "lucide-react";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
costCentreRows,
applyScope,
parseScope,
parseGranularity,
resolveFy,
fyLabel,
FY_MONTHS,
SCOPE_LABELS,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { ComparisonChart, Sparkline, SERIES_COLORS, type Series } from "@/components/reports/charts";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Cost Centres — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
export default async function CostCentresReport({
searchParams,
}: {
searchParams: Promise<{ fy?: string; gran?: string; scope?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const sp = await searchParams;
const ds = await getReportDataset();
const gran = parseGranularity(sp.gran);
const scope = parseScope(sp.scope);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const ranked = costCentreRows(ds, fy);
const rankOf = (r: { total: number; fyTotals: number[] }) => (yearly ? sum(r.fyTotals) : r.total);
ranked.sort((a, b) => rankOf(b) - rankOf(a));
const shown = applyScope(ranked, scope);
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
const totalSpend = shown.reduce((s, r) => s + (yearly ? sum(r.fyTotals) : r.total), 0);
const top = shown[0];
// YoY across the shown cost centres (latest two FYs).
const n = ds.fys.length;
const curT = n >= 1 ? shown.reduce((s, r) => s + r.fyTotals[n - 1], 0) : 0;
const prevT = n >= 2 ? shown.reduce((s, r) => s + r.fyTotals[n - 2], 0) : 0;
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
// Comparison chart series.
let chartData: Record<string, string | number>[];
let series: Series[];
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
if (yearly) {
chartData = shown.map((r) => {
const row: Record<string, string | number> = { name: r.name };
ds.fys.forEach((y, i) => (row[fyLabel(y)] = r.fyTotals[i]));
return row;
});
series = ds.fys.map((y, i) => ({ key: fyLabel(y), color: colored(i) }));
} else {
chartData = FY_MONTHS.map((m, i) => {
const row: Record<string, string | number> = { month: m };
shown.forEach((r) => (row[r.name] = r.months[i]));
return row;
});
series = shown.map((r, i) => ({ key: r.name, color: colored(i) }));
}
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : fyLabel(fy);
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}`;
const detailHref = (id: string) => `/reports/cost-centres/${id}?fy=${fy}&gran=${gran}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} scope={scope} exportHref={exportHref} />
<ReportTitle
title="Cost Centres"
subtitle="Approved spend compared across cost centres (vessels). Click a row to open its report."
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(totalSpend)} sub={periodLabel} />
<Kpi label="Cost centres" value={String(shown.length)} sub={`${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top?.name ?? "—"} sub={formatCompactINR(rankOf(top ?? { total: 0, fyTotals: [] }))} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? "Spend by cost centre — year over year" : "Monthly spend by cost centre"}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey={yearly ? "name" : "month"} series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="px-5 py-3">Cost Centre</th>
<th className="px-5 py-3">Trend</th>
<th className="px-5 py-3 text-right">Total Spend</th>
<th className="px-5 py-3 text-right">% of Shown</th>
<th className="px-5 py-3 text-right">POs</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
return (
<tr key={r.id} className="group hover:bg-primary-50/40">
<td className="px-5 py-3">
<Link href={detailHref(r.id)} className="block font-medium text-neutral-900 group-hover:text-primary-700">
{r.name}
<span className="ml-2 text-xs font-normal text-neutral-400">{r.code}</span>
</Link>
</td>
<td className="px-5 py-3">
<Sparkline values={yearly ? r.fyTotals : r.months} />
</td>
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100">
<div className="h-full rounded-full bg-primary-600" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className="w-10 text-right tabular-nums text-neutral-500">{pct.toFixed(0)}%</span>
</div>
</td>
<td className="px-5 py-3 text-right tabular-nums text-neutral-500">{r.poCount}</td>
<td className="px-5 py-3 text-right">
<Link href={detailHref(r.id)}>
<ChevronRight className="inline h-4 w-4 text-neutral-300 group-hover:text-primary-500" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
);
}