pelagia-portal/App/app/(portal)/reports/cost-centres/page.tsx
Hardik 34143b5e75
Some checks failed
PR checks / checks (pull_request) Failing after 15s
PR checks / integration (pull_request) Successful in 41s
fix(reports): chart series all rendered one colour (RSC boundary bug)
The comparison charts (and detail-page breakdown swatches) rendered every
series in recharts' default colour instead of the per-item palette.

Root cause: `SERIES_COLORS` was defined in `components/reports/charts.tsx`,
which is a "use client" module. The report **pages are server components** and
imported the palette from it. A plain value imported from a client module into
a server component is a client-reference proxy, not the real array — so
`SERIES_COLORS[i % SERIES_COLORS.length]` was `SERIES_COLORS[NaN]` → undefined,
every line got `stroke={undefined}`, and recharts fell back to #3182bd. (The
literal `strokeWidth={2}` still applied, which is why only the colour was wrong.
It passed jsdom tests because those import the array directly, not across the
RSC boundary.)

Fix: move the palette to a dependency-free shared module `lib/report-colors.ts`
(no "use client", no server-only imports) that resolves to the real array in
both server and client graphs. `charts.tsx` and all four report pages import it
from there. It can't live in `lib/reports.ts` (that imports Prisma `db`, which
must not enter the client bundle).

Verified in a real browser: line strokes now cycle the 10-colour palette
(#2563eb, #16a34a, …) instead of a uniform #3182bd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:31:40 +05:30

211 lines
9.7 KiB
TypeScript

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, type Series } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
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 — one distinct colour per item (series) in every granularity; the
// x-axis is months / weeks / financial years. (Yearly is grouped bars per item,
// not per FY, so each cost centre keeps its own colour.)
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
const row: Record<string, string | number> = { x: lab };
shown.forEach((r) => (row[r.name] = sparkOf(r)[i]));
return row;
});
const series: 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<string, string | undefined> = {
fy: String(fy),
gran: gran === "monthly" ? undefined : gran,
scope: scope === "top5" ? undefined : scope,
month: weekly ? String(month) : undefined,
};
const qs = (extra: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...baseParams, ...extra })) if (v) p.set(k, v);
const s = p.toString();
return s ? `?${s}` : "";
};
const selHref = (id: string) => {
const next = toggleSel(sel, id);
return `/reports/cost-centres${qs({ sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
};
const detailHref = (id: string) => `/reports/cost-centres/${id}${qs({ scope: undefined })}`;
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}${cmp ? `&sel=${sel.join(",")}` : ""}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
scope={cmp ? undefined : scope}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
{cmp ? (
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to browse
</Link>
) : (
sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ sel: undefined, cmp: undefined })} />
)}
<ReportTitle
title={cmp ? "Custom comparison" : "Cost Centres"}
subtitle={
cmp
? `Comparing ${shown.length} selected cost centres. Untick a row to remove it.`
: "Approved spend compared across cost centres (vessels). Tick rows to graph together, or click a row for its report."
}
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
<Kpi label="Cost centres" value={String(shown.length)} sub={cmp ? "selected" : `${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top?.name ?? "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "Monthly spend by cost centre"}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="px-5 py-3">Cost Centre</th>
<th className="px-5 py-3">Trend</th>
<th className="px-5 py-3 text-right">Total Spend</th>
<th className="px-5 py-3 text-right">% of Shown</th>
<th className="px-5 py-3 text-right">POs</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
return (
<tr key={r.id} className="group hover:bg-primary-50/40">
<td className="px-5 py-3">
<div className="flex items-center gap-3">
<SelectCheckbox checked={sel.includes(r.id)} href={selHref(r.id)} />
<Link href={detailHref(r.id)} className="block font-medium text-neutral-900 group-hover:text-primary-700">
{r.name}
<span className="ml-2 text-xs font-normal text-neutral-400">{r.code}</span>
</Link>
</div>
</td>
<td className="px-5 py-3">
<Sparkline values={sparkOf(r)} />
</td>
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100">
<div className="h-full rounded-full bg-primary-600" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className="w-10 text-right tabular-nums text-neutral-500">{pct.toFixed(0)}%</span>
</div>
</td>
<td className="px-5 py-3 text-right tabular-nums text-neutral-500">{r.poCount}</td>
<td className="px-5 py-3 text-right">
<Link href={detailHref(r.id)}>
<ChevronRight className="inline h-4 w-4 text-neutral-300 group-hover:text-primary-500" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
);
}