pelagia-portal/App/components/reports/report-header.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

68 lines
2.2 KiB
TypeScript

import Link from "next/link";
import { ChevronRight } 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 (
<nav className="mb-4 flex flex-wrap items-center gap-2 text-sm text-neutral-500">
<span>Reports</span>
{trail.map((t, i) => (
<span key={i} className="flex items-center gap-2">
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{t.href ? (
<Link href={t.href} className="hover:text-neutral-800">{t.label}</Link>
) : (
<span className="font-medium text-neutral-900">{t.label}</span>
)}
</span>
))}
</nav>
);
}
// 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 (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">{label}</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">
{options.map((o) => (
<Link
key={o.value}
href={hrefFor(o.value)}
className={
"rounded-md px-2.5 py-1 font-medium " +
(o.value === current ? "bg-primary-600 text-white" : "text-neutral-500 hover:text-neutral-800")
}
>
{o.label}
</Link>
))}
</div>
</div>
);
}
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
return (
<div className="mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{title}</h1>
{badge}
</div>
{subtitle && <p className="mt-1 text-sm text-neutral-500">{subtitle}</p>}
</div>
);
}