diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 9b9ed13..38e9495 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -153,6 +153,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. **`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** 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). + +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: diff --git a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx new file mode 100644 index 0000000..1a11a21 --- /dev/null +++ b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx @@ -0,0 +1,193 @@ +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, + 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"; +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 = { + 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; month?: 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 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] })) + : 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 }); + 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` : 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()}`; + }; + 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 ( +
+ + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> + + + ← Back to Accounting Codes + + + {node.tier}} + /> + + + + + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> + + +
+

Spend trend

+ +
+ +
+
+

{breakTitle}

+
+ {!leaf && ( + q({ break: v, topn: sp.topn ?? "5" })} + /> + )} + q({ break: breakMode, topn: v })} + /> +
+
+ {breakdown.length === 0 ? ( +

No spend to break down for {periodLabel}.

+ ) : ( +
+
+ +
+
+ + + + + + + + + + {breakdown.map((b, i) => ( + + + + + + ))} + +
{breakLabel}Spend%
+ + {b.label} + {formatCurrency(b.value)}{((b.value / breakTotal) * 100).toFixed(0)}%
+
+
+ )} +
+
+ ); +} diff --git a/App/app/(portal)/reports/accounting-codes/page.tsx b/App/app/(portal)/reports/accounting-codes/page.tsx new file mode 100644 index 0000000..cf5a9c8 --- /dev/null +++ b/App/app/(portal)/reports/accounting-codes/page.tsx @@ -0,0 +1,228 @@ +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, + 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, SelectCheckbox, CompareBar } 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 = { + 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; month?: string; parent?: string; sel?: string; cmp?: 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 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 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 = 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]; + + 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[]; + let series: Series[]; + if (yearly) { + chartData = shown.map((r) => { + const row: Record = { 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 { + 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 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); + + 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} + /> + + {cmp ? ( + + ← Back to browse + + ) : ( + <> + {parentNode && ( + + ← Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"} + + )} + {sel.length > 0 && } + + )} + + + + {grand === 0 ? ( +
+ No approved spend recorded for {periodLabel} yet. +
+ ) : ( + <> + + + + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> + + +
+
+

+ {yearly ? `Spend by ${childTier.toLowerCase()} — year over year` : weekly ? `Weekly spend by ${childTier.toLowerCase()}` : `Monthly spend by ${childTier.toLowerCase()}`} +

+ {periodLabel} +
+ +
+ +
+ {shown.map((r) => { + const value = rankOf(r); + const pct = grand ? (value / grand) * 100 : 0; + const leaf = idx.isLeaf(r.node.id); + const inner = ( + <> + {r.node.code} + {r.node.name} + {r.node.tier} + + {formatCurrency(value)} +
{pct.toFixed(0)}%
+ {!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 new file mode 100644 index 0000000..ef024d6 --- /dev/null +++ b/App/app/(portal)/reports/cost-centres/[id]/page.tsx @@ -0,0 +1,161 @@ +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, + costCentreWeekly, + topAccountsForCostCentre, + parseGranularity, + resolveFy, + resolveMonth, + fyLabel, + FY_MONTHS, + WEEK_LABELS, + 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; month?: 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 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] })) + : 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 }); + 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` : 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()}`; + }; + const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`; + + return ( +
+ + ({ value: i, label: monthLabel(i) }))} + exportHref={exportHref} + /> + + + ← Back to Cost Centres + + + + + + + + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> + + +
+

Spend trend

+ +
+ +
+
+

Top accounting codes

+
+ ({ value: t, label: t }))} current={tier} hrefFor={(v) => q({ tier: v, topn: sp.topn ?? "5" })} /> + q({ tier, topn: v })} + /> +
+
+ {breakdown.length === 0 ? ( +

No spend at this tier for {periodLabel}.

+ ) : ( +
+
+ +
+
+ + + + + + + + + + {breakdown.map((b, i) => ( + + + + + + ))} + +
{tier}Spend%
+ + {b.label} + {formatCurrency(b.value)}{((b.value / breakTotal) * 100).toFixed(0)}%
+
+
+ )} +
+
+ ); +} diff --git a/App/app/(portal)/reports/cost-centres/page.tsx b/App/app/(portal)/reports/cost-centres/page.tsx new file mode 100644 index 0000000..acccab0 --- /dev/null +++ b/App/app/(portal)/reports/cost-centres/page.tsx @@ -0,0 +1,219 @@ +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, + 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, SelectCheckbox, CompareBar } 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; month?: string; sel?: string; cmp?: 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 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: CostCentreSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total); + ranked.sort((a, b) => rankOf(b) - rankOf(a)); + const shown = cmp ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope); + const grand = shown.reduce((s, r) => s + rankOf(r), 0); + const top = shown[0]; + const sparkOf = (r: CostCentreSpend) => (yearly ? r.fyTotals : weekly ? costCentreWeekly(ds, r.id, fy, month) : r.months); + + 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; + + // Chart data. + const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length]; + let chartData: Record[]; + let series: Series[]; + if (yearly) { + chartData = shown.map((r) => { + const row: Record = { 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 { + 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 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 ? ( +
+ No approved spend recorded for {periodLabel} yet. +
+ ) : ( + <> + + + + + = 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} /> + + +
+
+

+ {yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "Monthly spend by cost centre"} +

+ {periodLabel} +
+ +
+ +
+ + + + + + + + + + + + {shown.map((r) => { + const value = rankOf(r); + const pct = grand ? (value / grand) * 100 : 0; + return ( + + + + + + + + + ); + })} + +
Cost CentreTrendTotal Spend% of ShownPOs +
+
+ + + {r.name} + {r.code} + +
+
+ + {formatCurrency(value)} +
+
+
+
+ {pct.toFixed(0)}% +
+
{r.poCount} + + + +
+
+ + )} +
+ ); +} diff --git a/App/app/api/reports/spend/route.ts b/App/app/api/reports/spend/route.ts new file mode 100644 index 0000000..1774adb --- /dev/null +++ b/App/app/api/reports/spend/route.ts @@ -0,0 +1,97 @@ +import { auth } from "@/auth"; +import { hasPermission } from "@/lib/permissions"; +import { NextRequest, NextResponse } from "next/server"; +import { + getReportDataset, + buildAccountIndex, + costCentreRows, + accountLevelRows, + topAccountsForCostCentre, + costCentresForAccount, + childBreakdown, + accountNodeSpend, + applyScope, + parseScope, + parseGranularity, + parseSel, + 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); + + 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 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") { + 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)); + } + + 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 }); +} diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index 4eba98d..b9ef796 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -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 && (
- {section.items.map((item) => ( - + {section.groups.map((group, gi) => ( +
+ {group.label && ( +

+ {group.label} +

+ )} + {group.items.map((item) => ( + + ))} +
))}
)} diff --git a/App/components/reports/charts.tsx b/App/components/reports/charts.tsx new file mode 100644 index 0000000..7dfd4e6 --- /dev/null +++ b/App/components/reports/charts.tsx @@ -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[]; + 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 ( + + {kind === "lines" ? ( + + + + + [fullINR(Number(v)), name]} /> + + {series.map((s) => ( + + ))} + + ) : ( + + + + + [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} /> + + {series.map((s) => ( + + ))} + + )} + + ); +} + +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 ( + + {kind === "line" ? ( + + + + + [fullINR(Number(v)), "Spend"]} /> + + + ) : ( + + + + + [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} /> + + + )} + + ); +} + +/** 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 ( + + + + + + [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} /> + + {trimmed.map((_, i) => ( + + ))} + + + + ); +} + +/** 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 ; + 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 ( + + + + + ); +} diff --git a/App/components/reports/kpi.tsx b/App/components/reports/kpi.tsx new file mode 100644 index 0000000..79c9deb --- /dev/null +++ b/App/components/reports/kpi.tsx @@ -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 ( +
+

{label}

+

{value}

+

{sub ?? " "}

+
+ ); +} + +export function KpiStrip({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/App/components/reports/report-header.tsx b/App/components/reports/report-header.tsx new file mode 100644 index 0000000..4495868 --- /dev/null +++ b/App/components/reports/report-header.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +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. +export function ReportBreadcrumb({ trail }: { trail: { label: string; href?: string }[] }) { + return ( + + ); +} + +// 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 ( +
+ {label} +
+ {options.map((o) => ( + + {o.label} + + ))} +
+
+ ); +} + +// 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 ( +
+
+

{title}

+ {badge} +
+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/App/components/reports/reports-toolbar.tsx b/App/components/reports/reports-toolbar.tsx new file mode 100644 index 0000000..67cc6b5 --- /dev/null +++ b/App/components/reports/reports-toolbar.tsx @@ -0,0 +1,119 @@ +"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; + /** Weekly mode: the selected FY-month index + the 12 month options. */ + month?: number; + monthOptions?: { value: number; label: string }[]; + exportHref: string; +} + +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, month, monthOptions, exportHref }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + + function update(patch: Record) { + 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"; + const weekly = gran === "weekly"; + + return ( +
+
+
+ Granularity +
+ {GRANS.map((g) => ( + + ))} +
+
+ + {!yearly && ( + + )} + + {weekly && monthOptions && ( + + )} + + {scope && ( + + )} + + + + Export + +
+
+ ); +} diff --git a/App/lib/reports.ts b/App/lib/reports.ts new file mode 100644 index 0000000..7ece38c --- /dev/null +++ b/App/lib/reports.ts @@ -0,0 +1,352 @@ +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; +} +/** 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"; + +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 (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 (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(); + 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 { + 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 { + const [pos, vessels, accounts] = await Promise.all([ + db.purchaseOrder.findMany({ + where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } }, + 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 } }), + ]); + + const childCount = new Map(); + 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; + 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)); + 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; + childrenOf: (parentId: string | null) => AccountNode[]; + leavesUnder: (id: string) => Set; + 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(); + 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>(); + function leavesUnder(id: string): Set { + const cached = leafCache.get(id); + if (cached) return cached; + const out = new Set(); + 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(); + const poSets = new Map>(); // 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); + 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; + 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(); + let total = 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; + poSet.add(r.poId); + } + } + 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 { + 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(); + 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 = { 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(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" | "weekly"; +export function parseGranularity(v: string | undefined): Granularity { + 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 { + const n = v ? Number(v) : NaN; + 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(); + 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]; +} diff --git a/App/tests/unit/reports.test.ts b/App/tests/unit/reports.test.ts new file mode 100644 index 0000000..0eb63ec --- /dev/null +++ b/App/tests/unit/reports.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +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"; + +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]. PO p1 is multi-account (rows on L1 + L2). +const DS: ReportDataset = { + vessels: [ + { id: "v1", code: "V1", name: "MV One" }, + { id: "v2", code: "V2", name: "MV Two" }, + ], + accounts: ACCOUNTS, + fys: [2024, 2025], + rows: [ + { 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 }, + ], +}; + +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(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")!; + 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("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]; + 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 + }); + 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 + }); +}); diff --git a/App/tests/unit/sidebar.test.tsx b/App/tests/unit/sidebar.test.tsx index 5facd73..8d55b2d 100644 --- a/App/tests/unit/sidebar.test.tsx +++ b/App/tests/unit/sidebar.test.tsx @@ -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(); + // 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(); + 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(); + expect(screen.queryByRole("button", { name: /^Reports/i })).not.toBeInTheDocument(); + }); +});