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>
This commit is contained in:
parent
21df005ab6
commit
8c6bbd8304
14 changed files with 1577 additions and 12 deletions
|
|
@ -145,6 +145,19 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
|
|||
|
||||
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
|
||||
|
||||
### Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
|
||||
|
||||
Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** subheading, so other domains can add report groups later). Gated by **`view_analytics`** (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an **index → drill/detail** pair:
|
||||
|
||||
- **Cost Centres** (`/reports/cost-centres`) — spend compared across **vessels** (the PO cost centre). Row → **`/reports/cost-centres/[id]`** detail: trend + a **Top accounting codes** breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N.
|
||||
- **Accounting Codes** (`/reports/accounting-codes`) — drills the `Account` tree (headings → sub-headings → leaves) via a `?parent=` query; leaf rows open **`/reports/accounting-codes/[id]`**: trend + breakdown **by cost centre** (or, for a non-leaf, by sub-account).
|
||||
|
||||
**Spend definition** (`lib/reports.ts`, the pure/unit-tested core): a PO counts once it reaches `POST_APPROVAL_STATUSES`, dated by `approvedAt`, valued at the full `totalAmount` — the same basis as the dashboard tiles. FY is the Indian **Apr–Mar** year. `getReportDataset()` does one query pass; everything else is pure functions over it. Each PO's `accountId` is a leaf, rolled up to parents via `buildAccountIndex().leavesUnder`.
|
||||
|
||||
**Filters** live in the **URL query** (`fy`, `gran`, `scope`, `parent`, `tier`, `break`, `topn`) so the server component re-renders — no client fetching. The shared `<ReportsToolbar>` (client) writes those params; charts are **recharts** (`components/reports/charts.tsx`, the dashboard pattern); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view).
|
||||
|
||||
**Deferred** (documented in the mockup): synthetic **Weekly** granularity, the **"Add to graph"** custom multi-select comparison, and **line-item-level** account allocation (v1 attributes a PO's whole amount to its PO-level `accountId`). Sites are **not** cost centres (only vessels are).
|
||||
|
||||
### Crewing (feature-flagged)
|
||||
|
||||
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:
|
||||
|
|
|
|||
176
App/app/(portal)/reports/accounting-codes/[id]/page.tsx
Normal file
176
App/app/(portal)/reports/accounting-codes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
accountNodeSpend,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { TrendChart, BreakdownChart, SERIES_COLORS } from "@/components/reports/charts";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Accounting Code — 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 AccountingCodeDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; break?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const node = idx.byId.get(id);
|
||||
if (!node) notFound();
|
||||
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const leaf = idx.isLeaf(id);
|
||||
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
|
||||
const breakMode = leaf ? "cc" : sp.break === "cc" ? "cc" : "children";
|
||||
|
||||
const spend = accountNodeSpend(ds, idx, id, fy);
|
||||
const series = yearly
|
||||
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: spend.fyTotals[i] }))
|
||||
: FY_MONTHS.map((m, i) => ({ label: m, value: spend.months[i] }));
|
||||
const total = sum(series.map((s) => s.value));
|
||||
const avg = series.length ? total / series.length : 0;
|
||||
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
|
||||
const nf = ds.fys.length;
|
||||
const yoy = nf >= 2 && spend.fyTotals[nf - 2] ? ((spend.fyTotals[nf - 1] - spend.fyTotals[nf - 2]) / spend.fyTotals[nf - 2]) * 100 : 0;
|
||||
|
||||
const childTier = idx.childrenOf(id)[0]?.tier ?? "Sub-heading";
|
||||
const breakdown = (breakMode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy)).slice(0, topn);
|
||||
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
|
||||
const breakLabel = breakMode === "cc" ? "Cost centre" : childTier;
|
||||
const breakTitle = breakMode === "cc" ? "Top cost centres" : "Composition by sub-account";
|
||||
|
||||
const periodLabel = yearly ? `${ds.fys.length} FYs` : fyLabel(fy);
|
||||
const base = `/reports/accounting-codes/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
|
||||
|
||||
const path = idx.pathTo(id);
|
||||
const trail = [
|
||||
{ label: "Accounting Codes", href: `/reports/accounting-codes?fy=${fy}&gran=${gran}` },
|
||||
...path.map((a, i) => ({
|
||||
label: `${a.code} · ${a.name}`,
|
||||
href: i < path.length - 1 ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${a.id}` : undefined,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={trail} />
|
||||
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} exportHref={exportHref} />
|
||||
|
||||
<Link
|
||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
← Back to Accounting Codes
|
||||
</Link>
|
||||
|
||||
<ReportTitle
|
||||
title={`${node.code} · ${node.name}`}
|
||||
subtitle={`Aggregates all spend under this ${node.tier.toLowerCase()} · ${periodLabel}`}
|
||||
badge={<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tierBadgeCls[node.tier]}`}>{node.tier}</span>}
|
||||
/>
|
||||
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
|
||||
<Kpi label={`Avg / ${yearly ? "year" : "month"}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${yearly ? "year" : "month"}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<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">
|
||||
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
|
||||
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-neutral-900">{breakTitle}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!leaf && (
|
||||
<SegLink
|
||||
label="Break down by"
|
||||
options={[{ value: "children", label: `${childTier}s` }, { value: "cc", label: "Cost centres" }]}
|
||||
current={breakMode}
|
||||
hrefFor={(v) => q({ break: v, topn: sp.topn ?? "5" })}
|
||||
/>
|
||||
)}
|
||||
<SegLink
|
||||
label="Top"
|
||||
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
|
||||
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
|
||||
hrefFor={(v) => q({ break: breakMode, topn: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{breakdown.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-400">No spend to break down for {periodLabel}.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<BreakdownChart data={breakdown} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th className="py-2">{breakLabel}</th>
|
||||
<th className="py-2 text-right">Spend</th>
|
||||
<th className="py-2 text-right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{breakdown.map((b, i) => (
|
||||
<tr key={b.id}>
|
||||
<td className="py-2">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
|
||||
{b.label}
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
|
||||
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
App/app/(portal)/reports/accounting-codes/page.tsx
Normal file
182
App/app/(portal)/reports/accounting-codes/page.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
144
App/app/(portal)/reports/cost-centres/[id]/page.tsx
Normal file
144
App/app/(portal)/reports/cost-centres/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
topAccountsForCostCentre,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
type Tier,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { TrendChart, BreakdownChart, SERIES_COLORS } from "@/components/reports/charts";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Cost Centre — Reports" };
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
const TIERS: Tier[] = ["Heading", "Sub-heading", "Leaf"];
|
||||
|
||||
export default async function CostCentreDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; tier?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const tier: Tier = TIERS.includes(sp.tier as Tier) ? (sp.tier as Tier) : "Leaf";
|
||||
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
|
||||
|
||||
const row = costCentreRows(ds, fy).find((r) => r.id === id);
|
||||
if (!row) notFound();
|
||||
|
||||
const series = yearly
|
||||
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: row.fyTotals[i] }))
|
||||
: FY_MONTHS.map((m, i) => ({ label: m, value: row.months[i] }));
|
||||
const total = sum(series.map((s) => s.value));
|
||||
const avg = series.length ? total / series.length : 0;
|
||||
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
|
||||
const nf = ds.fys.length;
|
||||
const yoy = nf >= 2 && row.fyTotals[nf - 2] ? ((row.fyTotals[nf - 1] - row.fyTotals[nf - 2]) / row.fyTotals[nf - 2]) * 100 : 0;
|
||||
|
||||
const breakdown = topAccountsForCostCentre(ds, idx, id, fy, tier).slice(0, topn);
|
||||
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
|
||||
|
||||
const periodLabel = yearly ? `${ds.fys.length} FYs` : fyLabel(fy);
|
||||
const base = `/reports/cost-centres/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={[{ label: "Cost Centres", href: `/reports/cost-centres?fy=${fy}&gran=${gran}` }, { label: row.name }]} />
|
||||
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} exportHref={exportHref} />
|
||||
|
||||
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to Cost Centres
|
||||
</Link>
|
||||
|
||||
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
|
||||
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
|
||||
<Kpi label={`Avg / ${yearly ? "year" : "month"}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${yearly ? "year" : "month"}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<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">
|
||||
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
|
||||
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-neutral-900">Top accounting codes</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegLink label="Tier" options={TIERS.map((t) => ({ value: t, label: t }))} current={tier} hrefFor={(v) => q({ tier: v, topn: sp.topn ?? "5" })} />
|
||||
<SegLink
|
||||
label="Top"
|
||||
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
|
||||
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
|
||||
hrefFor={(v) => q({ tier, topn: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{breakdown.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-400">No spend at this tier for {periodLabel}.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<BreakdownChart data={breakdown} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th className="py-2">{tier}</th>
|
||||
<th className="py-2 text-right">Spend</th>
|
||||
<th className="py-2 text-right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{breakdown.map((b, i) => (
|
||||
<tr key={b.id}>
|
||||
<td className="py-2">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
|
||||
{b.label}
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
|
||||
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
App/app/(portal)/reports/cost-centres/page.tsx
Normal file
167
App/app/(portal)/reports/cost-centres/page.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
85
App/app/api/reports/spend/route.ts
Normal file
85
App/app/api/reports/spend/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -91,6 +91,16 @@ const PURCHASING_MGMT: NavItem[] = [
|
|||
|
||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||
|
||||
// ── Reports section ───────────────────────────────────────────────────────────
|
||||
// Spend analytics, gated by `view_analytics` (Manager / SuperUser / Auditor /
|
||||
// Admin). Links are grouped under a "Purchasing" subheading so other domains
|
||||
// (e.g. Crewing) can hang their own report groups here later.
|
||||
const REPORTS_ROLES: Role[] = ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
|
||||
const REPORTS_PURCHASING: NavItem[] = [
|
||||
{ href: "/reports/cost-centres", label: "Cost Centres", icon: Ship, roles: REPORTS_ROLES },
|
||||
{ href: "/reports/accounting-codes", label: "Accounting Codes", icon: Building2, roles: REPORTS_ROLES },
|
||||
];
|
||||
|
||||
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
||||
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
|
||||
|
|
@ -134,10 +144,14 @@ const ADMIN_ITEMS: NavItem[] = [
|
|||
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
||||
];
|
||||
|
||||
interface NavGroup {
|
||||
label?: string; // optional subheading shown above the group's links
|
||||
items: NavItem[];
|
||||
}
|
||||
interface Section {
|
||||
id: string;
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
groups: NavGroup[];
|
||||
}
|
||||
|
||||
function isItemActive(href: string, pathname: string) {
|
||||
|
|
@ -148,22 +162,29 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
const pathname = usePathname();
|
||||
const isAdmin = userRole === "ADMIN";
|
||||
|
||||
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole);
|
||||
const visibleMain = NAV_ITEMS.filter(visible);
|
||||
const visiblePurchasing = PURCHASING_ITEMS.filter(visible);
|
||||
const visibleReports = REPORTS_PURCHASING.filter(visible);
|
||||
const visibleCrewing = CREWING_ITEMS.filter(visible);
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter(visible);
|
||||
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
|
||||
|
||||
// Headed, collapsible sections (the main links above sit outside any section).
|
||||
// A section holds one or more groups; a group can carry an optional subheading.
|
||||
const sections: Section[] = [
|
||||
{ id: "purchasing", label: "Purchasing", items: visiblePurchasing },
|
||||
{ id: "crewing", label: "Crewing", items: visibleCrewing },
|
||||
{ id: "administration", label: "Administration", items: adminItems },
|
||||
].filter((s) => s.items.length > 0);
|
||||
{ id: "purchasing", label: "Purchasing", groups: [{ items: visiblePurchasing }] },
|
||||
{ id: "reports", label: "Reports", groups: [{ label: "Purchasing", items: visibleReports }] },
|
||||
{ id: "crewing", label: "Crewing", groups: [{ items: visibleCrewing }] },
|
||||
{ id: "administration", label: "Administration", groups: [{ items: adminItems }] },
|
||||
]
|
||||
.map((s) => ({ ...s, groups: s.groups.filter((g) => g.items.length > 0) }))
|
||||
.filter((s) => s.groups.length > 0);
|
||||
|
||||
const sectionItems = (s: Section) => s.groups.flatMap((g) => g.items);
|
||||
// The section (if any) that holds the currently active route.
|
||||
const activeSectionId =
|
||||
sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null;
|
||||
sections.find((s) => sectionItems(s).some((i) => isItemActive(i.href, pathname)))?.id ?? null;
|
||||
|
||||
// Single-open accordion, collapsed by default. Auto-expand the section that
|
||||
// contains the active route so the user is never stranded on a hidden link.
|
||||
|
|
@ -205,8 +226,17 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
/>
|
||||
{isOpen && (
|
||||
<div id={regionId} className="space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
{section.groups.map((group, gi) => (
|
||||
<div key={group.label ?? gi} className="space-y-0.5">
|
||||
{group.label && (
|
||||
<p className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-300">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
147
App/components/reports/charts.tsx
Normal file
147
App/components/reports/charts.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
export const SERIES_COLORS = ["#2563eb", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#dc2626", "#ca8a04", "#4f46e5", "#0d9488", "#db2777"];
|
||||
|
||||
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
|
||||
export function formatINRShort(n: number): string {
|
||||
const a = Math.abs(n);
|
||||
if (a >= 1_00_00_000) return `₹${(n / 1_00_00_000).toFixed(1)}Cr`;
|
||||
if (a >= 1_00_000) return `₹${(n / 1_00_000).toFixed(1)}L`;
|
||||
if (a >= 1_000) return `₹${(n / 1_000).toFixed(0)}K`;
|
||||
return `₹${n.toFixed(0)}`;
|
||||
}
|
||||
function fullINR(n: number): string {
|
||||
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
key: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ComparisonProps {
|
||||
kind: "lines" | "bars";
|
||||
data: Record<string, string | number>[];
|
||||
xKey: string;
|
||||
series: Series[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Multi-series comparison: monthly trend lines, or year-over-year grouped bars. */
|
||||
export function ComparisonChart({ kind, data, xKey, series, height = 340 }: ComparisonProps) {
|
||||
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{kind === "lines" ? (
|
||||
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} iconType="plainline" />
|
||||
{series.map((s) => (
|
||||
<Line key={s.key} type="monotone" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 5 }} />
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{series.map((s) => (
|
||||
<Bar key={s.key} dataKey={s.key} fill={s.color} radius={[3, 3, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrendProps {
|
||||
kind: "line" | "bar";
|
||||
data: { label: string; value: number }[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Single-series spend trend (monthly line or yearly bar). */
|
||||
export function TrendChart({ kind, data, height = 300 }: TrendProps) {
|
||||
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{kind === "line" ? (
|
||||
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="label" {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} />
|
||||
<Line type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} fill="rgba(37,99,235,0.08)" />
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="label" {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Horizontal top-N breakdown bars (each bar its own colour). */
|
||||
export function BreakdownChart({ data, height = 300 }: { data: { label: string; value: number }[]; height?: number }) {
|
||||
const trimmed = data.map((d) => ({ ...d, short: d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart layout="vertical" data={trimmed} margin={{ top: 4, right: 16, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={formatINRShort} tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
|
||||
<YAxis type="category" dataKey="short" width={140} tick={{ fontSize: 11, fill: "#525252" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{trimmed.map((_, i) => (
|
||||
<Cell key={i} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiny inline trend sparkline (plain SVG — no chart library needed per row). */
|
||||
export function Sparkline({ values, width = 90, height = 28 }: { values: number[]; width?: number; height?: number }) {
|
||||
if (values.length < 2) return <svg width={width} height={height} />;
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const pad = 3;
|
||||
const span = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = pad + (i / (values.length - 1)) * (width - 2 * pad);
|
||||
const y = height - pad - ((v - min) / span) * (height - 2 * pad);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const last = pts[pts.length - 1].split(",");
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline points={pts.join(" ")} fill="none" stroke="#2563eb" strokeWidth={1.5} />
|
||||
<circle cx={last[0]} cy={last[1]} r={2} fill="#2563eb" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
App/components/reports/kpi.tsx
Normal file
28
App/components/reports/kpi.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Presentational KPI tile (server component — no interactivity). `delta` colours
|
||||
// the sub-line green/red for positive/negative changes (e.g. YoY).
|
||||
export function Kpi({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
delta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
delta?: number;
|
||||
}) {
|
||||
const subColor = delta === undefined ? "text-neutral-400" : delta >= 0 ? "text-green-600" : "text-red-600";
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">{label}</p>
|
||||
<p className="mt-1.5 text-xl font-semibold text-neutral-900">{value}</p>
|
||||
<p className={cn("mt-0.5 text-xs", subColor)}>{sub ?? " "}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiStrip({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">{children}</div>;
|
||||
}
|
||||
68
App/components/reports/report-header.tsx
Normal file
68
App/components/reports/report-header.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
// Reports breadcrumb: always rooted at "Reports", then the section and any
|
||||
// drill/detail crumbs. A crumb with an href is a link; the last is the current.
|
||||
export function ReportBreadcrumb({ trail }: { trail: { label: string; href?: string }[] }) {
|
||||
return (
|
||||
<nav className="mb-4 flex flex-wrap items-center gap-2 text-sm text-neutral-500">
|
||||
<span>Reports</span>
|
||||
{trail.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
{t.href ? (
|
||||
<Link href={t.href} className="hover:text-neutral-800">{t.label}</Link>
|
||||
) : (
|
||||
<span className="font-medium text-neutral-900">{t.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Server-rendered segmented control: each option is a link that re-renders the
|
||||
// page with the new value in the query string (used for tier / break-down / top-N).
|
||||
export function SegLink({
|
||||
label,
|
||||
options,
|
||||
current,
|
||||
hrefFor,
|
||||
}: {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
current: string;
|
||||
hrefFor: (v: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400">{label}</span>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.value}
|
||||
href={hrefFor(o.value)}
|
||||
className={
|
||||
"rounded-md px-2.5 py-1 font-medium " +
|
||||
(o.value === current ? "bg-primary-600 text-white" : "text-neutral-500 hover:text-neutral-800")
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{subtitle && <p className="mt-1 text-sm text-neutral-500">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
App/components/reports/reports-toolbar.tsx
Normal file
98
App/components/reports/reports-toolbar.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fyLabel, SCOPE_LABELS, type Granularity, type ScopeMode } from "@/lib/reports";
|
||||
|
||||
interface Props {
|
||||
fys: number[];
|
||||
fy: number;
|
||||
gran: Granularity;
|
||||
/** Pass a scope to render the Top/Bottom-N "Show" control (index pages only). */
|
||||
scope?: ScopeMode;
|
||||
exportHref: string;
|
||||
}
|
||||
|
||||
// Pinned filter toolbar shared by the report index pages. Each control writes its
|
||||
// value into the URL query string (preserving the rest) so the server component
|
||||
// re-renders the report for the new filters — no client-side data fetching.
|
||||
export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
|
||||
function update(patch: Record<string, string | null>) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null || v === "") q.delete(k);
|
||||
else q.set(k, v);
|
||||
}
|
||||
const qs = q.toString();
|
||||
router.push(qs ? `${pathname}?${qs}` : pathname);
|
||||
}
|
||||
|
||||
const yearly = gran === "yearly";
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-4 mb-6 border-b border-neutral-200 bg-neutral-50/95 px-4 py-3 backdrop-blur md:-mx-6 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
|
||||
{(["monthly", "yearly"] as Granularity[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => update({ gran: g === "monthly" ? null : g })}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 font-medium capitalize transition-colors",
|
||||
gran === g ? "bg-primary-600 text-white shadow-sm" : "text-neutral-500 hover:text-neutral-800"
|
||||
)}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!yearly && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
|
||||
<select
|
||||
value={fy}
|
||||
onChange={(e) => update({ fy: e.target.value })}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
|
||||
>
|
||||
{[...fys].reverse().map((y) => (
|
||||
<option key={y} value={y}>{fyLabel(y)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{scope && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => update({ scope: e.target.value === "top5" ? null : e.target.value })}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
|
||||
>
|
||||
{(Object.keys(SCOPE_LABELS) as ScopeMode[]).map((s) => (
|
||||
<option key={s} value={s}>{SCOPE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={exportHref}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
App/lib/reports.ts
Normal file
271
App/lib/reports.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { db } from "@/lib/db";
|
||||
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Spend reporting (Reports → Purchasing). Aggregates approved purchase-order
|
||||
* spend across two dimensions:
|
||||
* • Cost centres — the PO's vessel (`PurchaseOrder.vesselId`).
|
||||
* • Accounting codes — the self-referential `Account` tree (Heading →
|
||||
* Sub-heading → Leaf); each PO's `accountId` is a leaf, rolled up to parents.
|
||||
*
|
||||
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
|
||||
* dated by `approvedAt` and valued at the full `totalAmount` — the same
|
||||
* definition the dashboard's spend tiles use. Financial year is the Indian
|
||||
* Apr–Mar year. The heavy lifting is a single query in `getReportDataset()`;
|
||||
* everything below is pure functions over that dataset so they're unit-testable.
|
||||
*/
|
||||
|
||||
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
|
||||
|
||||
/** Indian FY start year for a date (Apr–Mar): Jan–Mar belong to the prior year. */
|
||||
export function fyStartYear(d: Date): number {
|
||||
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
|
||||
}
|
||||
/** "FY 2025–26" for start year 2025. */
|
||||
export function fyLabel(start: number): string {
|
||||
return `FY ${start}–${String((start + 1) % 100).padStart(2, "0")}`;
|
||||
}
|
||||
/** Month index within the FY: Apr=0 … Mar=11. */
|
||||
export function fyMonthIndex(d: Date): number {
|
||||
return (d.getMonth() - 3 + 12) % 12;
|
||||
}
|
||||
|
||||
export type Tier = "Heading" | "Sub-heading" | "Leaf";
|
||||
|
||||
export interface CostCentre {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
export interface AccountNode {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
tier: Tier;
|
||||
}
|
||||
/** One row per spend PO. */
|
||||
export interface SpendRow {
|
||||
vesselId: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
fy: number;
|
||||
month: number; // 0–11 within the FY
|
||||
}
|
||||
|
||||
export interface ReportDataset {
|
||||
rows: SpendRow[];
|
||||
vessels: CostCentre[];
|
||||
accounts: AccountNode[];
|
||||
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
|
||||
}
|
||||
|
||||
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
|
||||
export async function getReportDataset(): Promise<ReportDataset> {
|
||||
const [pos, vessels, accounts] = await Promise.all([
|
||||
db.purchaseOrder.findMany({
|
||||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
|
||||
select: { vesselId: true, accountId: true, totalAmount: true, approvedAt: true },
|
||||
}),
|
||||
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
|
||||
]);
|
||||
|
||||
const childCount = new Map<string, number>();
|
||||
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
|
||||
const accountNodes: AccountNode[] = accounts.map((a) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
parentId: a.parentId,
|
||||
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
|
||||
}));
|
||||
|
||||
const rows: SpendRow[] = [];
|
||||
for (const po of pos) {
|
||||
if (!po.approvedAt) continue;
|
||||
rows.push({
|
||||
vesselId: po.vesselId,
|
||||
accountId: po.accountId,
|
||||
amount: Number(po.totalAmount),
|
||||
fy: fyStartYear(po.approvedAt),
|
||||
month: fyMonthIndex(po.approvedAt),
|
||||
});
|
||||
}
|
||||
|
||||
const fySet = new Set(rows.map((r) => r.fy));
|
||||
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
|
||||
|
||||
return { rows, vessels, accounts: accountNodes, fys };
|
||||
}
|
||||
|
||||
// ── Account tree helpers ───────────────────────────────────────────────────
|
||||
|
||||
export interface AccountIndex {
|
||||
byId: Map<string, AccountNode>;
|
||||
childrenOf: (parentId: string | null) => AccountNode[];
|
||||
leavesUnder: (id: string) => Set<string>;
|
||||
isLeaf: (id: string) => boolean;
|
||||
pathTo: (id: string) => AccountNode[];
|
||||
}
|
||||
|
||||
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
|
||||
const byId = new Map(accounts.map((a) => [a.id, a]));
|
||||
const kids = new Map<string | null, AccountNode[]>();
|
||||
for (const a of accounts) {
|
||||
const k = a.parentId;
|
||||
if (!kids.has(k)) kids.set(k, []);
|
||||
kids.get(k)!.push(a);
|
||||
}
|
||||
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
|
||||
const isLeaf = (id: string) => childrenOf(id).length === 0;
|
||||
|
||||
const leafCache = new Map<string, Set<string>>();
|
||||
function leavesUnder(id: string): Set<string> {
|
||||
const cached = leafCache.get(id);
|
||||
if (cached) return cached;
|
||||
const out = new Set<string>();
|
||||
const children = childrenOf(id);
|
||||
if (children.length === 0) out.add(id);
|
||||
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
|
||||
leafCache.set(id, out);
|
||||
return out;
|
||||
}
|
||||
function pathTo(id: string): AccountNode[] {
|
||||
const node = byId.get(id);
|
||||
if (!node) return [];
|
||||
return node.parentId ? [...pathTo(node.parentId), node] : [node];
|
||||
}
|
||||
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
|
||||
}
|
||||
|
||||
// ── Aggregations ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface CostCentreSpend {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
total: number; // selected FY
|
||||
months: number[]; // 12 (Apr–Mar) of the selected FY
|
||||
poCount: number; // selected FY
|
||||
fyTotals: number[]; // aligned to ds.fys
|
||||
}
|
||||
|
||||
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
|
||||
const idx = new Map<string, CostCentreSpend>();
|
||||
for (const v of ds.vessels) {
|
||||
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
|
||||
}
|
||||
for (const r of ds.rows) {
|
||||
const row = idx.get(r.vesselId);
|
||||
if (!row) continue;
|
||||
const fi = ds.fys.indexOf(r.fy);
|
||||
if (fi >= 0) row.fyTotals[fi] += r.amount;
|
||||
if (r.fy === fy) {
|
||||
row.months[r.month] += r.amount;
|
||||
row.total += r.amount;
|
||||
row.poCount += 1;
|
||||
}
|
||||
}
|
||||
return [...idx.values()];
|
||||
}
|
||||
|
||||
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
|
||||
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const months = Array(12).fill(0);
|
||||
const fyTotals = Array(ds.fys.length).fill(0);
|
||||
let total = 0;
|
||||
let poCount = 0;
|
||||
for (const r of ds.rows) {
|
||||
if (!leaves.has(r.accountId)) continue;
|
||||
const fi = ds.fys.indexOf(r.fy);
|
||||
if (fi >= 0) fyTotals[fi] += r.amount;
|
||||
if (r.fy === fy) {
|
||||
months[r.month] += r.amount;
|
||||
total += r.amount;
|
||||
poCount += 1;
|
||||
}
|
||||
}
|
||||
return { total, months, fyTotals, poCount };
|
||||
}
|
||||
|
||||
export interface NodeSpend {
|
||||
node: AccountNode;
|
||||
total: number;
|
||||
months: number[];
|
||||
fyTotals: number[];
|
||||
poCount: number;
|
||||
}
|
||||
|
||||
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
|
||||
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
|
||||
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
|
||||
}
|
||||
|
||||
export interface Breakdown {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
|
||||
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
|
||||
return ds.accounts
|
||||
.filter((a) => a.tier === tier)
|
||||
.map((a) => {
|
||||
const leaves = idx.leavesUnder(a.id);
|
||||
let value = 0;
|
||||
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
|
||||
return { id: a.id, label: `${a.code} · ${a.name}`, value };
|
||||
})
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
/** For an account-node detail: which cost centres drive its spend, this FY. */
|
||||
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const byVessel = new Map<string, number>();
|
||||
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
|
||||
return ds.vessels
|
||||
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
|
||||
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||||
return idx
|
||||
.childrenOf(nodeId)
|
||||
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
|
||||
|
||||
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
|
||||
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
|
||||
|
||||
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
|
||||
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
|
||||
if (scope === "top5") return sortedDesc.slice(0, 5);
|
||||
if (scope === "top10") return sortedDesc.slice(0, 10);
|
||||
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
|
||||
return sortedDesc;
|
||||
}
|
||||
|
||||
export function parseScope(v: string | undefined): ScopeMode {
|
||||
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
|
||||
}
|
||||
export type Granularity = "yearly" | "monthly";
|
||||
export function parseGranularity(v: string | undefined): Granularity {
|
||||
return v === "yearly" ? "yearly" : "monthly";
|
||||
}
|
||||
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
|
||||
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
|
||||
return ds.fys[ds.fys.length - 1];
|
||||
}
|
||||
129
App/tests/unit/reports.test.ts
Normal file
129
App/tests/unit/reports.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
fyStartYear,
|
||||
fyLabel,
|
||||
fyMonthIndex,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
accountNodeSpend,
|
||||
accountLevelRows,
|
||||
topAccountsForCostCentre,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
applyScope,
|
||||
parseScope,
|
||||
resolveFy,
|
||||
type ReportDataset,
|
||||
type AccountNode,
|
||||
} from "@/lib/reports";
|
||||
|
||||
const ACCOUNTS: AccountNode[] = [
|
||||
{ id: "H", code: "5000", name: "Operating", parentId: null, tier: "Heading" },
|
||||
{ id: "S", code: "5100", name: "Vessel Running", parentId: "H", tier: "Sub-heading" },
|
||||
{ id: "L1", code: "5110", name: "Fuel", parentId: "S", tier: "Leaf" },
|
||||
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
|
||||
];
|
||||
|
||||
// fys ascending: [2024, 2025]
|
||||
const DS: ReportDataset = {
|
||||
vessels: [
|
||||
{ id: "v1", code: "V1", name: "MV One" },
|
||||
{ id: "v2", code: "V2", name: "MV Two" },
|
||||
],
|
||||
accounts: ACCOUNTS,
|
||||
fys: [2024, 2025],
|
||||
rows: [
|
||||
{ vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0 },
|
||||
{ vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1 },
|
||||
{ vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0 },
|
||||
{ vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5 },
|
||||
{ vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("financial-year helpers", () => {
|
||||
it("maps Apr–Mar to the Indian FY start year", () => {
|
||||
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
|
||||
expect(fyStartYear(new Date(2025, 0, 15))).toBe(2024); // Jan → prior FY
|
||||
expect(fyStartYear(new Date(2025, 2, 31))).toBe(2024); // Mar → prior FY
|
||||
});
|
||||
it("labels and indexes months within the FY", () => {
|
||||
expect(fyLabel(2025)).toBe("FY 2025–26");
|
||||
expect(fyMonthIndex(new Date(2025, 3, 1))).toBe(0); // Apr
|
||||
expect(fyMonthIndex(new Date(2026, 2, 1))).toBe(11); // Mar
|
||||
});
|
||||
});
|
||||
|
||||
describe("costCentreRows", () => {
|
||||
it("totals the selected FY by vessel with a 12-month series and PO count", () => {
|
||||
const rows = costCentreRows(DS, 2025);
|
||||
const v1 = rows.find((r) => r.id === "v1")!;
|
||||
expect(v1.total).toBe(180);
|
||||
expect(v1.months[0]).toBe(130); // 100 + 30
|
||||
expect(v1.months[1]).toBe(50);
|
||||
expect(v1.poCount).toBe(3);
|
||||
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
|
||||
|
||||
const v2 = rows.find((r) => r.id === "v2")!;
|
||||
expect(v2.total).toBe(70);
|
||||
expect(v2.fyTotals).toEqual([200, 70]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accounting-code rollup", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("rolls leaf spend up to the heading", () => {
|
||||
expect(accountNodeSpend(DS, idx, "H", 2025).total).toBe(250); // 100+50+30+70
|
||||
expect(accountNodeSpend(DS, idx, "L1", 2025).total).toBe(150);
|
||||
});
|
||||
it("lists the children to compare at a drill level", () => {
|
||||
const top = accountLevelRows(DS, idx, null, 2025); // headings
|
||||
expect(top.map((r) => r.node.id)).toEqual(["H"]);
|
||||
const subs = accountLevelRows(DS, idx, "H", 2025);
|
||||
expect(subs.map((r) => r.node.id)).toEqual(["S"]);
|
||||
});
|
||||
it("leaf detection and leaf set", () => {
|
||||
expect(idx.isLeaf("L1")).toBe(true);
|
||||
expect(idx.isLeaf("H")).toBe(false);
|
||||
expect([...idx.leavesUnder("H")].sort()).toEqual(["L1", "L2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakdowns", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("top accounting codes for a cost centre (by tier)", () => {
|
||||
const bd = topAccountsForCostCentre(DS, idx, "v1", 2025, "Leaf");
|
||||
expect(bd.map((b) => [b.id, b.value])).toEqual([
|
||||
["L1", 150],
|
||||
["L2", 30],
|
||||
]);
|
||||
});
|
||||
it("cost centres for an account node", () => {
|
||||
const bd = costCentresForAccount(DS, idx, "H", 2025);
|
||||
expect(bd.map((b) => [b.id, b.value])).toEqual([
|
||||
["v1", 180],
|
||||
["v2", 70],
|
||||
]);
|
||||
});
|
||||
it("child breakdown of a non-leaf node", () => {
|
||||
const bd = childBreakdown(DS, idx, "H", 2025);
|
||||
expect(bd).toEqual([{ id: "S", label: "5100 · Vessel Running", value: 250 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope + param parsing", () => {
|
||||
it("applies Top/Bottom-N to a descending list", () => {
|
||||
const sorted = [5, 4, 3, 2, 1];
|
||||
expect(applyScope(sorted, "top5")).toEqual([5, 4, 3, 2, 1]);
|
||||
expect(applyScope([9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1], "top10")).toHaveLength(10);
|
||||
expect(applyScope(sorted, "bottom5")).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(applyScope(sorted, "all")).toEqual(sorted);
|
||||
});
|
||||
it("parses scope + resolves FY with sensible defaults", () => {
|
||||
expect(parseScope("top10")).toBe("top10");
|
||||
expect(parseScope("garbage")).toBe("top5");
|
||||
expect(resolveFy(DS, "2024")).toBe(2024);
|
||||
expect(resolveFy(DS, undefined)).toBe(2025); // latest
|
||||
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
|
||||
});
|
||||
});
|
||||
|
|
@ -134,3 +134,30 @@ describe("Purchase Order links under Purchasing", () => {
|
|||
expect(screen.queryByRole("link", { name: /^History$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reports section (Purchasing subheading)", () => {
|
||||
it("reveals the report links under a Purchasing subheading for an analytics role", () => {
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
// Collapsed by default.
|
||||
expect(screen.queryByRole("link", { name: /Accounting Codes/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(headerButton("Reports"));
|
||||
|
||||
expect(screen.getByRole("link", { name: /Cost Centres/i })).toHaveAttribute("href", "/reports/cost-centres");
|
||||
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toHaveAttribute("href", "/reports/accounting-codes");
|
||||
// The "Purchasing" subheading is rendered in addition to the Purchasing section header.
|
||||
expect(screen.getAllByText("Purchasing").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("auto-expands Reports when a report route is active", () => {
|
||||
mockPathname = "/reports/accounting-codes";
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
expect(headerButton("Reports")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden from roles without view_analytics", () => {
|
||||
render(<Sidebar userRole="TECHNICAL" />);
|
||||
expect(screen.queryByRole("button", { name: /^Reports/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue