pelagia-portal/App/components/reports/reports-toolbar.tsx
Hardik 8c6bbd8304
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
feat(reports): Purchasing spend analytics (Cost Centres + Accounting Codes)
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>
2026-06-24 07:52:23 +05:30

98 lines
3.9 KiB
TypeScript

"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;
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
// re-renders the report for the new filters — no client-side data fetching.
export function ReportsToolbar({ fys, fy, gran, scope, exportHref }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
function update(patch: Record<string, string | null>) {
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";
return (
<div className="sticky top-0 z-20 -mx-4 mb-6 border-b border-neutral-200 bg-neutral-50/95 px-4 py-3 backdrop-blur md:-mx-6 md:px-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
{(["monthly", "yearly"] as Granularity[]).map((g) => (
<button
key={g}
onClick={() => update({ gran: g === "monthly" ? null : g })}
className={cn(
"rounded-md px-3 py-1 font-medium capitalize transition-colors",
gran === g ? "bg-primary-600 text-white shadow-sm" : "text-neutral-500 hover:text-neutral-800"
)}
>
{g}
</button>
))}
</div>
</div>
{!yearly && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
<select
value={fy}
onChange={(e) => update({ fy: e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{[...fys].reverse().map((y) => (
<option key={y} value={y}>{fyLabel(y)}</option>
))}
</select>
</label>
)}
{scope && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
<select
value={scope}
onChange={(e) => update({ scope: e.target.value === "top5" ? null : e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{(Object.keys(SCOPE_LABELS) as ScopeMode[]).map((s) => (
<option key={s} value={s}>{SCOPE_LABELS[s]}</option>
))}
</select>
</label>
)}
<a
href={exportHref}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
<Download className="h-4 w-4" />
Export
</a>
</div>
</div>
);
}