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>
119 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|