Simpl-Resultat/src/components/reports/CategoryOverTimeTable.tsx
le king fu 48adb3db77
All checks were successful
PR Check / rust (pull_request) Successful in 23m0s
PR Check / frontend (pull_request) Successful in 2m30s
feat(reports): collapse category hierarchy at every level (socle + 3 reports)
Generalize the report category collapse from level-1-only to every hierarchy
level, on the three hierarchical report tables (real-vs-real Compare,
real-vs-budget Compare, Trends by category). Visibility is now decided by an
ancestor walk, not by row adjacency, so it is independent of row order (the
level-ordered budget grid emits a non-DFS order).

- collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff
  any ancestor is collapsed); add parentKeyOf + injective `p:` keys;
  collapsibleKeys returns all parents (any depth); extract the pure, tested
  isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard.
- useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with
  the profile) instead of localStorage; storageKey nullable (no persistence);
  options.defaultExpanded; async hydration (no flash); collapseAll(rows).
- 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add
  parentKeyOf accessors, aria-level on parent rows.
- Delete dead CategoryTable.tsx (0 imports).
- Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard,
  cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel
  fixture to 3 levels with cascade assertions.

Collapse stays purely visual: subtotals and result lines are computed from raw
rows, never from visible rows.

Resolves #288

Generated autonomously by /autopilot run of 2026-07-15

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:58:18 -04:00

254 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
import {
OVERTIME_COLLAPSE_ACCESSORS,
OVERTIME_EXPANDED_STORAGE_KEY,
groupOverTimeSections,
type OverTimeRenderSection,
} from "./overTimeTableModel";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
function resultColor(value: number): string {
if (value > 0) return "var(--positive, #10b981)";
if (value < 0) return "var(--negative, #ef4444)";
return "";
}
const SECTION_LABEL_KEY: Record<OverTimeType, string> = {
income: "reports.compare.sections.income",
expense: "reports.compare.sections.expenses",
transfer: "reports.compare.sections.transfers",
};
const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
income: "reports.compare.totalIncome",
expense: "reports.compare.totalExpenses",
transfer: "reports.compare.totalTransfers",
};
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
}
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
const { t } = useTranslation();
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
// persisted under a key distinct from the comparable tables. Called before the
// early return so the hook order stays stable.
const groups = useCollapsibleGroups<OverTimeRow>(
OVERTIME_EXPANDED_STORAGE_KEY,
OVERTIME_COLLAPSE_ACCESSORS,
);
if (data.data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
);
}
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
// the result lines are exact, and homonym categories never collide.
const analysis = computeOverTimeResults(data);
const { months, hasTransfers, net, beforeTransfers } = analysis;
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
const colSpan = months.length + 2;
const hasGroups = groups.groupCount(data.tree) > 0;
const allExpanded = groups.allExpanded(data.tree);
// A section = its header, its (visibly) indented hierarchy rows, and a
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
// comparable tables); the subtotal is always the reducer's leaf sum, so it
// never changes when a group is folded away.
const renderSection = (section: OverTimeRenderSection) => (
<Fragment key={section.type}>
{/* Section header */}
<tr className="bg-[var(--muted)]">
<td
colSpan={colSpan}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral leaf/parent magnitudes (not deltas) */}
{groups.visible(section.rows).map((row) => {
const isParent = row.is_parent;
const depth = row.depth;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const collapsed = isParent && groups.isCollapsed(row);
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
aria-level={isParent ? depth + 1 : undefined}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
{isParent ? (
<button
type="button"
onClick={() => groups.toggle(row)}
aria-expanded={!collapsed}
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
>
{collapsed ? (
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
)}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</button>
) : (
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
)}
</td>
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
</td>
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(row.total)}
</td>
</tr>
);
})}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(SECTION_TOTAL_KEY[section.type])}
</td>
{months.map((month) => (
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
{cadFormatter(section.monthly[month])}
</td>
))}
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(section.total)}
</td>
</tr>
</Fragment>
);
// A result line (before-transfers subtotal or the net bottom line). Amounts are
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
// so folding groups never moves these figures.
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
<tr
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
}`}
>
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t(labelKey)}
</td>
{months.map((month) => (
<td
key={month}
className="text-right px-3 py-3 tabular-nums"
style={{ color: resultColor(series.monthly[month]) }}
>
{cadFormatter(series.monthly[month])}
</td>
))}
<td
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
style={{ color: resultColor(series.total) }}
>
{cadFormatter(series.total)}
</td>
</tr>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{hasGroups && (
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => (allExpanded ? groups.collapseAll(data.tree) : groups.expandAll(data.tree))}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm whitespace-nowrap">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
{t("budget.category")}
</th>
{months.map((month) => (
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
{formatMonth(month)}
</th>
))}
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
{t("common.total")}
</th>
</tr>
</thead>
<tbody>
{nonTransferSections.map(renderSection)}
{/* Operating result (revenues expenses), interleaved before the
transfers section only when transfers exist — otherwise it equals
the net total below and would just be noise. */}
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
{transferSection && renderSection(transferSection)}
{/* Bottom line: result after netting transfers. */}
{renderResultRow("reports.compare.resultNet", net, true)}
</tbody>
</table>
</div>
</div>
);
}