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>
98 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|