pelagia-portal/App/components/reports/reports-toolbar.tsx
Hardik 47ac2c7813
All checks were successful
PR checks / checks (pull_request) Successful in 48s
PR checks / integration (pull_request) Successful in 31s
feat(reports): weekly granularity, custom compare, line-item allocation
Picks up the three pieces deferred from the initial reports PR:

#3 Line-item account allocation — allocatePoSpend() splits each PO across the
accounting codes its line items carry (line accountId, falling back to the
PO-level account), proportionally so per-PO rows sum back to totalAmount. The
accounting-code report now attributes multi-account POs correctly. SpendRow
gains poId; poCount is now distinct POs, not row count.

#2 Custom "Add to graph" — tick rows on either index (SelectCheckbox links
write ?sel=id1,id2), then "Compare selected" (?cmp=1) shows a custom comparison
of just those entities. Fully server-rendered + shareable; export honours sel.

#1 Weekly granularity — a third Granularity that focuses one FY month and
buckets spend by week-of-month (W1–W5) from approvedAt, with a Month picker in
the toolbar. Real buckets (not the mockup's synthetic split).

All three are URL-driven like the rest, so no client fetching. Charts/KPIs/
detail trends all branch on the new mode.

Tests: +8 unit cases (allocation proportional/fallback/empty, weekly buckets,
sel parse/toggle, month + granularity parsing); fixture updated for poId/week.
Full unit suite 311 green; tsc clean. Smoke-tested weekly + custom-compare +
exports end-to-end (all 200). Docs + wiki updated to mark them implemented.

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

119 lines
4.7 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;
/** Weekly mode: the selected FY-month index + the 12 month options. */
month?: number;
monthOptions?: { value: number; label: string }[];
exportHref: string;
}
const GRANS: Granularity[] = ["weekly", "monthly", "yearly"];
// Pinned filter toolbar shared by the report 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, month, monthOptions, 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";
const weekly = gran === "weekly";
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">
{GRANS.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>
)}
{weekly && monthOptions && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
<select
value={month}
onChange={(e) => update({ month: 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"
>
{monthOptions.map((m) => (
<option key={m.value} value={m.value}>{m.label}</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>
);
}