feat(reports): weekly granularity, custom compare, line-item allocation
Picks up the three pieces deferred from the initial reports PR: #3 Line-item account allocation — allocatePoSpend() splits each PO across the accounting codes its line items carry (line accountId, falling back to the PO-level account), proportionally so per-PO rows sum back to totalAmount. The accounting-code report now attributes multi-account POs correctly. SpendRow gains poId; poCount is now distinct POs, not row count. #2 Custom "Add to graph" — tick rows on either index (SelectCheckbox links write ?sel=id1,id2), then "Compare selected" (?cmp=1) shows a custom comparison of just those entities. Fully server-rendered + shareable; export honours sel. #1 Weekly granularity — a third Granularity that focuses one FY month and buckets spend by week-of-month (W1–W5) from approvedAt, with a Month picker in the toolbar. Real buckets (not the mockup's synthetic split). All three are URL-driven like the rest, so no client fetching. Charts/KPIs/ detail trends all branch on the new mode. Tests: +8 unit cases (allocation proportional/fallback/empty, weekly buckets, sel parse/toggle, month + granularity parsing); fixture updated for poId/week. Full unit suite 311 green; tsc clean. Smoke-tested weekly + custom-compare + exports end-to-end (all 200). Docs + wiki updated to mark them implemented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c6bbd8304
commit
47ac2c7813
10 changed files with 466 additions and 123 deletions
|
|
@ -152,11 +152,11 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub
|
|||
- **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`.
|
||||
**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. **`allocatePoSpend()`** splits each PO across the accounting codes its **line items** carry (line `accountId`, falling back to the PO-level account), **proportionally** so the per-PO rows always sum back to `totalAmount` — so multi-account POs are attributed correctly in the accounting-code report. `poCount` is **distinct POs** (a multi-account PO yields several rows). Account spend rolls leaf descendants up 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).
|
||||
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
|
||||
|
||||
**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).
|
||||
Sites are **not** cost centres (only vessels are).
|
||||
|
||||
### Crewing (feature-flagged)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import {
|
|||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { TrendChart, BreakdownChart, SERIES_COLORS } from "@/components/reports/charts";
|
||||
|
|
@ -34,7 +37,7 @@ export default async function AccountingCodeDetail({
|
|||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; break?: string; topn?: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; month?: string; break?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
|
@ -50,14 +53,20 @@ export default async function AccountingCodeDetail({
|
|||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const unit = yearly ? "year" : weekly ? "week" : "month";
|
||||
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 monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
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] }));
|
||||
: weekly
|
||||
? WEEK_LABELS.map((w, i) => ({ label: w, value: accountNodeWeekly(ds, idx, id, fy, month)[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 });
|
||||
|
|
@ -70,10 +79,11 @@ export default async function AccountingCodeDetail({
|
|||
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 periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
const base = `/reports/accounting-codes/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
if (weekly) p.set("month", String(month));
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
|
|
@ -91,7 +101,14 @@ export default async function AccountingCodeDetail({
|
|||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={trail} />
|
||||
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} exportHref={exportHref} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||
|
|
@ -108,8 +125,8 @@ export default async function AccountingCodeDetail({
|
|||
|
||||
<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={`Avg / ${unit}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,19 +9,25 @@ import {
|
|||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
accountLevelRows,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
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";
|
||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Accounting Codes — Reports" };
|
||||
|
||||
|
|
@ -35,7 +41,7 @@ const tierBadgeCls: Record<string, string> = {
|
|||
export default async function AccountingCodesReport({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; parent?: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; parent?: string; sel?: string; cmp?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
|
@ -48,14 +54,22 @@ export default async function AccountingCodesReport({
|
|||
const scope = parseScope(sp.scope);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const sel = parseSel(sp.sel);
|
||||
const cmp = sp.cmp === "1" && sel.length > 0;
|
||||
|
||||
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);
|
||||
const rankOf = (r: NodeSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
|
||||
const sparkOf = (r: NodeSpend) => (yearly ? r.fyTotals : weekly ? accountNodeWeekly(ds, idx, r.node.id, fy, month) : r.months);
|
||||
|
||||
const ranked = cmp
|
||||
? sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }))
|
||||
: accountLevelRows(ds, idx, parent, fy);
|
||||
ranked.sort((a, b) => rankOf(b) - rankOf(a));
|
||||
const shown = applyScope(ranked, scope);
|
||||
const shown = cmp ? ranked : applyScope(ranked, scope);
|
||||
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
|
||||
const childTier = shown[0]?.node.tier ?? "Heading";
|
||||
const top = shown[0];
|
||||
|
|
@ -76,53 +90,83 @@ export default async function AccountingCodesReport({
|
|||
});
|
||||
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]));
|
||||
const labels = weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
|
||||
chartData = labels.map((lab, i) => {
|
||||
const row: Record<string, string | number> = { x: lab };
|
||||
shown.forEach((r) => (row[r.node.code] = sparkOf(r)[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}` : ""}`;
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
|
||||
// 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 })
|
||||
);
|
||||
const base: Record<string, string | undefined> = {
|
||||
fy: String(fy),
|
||||
gran: gran === "monthly" ? undefined : gran,
|
||||
scope: scope === "top5" ? undefined : scope,
|
||||
month: weekly ? String(month) : undefined,
|
||||
};
|
||||
const qs = (extra: Record<string, string | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries({ ...base, ...extra })) if (v) p.set(k, v);
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
};
|
||||
const linkWith = (parentId: string | null) => `/reports/accounting-codes${qs({ parent: parentId ?? undefined, sel: sel.join(",") || undefined })}`;
|
||||
const detailHref = (id: string) => `/reports/accounting-codes/${id}${qs({ scope: undefined, parent: undefined })}`;
|
||||
const selHref = (id: string) => {
|
||||
const next = toggleSel(sel, id);
|
||||
return `/reports/accounting-codes${qs({ parent: cmp ? undefined : parent ?? undefined, sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
|
||||
};
|
||||
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 && !cmp ? `&parent=${parent}` : ""}${cmp ? `&sel=${sel.join(",")}` : ""}`;
|
||||
|
||||
const trail = [{ label: "Accounting Codes", href: parent || cmp ? linkWith(null) : undefined }];
|
||||
if (parentNode && !cmp) {
|
||||
idx.pathTo(parentNode.id).forEach((a, i, arr) => trail.push({ label: `${a.code} · ${a.name}`, href: i < arr.length - 1 ? linkWith(a.id) : undefined }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={trail} />
|
||||
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} scope={scope} exportHref={exportHref} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
scope={cmp ? undefined : scope}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
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"}
|
||||
{cmp ? (
|
||||
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to browse
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{parentNode && (
|
||||
<Link
|
||||
href={linkWith(parentNode.parentId ?? 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>
|
||||
)}
|
||||
{sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ parent: parent ?? undefined })} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReportTitle
|
||||
title={parentNode ? `${parentNode.code} · ${parentNode.name}` : "Accounting Codes"}
|
||||
title={cmp ? "Custom comparison" : 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."
|
||||
cmp
|
||||
? `Comparing ${shown.length} selected accounting codes. Untick a row to remove it.`
|
||||
: parentNode
|
||||
? `Comparing the ${childTier.toLowerCase()}s of ${parentNode.name}. Tick to graph, or click to ${childTier === "Leaf" ? "open its report" : "drill deeper"}.`
|
||||
: "Comparing top-level headings. Tick to graph, or click a heading to drill in."
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -133,8 +177,8 @@ export default async function AccountingCodesReport({
|
|||
) : (
|
||||
<>
|
||||
<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="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
|
||||
<Kpi label={cmp ? "Selected" : `${childTier}s`} value={String(shown.length)} sub={cmp ? "in this graph" : `${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>
|
||||
|
|
@ -142,11 +186,11 @@ export default async function AccountingCodesReport({
|
|||
<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()}`}
|
||||
{yearly ? `Spend by ${childTier.toLowerCase()} — year over year` : weekly ? `Weekly spend by ${childTier.toLowerCase()}` : `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} />
|
||||
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey={yearly ? "name" : "x"} series={series} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
|
|
@ -154,24 +198,26 @@ export default async function AccountingCodesReport({
|
|||
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"
|
||||
>
|
||||
const inner = (
|
||||
<>
|
||||
<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} />
|
||||
<Sparkline values={sparkOf(r)} 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" />
|
||||
{!cmp && (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" />)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div key={r.node.id} className="group flex items-center gap-3 border-b border-neutral-100 px-5 py-3 last:border-0 hover:bg-primary-50/40">
|
||||
<SelectCheckbox checked={sel.includes(r.node.id)} href={selHref(r.node.id)} />
|
||||
{cmp ? (
|
||||
<div className="flex flex-1 items-center gap-3 min-w-0">{inner}</div>
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />
|
||||
<Link href={rowHref(r)} className="flex flex-1 items-center gap-3 min-w-0">{inner}</Link>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import {
|
|||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
topAccountsForCostCentre,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
type Tier,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
|
|
@ -30,7 +33,7 @@ export default async function CostCentreDetail({
|
|||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; tier?: string; topn?: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; month?: string; tier?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
|
@ -43,15 +46,21 @@ export default async function CostCentreDetail({
|
|||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const unit = yearly ? "year" : weekly ? "week" : "month";
|
||||
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 monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
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] }));
|
||||
: weekly
|
||||
? WEEK_LABELS.map((w, i) => ({ label: w, value: costCentreWeekly(ds, id, fy, month)[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 });
|
||||
|
|
@ -61,10 +70,11 @@ export default async function CostCentreDetail({
|
|||
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 periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
const base = `/reports/cost-centres/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
if (weekly) p.set("month", String(month));
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
|
|
@ -73,7 +83,14 @@ export default async function CostCentreDetail({
|
|||
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} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
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
|
||||
|
|
@ -83,8 +100,8 @@ export default async function CostCentreDetail({
|
|||
|
||||
<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={`Avg / ${unit}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,18 +8,24 @@ import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
|||
import {
|
||||
getReportDataset,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
SCOPE_LABELS,
|
||||
type CostCentreSpend,
|
||||
} 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";
|
||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Cost Centres — Reports" };
|
||||
|
||||
|
|
@ -28,7 +34,7 @@ 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 }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; sel?: string; cmp?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
|
@ -40,25 +46,28 @@ export default async function CostCentresReport({
|
|||
const scope = parseScope(sp.scope);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const sel = parseSel(sp.sel);
|
||||
const cmp = sp.cmp === "1" && sel.length > 0;
|
||||
|
||||
const ranked = costCentreRows(ds, fy);
|
||||
const rankOf = (r: { total: number; fyTotals: number[] }) => (yearly ? sum(r.fyTotals) : r.total);
|
||||
const rankOf = (r: CostCentreSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
|
||||
ranked.sort((a, b) => rankOf(b) - rankOf(a));
|
||||
const shown = applyScope(ranked, scope);
|
||||
const shown = cmp ? ranked.filter((r) => sel.includes(r.id)) : 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];
|
||||
const sparkOf = (r: CostCentreSpend) => (yearly ? r.fyTotals : weekly ? costCentreWeekly(ds, r.id, fy, month) : r.months);
|
||||
|
||||
// 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 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;
|
||||
|
||||
// Comparison chart series.
|
||||
// Chart data.
|
||||
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
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 };
|
||||
|
|
@ -67,26 +76,66 @@ export default async function CostCentresReport({
|
|||
});
|
||||
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]));
|
||||
const labels = weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
|
||||
chartData = labels.map((lab, i) => {
|
||||
const row: Record<string, string | number> = { x: lab };
|
||||
shown.forEach((r) => (row[r.name] = sparkOf(r)[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}`;
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
|
||||
// Query-string helpers (preserve current filters).
|
||||
const baseParams: Record<string, string | undefined> = {
|
||||
fy: String(fy),
|
||||
gran: gran === "monthly" ? undefined : gran,
|
||||
scope: scope === "top5" ? undefined : scope,
|
||||
month: weekly ? String(month) : undefined,
|
||||
};
|
||||
const qs = (extra: Record<string, string | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries({ ...baseParams, ...extra })) if (v) p.set(k, v);
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
};
|
||||
const selHref = (id: string) => {
|
||||
const next = toggleSel(sel, id);
|
||||
return `/reports/cost-centres${qs({ sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
|
||||
};
|
||||
const detailHref = (id: string) => `/reports/cost-centres/${id}${qs({ scope: undefined })}`;
|
||||
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}${cmp ? `&sel=${sel.join(",")}` : ""}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
|
||||
<ReportsToolbar fys={ds.fys} fy={fy} gran={gran} scope={scope} exportHref={exportHref} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
scope={cmp ? undefined : scope}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
{cmp ? (
|
||||
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to browse
|
||||
</Link>
|
||||
) : (
|
||||
sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ sel: undefined, cmp: undefined })} />
|
||||
)}
|
||||
|
||||
<ReportTitle
|
||||
title="Cost Centres"
|
||||
subtitle="Approved spend compared across cost centres (vessels). Click a row to open its report."
|
||||
title={cmp ? "Custom comparison" : "Cost Centres"}
|
||||
subtitle={
|
||||
cmp
|
||||
? `Comparing ${shown.length} selected cost centres. Untick a row to remove it.`
|
||||
: "Approved spend compared across cost centres (vessels). Tick rows to graph together, or click a row for its report."
|
||||
}
|
||||
/>
|
||||
|
||||
{grand === 0 ? (
|
||||
|
|
@ -96,20 +145,20 @@ export default async function CostCentresReport({
|
|||
) : (
|
||||
<>
|
||||
<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="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
|
||||
<Kpi label="Cost centres" value={String(shown.length)} sub={cmp ? "selected" : `${SCOPE_LABELS[scope]} shown`} />
|
||||
<Kpi label="Highest spender" value={top?.name ?? "—"} 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 cost centre — year over year" : "Monthly spend by cost centre"}
|
||||
{yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "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} />
|
||||
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey={yearly ? "name" : "x"} series={series} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
|
|
@ -131,13 +180,16 @@ export default async function CostCentresReport({
|
|||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<SelectCheckbox checked={sel.includes(r.id)} href={selHref(r.id)} />
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Sparkline values={yearly ? r.fyTotals : r.months} />
|
||||
<Sparkline values={sparkOf(r)} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import {
|
|||
topAccountsForCostCentre,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
accountNodeSpend,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
parseSel,
|
||||
resolveFy,
|
||||
fyLabel,
|
||||
type Tier,
|
||||
|
|
@ -51,17 +53,27 @@ export async function GET(req: NextRequest) {
|
|||
const yearly = gran === "yearly";
|
||||
const fyCols = ds.fys.map(fyLabel);
|
||||
|
||||
const sel = parseSel(sp.get("sel") ?? undefined);
|
||||
|
||||
if (dim === "cost-centre") {
|
||||
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
||||
const rows = applyScope(ranked, scope).map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
|
||||
const picked = sel.length ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
|
||||
const rows = picked.map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
|
||||
return file("pelagia-cost-centre-spend", csv(["Code", "Cost Centre", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
|
||||
}
|
||||
|
||||
if (dim === "accounting-code") {
|
||||
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]);
|
||||
let ranked;
|
||||
if (sel.length) {
|
||||
ranked = sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }));
|
||||
} else {
|
||||
const parent = sp.get("parent");
|
||||
const parentId = parent && idx.byId.has(parent) ? parent : null;
|
||||
ranked = accountLevelRows(ds, idx, parentId, fy);
|
||||
}
|
||||
ranked.sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
||||
const picked = sel.length ? ranked : applyScope(ranked, scope);
|
||||
const rows = picked.map((r) => [r.node.code, r.node.name, r.node.tier, ...r.fyTotals, r.total, r.poCount]);
|
||||
return file("pelagia-accounting-code-spend", csv(["Code", "Name", "Tier", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, Check } 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.
|
||||
|
|
@ -55,6 +55,41 @@ export function SegLink({
|
|||
);
|
||||
}
|
||||
|
||||
// A checkbox rendered as a navigation link — toggles this row's id in the
|
||||
// `?sel=` custom-comparison selection (keeps the report fully server-rendered).
|
||||
export function SelectCheckbox({ checked, href, title }: { checked: boolean; href: string; title?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title ?? "Select to graph"}
|
||||
scroll={false}
|
||||
className={
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border " +
|
||||
(checked ? "border-primary-600 bg-primary-600 text-white" : "border-neutral-300 bg-white hover:border-primary-500")
|
||||
}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Sticky banner shown while rows are selected: jump to the custom comparison or clear.
|
||||
export function CompareBar({ count, compareHref, clearHref }: { count: number; compareHref: string; clearHref: string }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5">
|
||||
<span className="text-sm font-medium text-primary-800">{count} selected</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={compareHref} className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
Compare selected
|
||||
</Link>
|
||||
<Link href={clearHref} className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
|
||||
Clear
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
|
|
|
|||
|
|
@ -11,13 +11,18 @@ interface Props {
|
|||
gran: Granularity;
|
||||
/** Pass a scope to render the Top/Bottom-N "Show" control (index pages only). */
|
||||
scope?: ScopeMode;
|
||||
/** Weekly mode: the selected FY-month index + the 12 month options. */
|
||||
month?: number;
|
||||
monthOptions?: { value: number; label: string }[];
|
||||
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
|
||||
const GRANS: Granularity[] = ["weekly", "monthly", "yearly"];
|
||||
|
||||
// Pinned filter toolbar shared by the report 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) {
|
||||
export function ReportsToolbar({ fys, fy, gran, scope, month, monthOptions, exportHref }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
|
|
@ -33,6 +38,7 @@ export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
|
|||
}
|
||||
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
|
||||
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">
|
||||
|
|
@ -40,7 +46,7 @@ export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
|
|||
<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) => (
|
||||
{GRANS.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => update({ gran: g === "monthly" ? null : g })}
|
||||
|
|
@ -70,6 +76,21 @@ export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
|
|||
</label>
|
||||
)}
|
||||
|
||||
{weekly && monthOptions && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => update({ month: 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"
|
||||
>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ export function fyLabel(start: number): string {
|
|||
export function fyMonthIndex(d: Date): number {
|
||||
return (d.getMonth() - 3 + 12) % 12;
|
||||
}
|
||||
/** Week-of-month bucket: 0–4 (W1–W5) from the day of the month. */
|
||||
export function weekOfMonth(d: Date): number {
|
||||
return Math.min(4, Math.floor((d.getDate() - 1) / 7));
|
||||
}
|
||||
export const WEEK_LABELS = ["W1", "W2", "W3", "W4", "W5"] as const;
|
||||
|
||||
export type Tier = "Heading" | "Sub-heading" | "Leaf";
|
||||
|
||||
|
|
@ -44,13 +49,41 @@ export interface AccountNode {
|
|||
parentId: string | null;
|
||||
tier: Tier;
|
||||
}
|
||||
/** One row per spend PO. */
|
||||
/** One row per (PO, accounting code). Multi-account POs yield several rows. */
|
||||
export interface SpendRow {
|
||||
poId: string;
|
||||
vesselId: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
fy: number;
|
||||
month: number; // 0–11 within the FY
|
||||
month: number; // 0–11 within the FY (Apr=0)
|
||||
week: number; // 0–4 within the calendar month
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a PO's spend across the accounting codes its line items carry, so the
|
||||
* accounting-code report attributes multi-account POs correctly. The PO's
|
||||
* `totalAmount` is allocated **proportionally** to each line's account share
|
||||
* (line `accountId`, falling back to the PO-level account), so the per-PO rows
|
||||
* always sum back to `totalAmount` exactly. With no line items (or zero line
|
||||
* value) the whole amount lands on the PO-level account.
|
||||
*/
|
||||
export function allocatePoSpend(
|
||||
po: { id: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; week: number },
|
||||
lines: { accountId: string | null; amount: number }[]
|
||||
): SpendRow[] {
|
||||
const base = { poId: po.id, vesselId: po.vesselId, fy: po.fy, month: po.month, week: po.week };
|
||||
const byAccount = new Map<string, number>();
|
||||
let lineTotal = 0;
|
||||
for (const l of lines) {
|
||||
const key = l.accountId ?? po.accountId;
|
||||
byAccount.set(key, (byAccount.get(key) ?? 0) + l.amount);
|
||||
lineTotal += l.amount;
|
||||
}
|
||||
if (byAccount.size === 0 || lineTotal <= 0) {
|
||||
return [{ ...base, accountId: po.accountId, amount: po.amount }];
|
||||
}
|
||||
return [...byAccount.entries()].map(([accountId, share]) => ({ ...base, accountId, amount: po.amount * (share / lineTotal) }));
|
||||
}
|
||||
|
||||
export interface ReportDataset {
|
||||
|
|
@ -65,7 +98,14 @@ 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 },
|
||||
select: {
|
||||
id: true,
|
||||
vesselId: true,
|
||||
accountId: true,
|
||||
totalAmount: true,
|
||||
approvedAt: true,
|
||||
lineItems: { select: { accountId: true, totalPrice: 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 } }),
|
||||
|
|
@ -84,13 +124,16 @@ export async function getReportDataset(): Promise<ReportDataset> {
|
|||
const rows: SpendRow[] = [];
|
||||
for (const po of pos) {
|
||||
if (!po.approvedAt) continue;
|
||||
rows.push({
|
||||
const meta = {
|
||||
id: po.id,
|
||||
vesselId: po.vesselId,
|
||||
accountId: po.accountId,
|
||||
amount: Number(po.totalAmount),
|
||||
fy: fyStartYear(po.approvedAt),
|
||||
month: fyMonthIndex(po.approvedAt),
|
||||
});
|
||||
week: weekOfMonth(po.approvedAt),
|
||||
};
|
||||
rows.push(...allocatePoSpend(meta, po.lineItems.map((l) => ({ accountId: l.accountId, amount: Number(l.totalPrice) }))));
|
||||
}
|
||||
|
||||
const fySet = new Set(rows.map((r) => r.fy));
|
||||
|
|
@ -153,8 +196,10 @@ export interface CostCentreSpend {
|
|||
|
||||
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
|
||||
const idx = new Map<string, CostCentreSpend>();
|
||||
const poSets = new Map<string, Set<string>>(); // distinct POs per vessel in the selected FY
|
||||
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) });
|
||||
poSets.set(v.id, new Set());
|
||||
}
|
||||
for (const r of ds.rows) {
|
||||
const row = idx.get(r.vesselId);
|
||||
|
|
@ -164,19 +209,27 @@ export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[]
|
|||
if (r.fy === fy) {
|
||||
row.months[r.month] += r.amount;
|
||||
row.total += r.amount;
|
||||
row.poCount += 1;
|
||||
poSets.get(r.vesselId)!.add(r.poId);
|
||||
}
|
||||
}
|
||||
for (const [id, set] of poSets) idx.get(id)!.poCount = set.size;
|
||||
return [...idx.values()];
|
||||
}
|
||||
|
||||
/** Weekly buckets (W1–W5) of one FY month for a cost centre. */
|
||||
export function costCentreWeekly(ds: ReportDataset, vesselId: string, fy: number, month: number): number[] {
|
||||
const weeks = Array(5).fill(0);
|
||||
for (const r of ds.rows) if (r.vesselId === vesselId && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const poSet = new Set<string>();
|
||||
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);
|
||||
|
|
@ -184,10 +237,18 @@ export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: s
|
|||
if (r.fy === fy) {
|
||||
months[r.month] += r.amount;
|
||||
total += r.amount;
|
||||
poCount += 1;
|
||||
poSet.add(r.poId);
|
||||
}
|
||||
}
|
||||
return { total, months, fyTotals, poCount };
|
||||
return { total, months, fyTotals, poCount: poSet.size };
|
||||
}
|
||||
|
||||
/** Weekly buckets (W1–W5) of one FY month for an account node (rolls leaves up). */
|
||||
export function accountNodeWeekly(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number, month: number): number[] {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const weeks = Array(5).fill(0);
|
||||
for (const r of ds.rows) if (leaves.has(r.accountId) && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export interface NodeSpend {
|
||||
|
|
@ -259,9 +320,9 @@ export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
|
|||
export function parseScope(v: string | undefined): ScopeMode {
|
||||
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
|
||||
}
|
||||
export type Granularity = "yearly" | "monthly";
|
||||
export type Granularity = "yearly" | "monthly" | "weekly";
|
||||
export function parseGranularity(v: string | undefined): Granularity {
|
||||
return v === "yearly" ? "yearly" : "monthly";
|
||||
return v === "yearly" || v === "weekly" ? v : "monthly";
|
||||
}
|
||||
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
|
||||
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
|
||||
|
|
@ -269,3 +330,23 @@ export function resolveFy(ds: ReportDataset, v: string | undefined): number {
|
|||
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
|
||||
return ds.fys[ds.fys.length - 1];
|
||||
}
|
||||
/** Resolve the FY-month index (0–11) for weekly mode (default: latest month with spend, else 0). */
|
||||
export function resolveMonth(ds: ReportDataset, fy: number, v: string | undefined): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 11) return n;
|
||||
let last = 0;
|
||||
for (const r of ds.rows) if (r.fy === fy && r.month > last) last = r.month;
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Parse the `?sel=id1,id2` custom-comparison selection into an ordered, de-duped id list. */
|
||||
export function parseSel(v: string | undefined): string[] {
|
||||
if (!v) return [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of v.split(",")) if (id.trim()) seen.add(id.trim());
|
||||
return [...seen];
|
||||
}
|
||||
/** Toggle an id within a selection list (for the checkbox links). */
|
||||
export function toggleSel(sel: string[], id: string): string[] {
|
||||
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,24 @@ import {
|
|||
fyStartYear,
|
||||
fyLabel,
|
||||
fyMonthIndex,
|
||||
weekOfMonth,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
accountLevelRows,
|
||||
topAccountsForCostCentre,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
allocatePoSpend,
|
||||
type ReportDataset,
|
||||
type AccountNode,
|
||||
} from "@/lib/reports";
|
||||
|
|
@ -24,7 +32,7 @@ const ACCOUNTS: AccountNode[] = [
|
|||
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
|
||||
];
|
||||
|
||||
// fys ascending: [2024, 2025]
|
||||
// fys ascending: [2024, 2025]. PO p1 is multi-account (rows on L1 + L2).
|
||||
const DS: ReportDataset = {
|
||||
vessels: [
|
||||
{ id: "v1", code: "V1", name: "MV One" },
|
||||
|
|
@ -33,11 +41,11 @@ const DS: ReportDataset = {
|
|||
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 },
|
||||
{ poId: "p1", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 },
|
||||
{ poId: "p2", vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1, week: 1 },
|
||||
{ poId: "p1", vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0, week: 0 },
|
||||
{ poId: "p3", vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5, week: 0 },
|
||||
{ poId: "p4", vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11, week: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -61,7 +69,7 @@ describe("costCentreRows", () => {
|
|||
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.poCount).toBe(2); // distinct POs (p1 is multi-account, + p2) — not row count
|
||||
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
|
||||
|
||||
const v2 = rows.find((r) => r.id === "v2")!;
|
||||
|
|
@ -111,6 +119,52 @@ describe("breakdowns", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("line-item account allocation (#3)", () => {
|
||||
const po = { id: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 };
|
||||
it("splits a PO proportionally across its line-item accounts, summing back to the PO total", () => {
|
||||
const out = allocatePoSpend(po, [
|
||||
{ accountId: "L1", amount: 30 },
|
||||
{ accountId: "L2", amount: 90 },
|
||||
]);
|
||||
const byAcc = Object.fromEntries(out.map((r) => [r.accountId, r.amount]));
|
||||
expect(byAcc["L1"]).toBeCloseTo(25); // 100 * 30/120
|
||||
expect(byAcc["L2"]).toBeCloseTo(75); // 100 * 90/120
|
||||
expect(out.reduce((s, r) => s + r.amount, 0)).toBeCloseTo(100);
|
||||
expect(out.every((r) => r.poId === "po9" && r.vesselId === "v1")).toBe(true);
|
||||
});
|
||||
it("falls a line with no account back to the PO-level account", () => {
|
||||
const out = allocatePoSpend(po, [{ accountId: null, amount: 10 }, { accountId: "L2", amount: 10 }]);
|
||||
expect(Object.fromEntries(out.map((r) => [r.accountId, r.amount]))).toEqual({ L1: 50, L2: 50 });
|
||||
});
|
||||
it("puts the whole amount on the PO account when there are no line items", () => {
|
||||
expect(allocatePoSpend(po, [])).toEqual([{ poId: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekly buckets (#1)", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("computes week-of-month from the day", () => {
|
||||
expect(weekOfMonth(new Date(2025, 3, 1))).toBe(0);
|
||||
expect(weekOfMonth(new Date(2025, 3, 8))).toBe(1);
|
||||
expect(weekOfMonth(new Date(2025, 3, 29))).toBe(4);
|
||||
});
|
||||
it("buckets a month's spend into weeks for a cost centre and an account node", () => {
|
||||
expect(costCentreWeekly(DS, "v1", 2025, 0)).toEqual([130, 0, 0, 0, 0]); // both month-0 rows in W1
|
||||
expect(accountNodeWeekly(DS, idx, "H", 2025, 0)).toEqual([130, 0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom selection (#2)", () => {
|
||||
it("parses and de-dupes ?sel=", () => {
|
||||
expect(parseSel("a,b,a, ,c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseSel(undefined)).toEqual([]);
|
||||
});
|
||||
it("toggles ids in and out", () => {
|
||||
expect(toggleSel(["a", "b"], "a")).toEqual(["b"]);
|
||||
expect(toggleSel(["a"], "b")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope + param parsing", () => {
|
||||
it("applies Top/Bottom-N to a descending list", () => {
|
||||
const sorted = [5, 4, 3, 2, 1];
|
||||
|
|
@ -126,4 +180,12 @@ describe("scope + param parsing", () => {
|
|||
expect(resolveFy(DS, undefined)).toBe(2025); // latest
|
||||
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
|
||||
});
|
||||
it("parses granularity (incl. weekly) and resolves the weekly month", () => {
|
||||
expect(parseGranularity("weekly")).toBe("weekly");
|
||||
expect(parseGranularity("yearly")).toBe("yearly");
|
||||
expect(parseGranularity(undefined)).toBe("monthly");
|
||||
expect(resolveMonth(DS, 2025, undefined)).toBe(11); // latest month with spend in FY2025
|
||||
expect(resolveMonth(DS, 2025, "3")).toBe(3);
|
||||
expect(resolveMonth(DS, 2025, "99")).toBe(11); // out of range → latest
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue