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>
182 lines
8.1 KiB
TypeScript
182 lines
8.1 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { redirect } from "next/navigation";
|
|
import Link from "next/link";
|
|
import type { Metadata } from "next";
|
|
import { ChevronRight, BarChart3 } from "lucide-react";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
|
import {
|
|
getReportDataset,
|
|
buildAccountIndex,
|
|
accountLevelRows,
|
|
applyScope,
|
|
parseScope,
|
|
parseGranularity,
|
|
resolveFy,
|
|
fyLabel,
|
|
FY_MONTHS,
|
|
SCOPE_LABELS,
|
|
type NodeSpend,
|
|
} 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: "Accounting Codes — Reports" };
|
|
|
|
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
|
const tierBadgeCls: Record<string, string> = {
|
|
Heading: "bg-primary-50 text-primary-700",
|
|
"Sub-heading": "bg-violet-50 text-violet-700",
|
|
Leaf: "bg-neutral-100 text-neutral-600",
|
|
};
|
|
|
|
export default async function AccountingCodesReport({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; parent?: 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 idx = buildAccountIndex(ds.accounts);
|
|
const gran = parseGranularity(sp.gran);
|
|
const scope = parseScope(sp.scope);
|
|
const fy = resolveFy(ds, sp.fy);
|
|
const yearly = gran === "yearly";
|
|
|
|
const parent = sp.parent && idx.byId.has(sp.parent) ? sp.parent : null;
|
|
const parentNode = parent ? idx.byId.get(parent)! : null;
|
|
|
|
const ranked = accountLevelRows(ds, idx, parent, fy);
|
|
const rankOf = (r: NodeSpend) => (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 childTier = shown[0]?.node.tier ?? "Heading";
|
|
const top = shown[0];
|
|
|
|
const nf = ds.fys.length;
|
|
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
|
|
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
|
|
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
|
|
|
|
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
|
|
let chartData: Record<string, string | number>[];
|
|
let series: Series[];
|
|
if (yearly) {
|
|
chartData = shown.map((r) => {
|
|
const row: Record<string, string | number> = { name: r.node.code };
|
|
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.node.code] = r.months[i]));
|
|
return row;
|
|
});
|
|
series = shown.map((r, i) => ({ key: r.node.code, color: colored(i) }));
|
|
}
|
|
|
|
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : fyLabel(fy);
|
|
const linkWith = (parentId: string | null) => {
|
|
const p = new URLSearchParams({ fy: String(fy), gran, scope });
|
|
if (parentId) p.set("parent", parentId);
|
|
return `/reports/accounting-codes?${p.toString()}`;
|
|
};
|
|
const detailHref = (id: string) => `/reports/accounting-codes/${id}?fy=${fy}&gran=${gran}`;
|
|
const rowHref = (r: NodeSpend) => (idx.isLeaf(r.node.id) ? detailHref(r.node.id) : linkWith(r.node.id));
|
|
const exportHref = `/api/reports/spend?dim=accounting-code&fy=${fy}&gran=${gran}&scope=${scope}${parent ? `&parent=${parent}` : ""}`;
|
|
|
|
// Breadcrumb: Reports / Accounting Codes / …ancestors… / current parent.
|
|
const trail = [{ label: "Accounting Codes", href: parent ? linkWith(null) : undefined }];
|
|
if (parentNode) {
|
|
const path = idx.pathTo(parentNode.id);
|
|
path.forEach((a, i) =>
|
|
trail.push({ label: `${a.code} · ${a.name}`, href: i < path.length - 1 ? linkWith(a.id) : undefined })
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<ReportBreadcrumb trail={trail} />
|
|
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} scope={scope} exportHref={exportHref} />
|
|
|
|
{parentNode && (
|
|
<Link
|
|
href={parentNode.parentId ? linkWith(parentNode.parentId) : linkWith(null)}
|
|
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
|
>
|
|
← Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"}
|
|
</Link>
|
|
)}
|
|
|
|
<ReportTitle
|
|
title={parentNode ? `${parentNode.code} · ${parentNode.name}` : "Accounting Codes"}
|
|
subtitle={
|
|
parentNode
|
|
? `Comparing the ${childTier.toLowerCase()}s of ${parentNode.name}. Click a row to ${childTier === "Leaf" ? "open its report" : "drill deeper"}.`
|
|
: "Comparing top-level headings. Click a heading to drill into its sub-headings."
|
|
}
|
|
/>
|
|
|
|
{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(shown.reduce((s, r) => s + (yearly ? sum(r.fyTotals) : r.total), 0))} sub={periodLabel} />
|
|
<Kpi label={`${childTier}s`} value={String(shown.length)} sub={`${SCOPE_LABELS[scope]} shown`} />
|
|
<Kpi label="Highest spender" value={top ? top.node.code : "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
|
|
<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 ${childTier.toLowerCase()} — year over year` : `Monthly spend by ${childTier.toLowerCase()}`}
|
|
</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">
|
|
{shown.map((r) => {
|
|
const value = rankOf(r);
|
|
const pct = grand ? (value / grand) * 100 : 0;
|
|
const leaf = idx.isLeaf(r.node.id);
|
|
return (
|
|
<Link
|
|
key={r.node.id}
|
|
href={rowHref(r)}
|
|
className="group flex items-center gap-3 border-b border-neutral-100 px-5 py-3 last:border-0 hover:bg-primary-50/40"
|
|
>
|
|
<span className="w-14 shrink-0 font-mono text-xs text-neutral-500">{r.node.code}</span>
|
|
<span className="flex-1 truncate text-sm font-medium text-neutral-900 group-hover:text-primary-700">{r.node.name}</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${tierBadgeCls[r.node.tier]}`}>{r.node.tier}</span>
|
|
<Sparkline values={yearly ? r.fyTotals : r.months} width={80} height={24} />
|
|
<span className="w-28 text-right font-medium tabular-nums text-sm">{formatCurrency(value)}</span>
|
|
<div className="hidden w-12 text-right tabular-nums text-xs text-neutral-500 md:block">{pct.toFixed(0)}%</div>
|
|
{leaf ? (
|
|
<BarChart3 className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|