Implements the wiki "Reports Mockup" as a Reports → Purchasing sidebar section, wired to real approved-PO spend. Two report families, each index → drill/detail: - Cost Centres (/reports/cost-centres) — spend compared across vessels; row opens a cost-centre report with a Top-accounting-codes breakdown re-pivotable by tier (Heading/Sub/Leaf) + Top-N. - Accounting Codes (/reports/accounting-codes) — drills the Account tree (headings → sub → leaves) via ?parent=; a leaf opens its report broken down by cost centre (or, for a non-leaf, by sub-account). Shared: a pinned filter toolbar (Granularity Monthly/Yearly, Financial Year, Show Top5/Top10/Bottom5/All) whose values live in the URL query so the server component re-renders — no client fetching. KPI tiles, recharts comparison/trend/ breakdown charts, per-row trend sparklines, and CSV export (/api/reports/spend). - lib/reports.ts: the pure, unit-tested aggregation core. Spend = a PO once it reaches POST_APPROVAL_STATUSES, dated by approvedAt, valued at totalAmount (the dashboard's basis); Indian Apr–Mar FY; each PO's leaf accountId rolled up to parents. One query in getReportDataset(), everything else pure. - Sidebar: new collapsible "Reports" section with a "Purchasing" subheading (subgroup support added to the Section model). Gated by view_analytics (Manager/SuperUser/Auditor/Admin); export by the same. Deferred (documented): synthetic Weekly granularity, the "Add to graph" custom multi-select, and line-item-level account allocation (v1 uses the PO-level account). Sites are not cost centres — only vessels. Tests: 11 unit cases for the aggregation core + 3 sidebar cases for the Reports section. Full unit suite 303 green; tsc clean. Smoke-tested all routes end to end against seed data (index/drill/detail/export 200; non-analytics role 307/403). Wiki: "Reports Mockup" marked implemented; "Pages and Navigation" lists the new routes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
6.2 KiB
TypeScript
147 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
CartesianGrid,
|
|
Cell,
|
|
} from "recharts";
|
|
|
|
export const SERIES_COLORS = ["#2563eb", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#dc2626", "#ca8a04", "#4f46e5", "#0d9488", "#db2777"];
|
|
|
|
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
|
|
export function formatINRShort(n: number): string {
|
|
const a = Math.abs(n);
|
|
if (a >= 1_00_00_000) return `₹${(n / 1_00_00_000).toFixed(1)}Cr`;
|
|
if (a >= 1_00_000) return `₹${(n / 1_00_000).toFixed(1)}L`;
|
|
if (a >= 1_000) return `₹${(n / 1_000).toFixed(0)}K`;
|
|
return `₹${n.toFixed(0)}`;
|
|
}
|
|
function fullINR(n: number): string {
|
|
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
|
}
|
|
|
|
export interface Series {
|
|
key: string;
|
|
color: string;
|
|
}
|
|
|
|
interface ComparisonProps {
|
|
kind: "lines" | "bars";
|
|
data: Record<string, string | number>[];
|
|
xKey: string;
|
|
series: Series[];
|
|
height?: number;
|
|
}
|
|
|
|
/** Multi-series comparison: monthly trend lines, or year-over-year grouped bars. */
|
|
export function ComparisonChart({ kind, data, xKey, series, height = 340 }: ComparisonProps) {
|
|
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
{kind === "lines" ? (
|
|
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
|
<XAxis dataKey={xKey} {...axis} />
|
|
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
|
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} />
|
|
<Legend wrapperStyle={{ fontSize: 11 }} iconType="plainline" />
|
|
{series.map((s) => (
|
|
<Line key={s.key} type="monotone" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 5 }} />
|
|
))}
|
|
</LineChart>
|
|
) : (
|
|
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
|
<XAxis dataKey={xKey} {...axis} />
|
|
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
|
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} />
|
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
{series.map((s) => (
|
|
<Bar key={s.key} dataKey={s.key} fill={s.color} radius={[3, 3, 0, 0]} />
|
|
))}
|
|
</BarChart>
|
|
)}
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
interface TrendProps {
|
|
kind: "line" | "bar";
|
|
data: { label: string; value: number }[];
|
|
height?: number;
|
|
}
|
|
|
|
/** Single-series spend trend (monthly line or yearly bar). */
|
|
export function TrendChart({ kind, data, height = 300 }: TrendProps) {
|
|
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
{kind === "line" ? (
|
|
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
|
<XAxis dataKey="label" {...axis} />
|
|
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
|
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} />
|
|
<Line type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} fill="rgba(37,99,235,0.08)" />
|
|
</LineChart>
|
|
) : (
|
|
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
|
<XAxis dataKey="label" {...axis} />
|
|
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
|
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
|
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
)}
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
/** Horizontal top-N breakdown bars (each bar its own colour). */
|
|
export function BreakdownChart({ data, height = 300 }: { data: { label: string; value: number }[]; height?: number }) {
|
|
const trimmed = data.map((d) => ({ ...d, short: d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label }));
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<BarChart layout="vertical" data={trimmed} margin={{ top: 4, right: 16, bottom: 4, left: 8 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
|
|
<XAxis type="number" tickFormatter={formatINRShort} tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
|
|
<YAxis type="category" dataKey="short" width={140} tick={{ fontSize: 11, fill: "#525252" }} tickLine={false} axisLine={false} />
|
|
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
|
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
|
{trimmed.map((_, i) => (
|
|
<Cell key={i} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
/** Tiny inline trend sparkline (plain SVG — no chart library needed per row). */
|
|
export function Sparkline({ values, width = 90, height = 28 }: { values: number[]; width?: number; height?: number }) {
|
|
if (values.length < 2) return <svg width={width} height={height} />;
|
|
const max = Math.max(...values);
|
|
const min = Math.min(...values);
|
|
const pad = 3;
|
|
const span = max - min || 1;
|
|
const pts = values.map((v, i) => {
|
|
const x = pad + (i / (values.length - 1)) * (width - 2 * pad);
|
|
const y = height - pad - ((v - min) / span) * (height - 2 * pad);
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
});
|
|
const last = pts[pts.length - 1].split(",");
|
|
return (
|
|
<svg width={width} height={height} className="overflow-visible">
|
|
<polyline points={pts.join(" ")} fill="none" stroke="#2563eb" strokeWidth={1.5} />
|
|
<circle cx={last[0]} cy={last[1]} r={2} fill="#2563eb" />
|
|
</svg>
|
|
);
|
|
}
|