diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7de9224..5df2eb6 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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 `` (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 `` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `` (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) diff --git a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx index 09facfc..1a11a21 100644 --- a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx +++ b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx @@ -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) => { 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 (
- + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> - - + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> diff --git a/App/app/(portal)/reports/accounting-codes/page.tsx b/App/app/(portal)/reports/accounting-codes/page.tsx index 2c8fb6e..cf5a9c8 100644 --- a/App/app/(portal)/reports/accounting-codes/page.tsx +++ b/App/app/(portal)/reports/accounting-codes/page.tsx @@ -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 = { 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 = { 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 = { 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 = { + fy: String(fy), + gran: gran === "monthly" ? undefined : gran, + scope: scope === "top5" ? undefined : scope, + month: weekly ? String(month) : undefined, + }; + const qs = (extra: Record) => { + 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 (
- + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> - {parentNode && ( - - ← Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"} + {cmp ? ( + + ← Back to browse + ) : ( + <> + {parentNode && ( + + ← Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"} + + )} + {sel.length > 0 && } + )} @@ -133,8 +177,8 @@ export default async function AccountingCodesReport({ ) : ( <> - s + (yearly ? sum(r.fyTotals) : r.total), 0))} sub={periodLabel} /> - + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> @@ -142,11 +186,11 @@ export default async function AccountingCodesReport({

- {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()}`}

{periodLabel}
- +
@@ -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 ( - + const inner = ( + <> {r.node.code} {r.node.name} {r.node.tier} - + {formatCurrency(value)}
{pct.toFixed(0)}%
- {leaf ? ( - + {!cmp && (leaf ? : )} + + ); + return ( +
+ + {cmp ? ( +
{inner}
) : ( - + {inner} )} - +
); })}
diff --git a/App/app/(portal)/reports/cost-centres/[id]/page.tsx b/App/app/(portal)/reports/cost-centres/[id]/page.tsx index 714537f..ef024d6 100644 --- a/App/app/(portal)/reports/cost-centres/[id]/page.tsx +++ b/App/app/(portal)/reports/cost-centres/[id]/page.tsx @@ -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) => { 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 (
- + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> ← Back to Cost Centres @@ -83,8 +100,8 @@ export default async function CostCentreDetail({ - - + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> diff --git a/App/app/(portal)/reports/cost-centres/page.tsx b/App/app/(portal)/reports/cost-centres/page.tsx index 672aaae..acccab0 100644 --- a/App/app/(portal)/reports/cost-centres/page.tsx +++ b/App/app/(portal)/reports/cost-centres/page.tsx @@ -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[]; let series: Series[]; - const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length]; if (yearly) { chartData = shown.map((r) => { const row: Record = { 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 = { 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 = { 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 = { + fy: String(fy), + gran: gran === "monthly" ? undefined : gran, + scope: scope === "top5" ? undefined : scope, + month: weekly ? String(month) : undefined, + }; + const qs = (extra: Record) => { + 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 (
- + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> + + {cmp ? ( + + ← Back to browse + + ) : ( + sel.length > 0 && + )} {grand === 0 ? ( @@ -96,20 +145,20 @@ export default async function CostCentresReport({ ) : ( <> - - - + + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />

- {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"}

{periodLabel}
- +
@@ -131,13 +180,16 @@ export default async function CostCentresReport({ return ( - - {r.name} - {r.code} - +
+ + + {r.name} + {r.code} + +
- + {formatCurrency(value)} diff --git a/App/app/api/reports/spend/route.ts b/App/app/api/reports/spend/route.ts index 2ff28c5..1774adb 100644 --- a/App/app/api/reports/spend/route.ts +++ b/App/app/api/reports/spend/route.ts @@ -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)); } diff --git a/App/components/reports/report-header.tsx b/App/components/reports/report-header.tsx index 4d53ddc..4495868 100644 --- a/App/components/reports/report-header.tsx +++ b/App/components/reports/report-header.tsx @@ -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 ( + + {checked && } + + ); +} + +// 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 ( +
+ {count} selected +
+ + Compare selected + + + Clear + +
+
+ ); +} + export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) { return (
diff --git a/App/components/reports/reports-toolbar.tsx b/App/components/reports/reports-toolbar.tsx index 8a0fe16..67cc6b5 100644 --- a/App/components/reports/reports-toolbar.tsx +++ b/App/components/reports/reports-toolbar.tsx @@ -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 (
@@ -40,7 +46,7 @@ export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
Granularity
- {(["monthly", "yearly"] as Granularity[]).map((g) => ( + {GRANS.map((g) => (