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>
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import Link from "next/link";
|
|
import { ChevronRight, Check } 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>
|
|
);
|
|
}
|
|
|
|
// A checkbox rendered as a navigation link — toggles this row's id in the
|
|
// `?sel=` custom-comparison selection (keeps the report fully server-rendered).
|
|
export function SelectCheckbox({ checked, href, title }: { checked: boolean; href: string; title?: string }) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
title={title ?? "Select to graph"}
|
|
scroll={false}
|
|
className={
|
|
"flex h-4 w-4 shrink-0 items-center justify-center rounded border " +
|
|
(checked ? "border-primary-600 bg-primary-600 text-white" : "border-neutral-300 bg-white hover:border-primary-500")
|
|
}
|
|
>
|
|
{checked && <Check className="h-3 w-3" />}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// Sticky banner shown while rows are selected: jump to the custom comparison or clear.
|
|
export function CompareBar({ count, compareHref, clearHref }: { count: number; compareHref: string; clearHref: string }) {
|
|
return (
|
|
<div className="mb-4 flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5">
|
|
<span className="text-sm font-medium text-primary-800">{count} selected</span>
|
|
<div className="flex items-center gap-2">
|
|
<Link href={compareHref} className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700">
|
|
Compare selected
|
|
</Link>
|
|
<Link href={clearHref} className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
|
|
Clear
|
|
</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>
|
|
);
|
|
}
|