feat(reports): collapse/expand sub-categories in comparable reports (#254) #261
9 changed files with 437 additions and 20 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
|
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -26,6 +28,17 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
|
// Collapse groups keyed by category id; depth mirrors the render logic below
|
||||||
|
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
||||||
|
// group's indented descendants.
|
||||||
|
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
||||||
|
keyOf: (row) => String(row.category_id),
|
||||||
|
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
|
||||||
|
isParent: (row) => row.is_parent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
|
@ -41,6 +54,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
|
||||||
|
const groups = useCollapsibleGroups<BudgetVsActualRow>(
|
||||||
|
BVA_EXPANDED_STORAGE_KEY,
|
||||||
|
BVA_COLLAPSE_ACCESSORS,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
|
@ -88,9 +107,22 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
||||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||||
|
|
||||||
|
const hasGroups = groups.groupCount(data) > 0;
|
||||||
|
const allExpanded = groups.allExpanded(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
{hasGroups && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleSubtotals}
|
onClick={toggleSubtotals}
|
||||||
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"
|
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"
|
||||||
|
|
@ -163,11 +195,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
{section.label}
|
{section.label}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||||
const isParent = row.is_parent;
|
const isParent = row.is_parent;
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
const isTopParent = isParent && depth === 0;
|
const isTopParent = isParent && depth === 0;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const collapsed = isTopParent && groups.isCollapsed(row);
|
||||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -184,13 +217,33 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}>
|
}`}>
|
||||||
<span className="flex items-center gap-2">
|
{isTopParent ? (
|
||||||
<span
|
<button
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
type="button"
|
||||||
style={{ backgroundColor: row.category_color }}
|
onClick={() => groups.toggle(row)}
|
||||||
/>
|
aria-expanded={!collapsed}
|
||||||
{row.category_name}
|
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||||
</span>
|
>
|
||||||
|
{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.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||||
{cadFormatter(row.monthActual)}
|
{cadFormatter(row.monthActual)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||||
import type { CategoryDelta } from "../../shared/types";
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
|
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||||
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
|
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
|
||||||
|
|
||||||
export interface ComparePeriodTableProps {
|
export interface ComparePeriodTableProps {
|
||||||
|
|
@ -57,6 +59,16 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
|
||||||
|
|
||||||
const STORAGE_KEY = "compare-subtotals-position";
|
const STORAGE_KEY = "compare-subtotals-position";
|
||||||
|
|
||||||
|
// Collapse groups keyed by category id; depth/parent mirror the render logic
|
||||||
|
// below so the hidden rows are exactly a group's indented descendants.
|
||||||
|
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
||||||
|
keyOf: (row) => String(row.categoryId),
|
||||||
|
depthOf: (row) => row.depth ?? 0,
|
||||||
|
isParent: (row) => row.is_parent ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
|
||||||
|
|
||||||
const COL_COUNT = 9;
|
const COL_COUNT = 9;
|
||||||
|
|
||||||
export default function ComparePeriodTable({
|
export default function ComparePeriodTable({
|
||||||
|
|
@ -81,6 +93,12 @@ export default function ComparePeriodTable({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
|
||||||
|
const groups = useCollapsibleGroups<CategoryDelta>(
|
||||||
|
COMPARE_EXPANDED_STORAGE_KEY,
|
||||||
|
COMPARE_COLLAPSE_ACCESSORS,
|
||||||
|
);
|
||||||
|
|
||||||
const monthPrevLabel = previousLabel;
|
const monthPrevLabel = previousLabel;
|
||||||
const monthCurrLabel = currentLabel;
|
const monthCurrLabel = currentLabel;
|
||||||
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
|
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
|
||||||
|
|
@ -131,11 +149,12 @@ export default function ComparePeriodTable({
|
||||||
{sectionLabels[section.type]}
|
{sectionLabels[section.type]}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||||
const isParent = row.is_parent ?? false;
|
const isParent = row.is_parent ?? false;
|
||||||
const depth = row.depth ?? 0;
|
const depth = row.depth ?? 0;
|
||||||
const isTopParent = isParent && depth === 0;
|
const isTopParent = isParent && depth === 0;
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const collapsed = isTopParent && groups.isCollapsed(row);
|
||||||
const paddingClass =
|
const paddingClass =
|
||||||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
|
|
@ -158,13 +177,33 @@ export default function ComparePeriodTable({
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
{isTopParent ? (
|
||||||
<span
|
<button
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
type="button"
|
||||||
style={{ backgroundColor: row.categoryColor }}
|
onClick={() => groups.toggle(row)}
|
||||||
/>
|
aria-expanded={!collapsed}
|
||||||
{row.categoryName}
|
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||||
</span>
|
>
|
||||||
|
{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>
|
</td>
|
||||||
{/* Monthly block */}
|
{/* Monthly block */}
|
||||||
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
|
@ -301,9 +340,22 @@ export default function ComparePeriodTable({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasGroups = groups.groupCount(rows) > 0;
|
||||||
|
const allExpanded = groups.allExpanded(rows);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
{hasGroups && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(rows))}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleSubtotals}
|
onClick={toggleSubtotals}
|
||||||
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"
|
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"
|
||||||
|
|
|
||||||
94
src/hooks/useCollapsibleGroups.ts
Normal file
94
src/hooks/useCollapsibleGroups.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import {
|
||||||
|
type CollapseAccessors,
|
||||||
|
collapsibleKeys,
|
||||||
|
parseStoredExpanded,
|
||||||
|
serializeExpanded,
|
||||||
|
visibleRows,
|
||||||
|
} from "../utils/collapsibleRows";
|
||||||
|
|
||||||
|
export interface CollapsibleGroups<T> {
|
||||||
|
/** Filters a section's rows down to the ones visible under the current state. */
|
||||||
|
visible: (rows: T[]) => T[];
|
||||||
|
/** True when this top-level parent row is collapsed (its children are hidden). */
|
||||||
|
isCollapsed: (row: T) => boolean;
|
||||||
|
/** Flip one group between collapsed and expanded, then persist. */
|
||||||
|
toggle: (row: T) => void;
|
||||||
|
/** Expand every collapsible group found in `rows`, then persist. */
|
||||||
|
expandAll: (rows: T[]) => void;
|
||||||
|
/** Collapse every group (back to the default), then persist. */
|
||||||
|
collapseAll: () => void;
|
||||||
|
/** True when `rows` has at least one group and all of them are expanded. */
|
||||||
|
allExpanded: (rows: T[]) => boolean;
|
||||||
|
/** Number of collapsible top-level groups in `rows`. */
|
||||||
|
groupCount: (rows: T[]) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-report collapse/expand state for the hierarchical comparable tables
|
||||||
|
* (issue #254). Groups are collapsed by default (issue #260): the persisted
|
||||||
|
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
|
||||||
|
* shows every group collapsed with only its subtotal, while any group the user
|
||||||
|
* expands is remembered.
|
||||||
|
*
|
||||||
|
* Persistence uses localStorage, the same synchronous store the sibling
|
||||||
|
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
||||||
|
* per report via a distinct `storageKey`.
|
||||||
|
*/
|
||||||
|
export function useCollapsibleGroups<T>(
|
||||||
|
storageKey: string,
|
||||||
|
acc: CollapseAccessors<T>,
|
||||||
|
): CollapsibleGroups<T> {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
||||||
|
parseStoredExpanded(
|
||||||
|
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
(next: Set<string>) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, serializeExpanded(next));
|
||||||
|
} catch {
|
||||||
|
// Best-effort: a write failure just means the next session falls back
|
||||||
|
// to the collapsed default.
|
||||||
|
}
|
||||||
|
setExpanded(next);
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
|
||||||
|
|
||||||
|
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(row: T) => {
|
||||||
|
const key = acc.keyOf(row);
|
||||||
|
const next = new Set(expanded);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
persist(next);
|
||||||
|
},
|
||||||
|
[expanded, acc, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandAll = useCallback(
|
||||||
|
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
||||||
|
[acc, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collapseAll = useCallback(() => persist(new Set()), [persist]);
|
||||||
|
|
||||||
|
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
|
||||||
|
|
||||||
|
const allExpanded = useCallback(
|
||||||
|
(rows: T[]) => {
|
||||||
|
const keys = collapsibleKeys(rows, acc);
|
||||||
|
return keys.length > 0 && keys.every((k) => expanded.has(k));
|
||||||
|
},
|
||||||
|
[expanded, acc],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
||||||
|
}
|
||||||
|
|
@ -379,6 +379,10 @@
|
||||||
"budgetVsActual": "Budget vs Actual",
|
"budgetVsActual": "Budget vs Actual",
|
||||||
"subtotalsOnTop": "Subtotals on top",
|
"subtotalsOnTop": "Subtotals on top",
|
||||||
"subtotalsOnBottom": "Subtotals on bottom",
|
"subtotalsOnBottom": "Subtotals on bottom",
|
||||||
|
"collapse": {
|
||||||
|
"expandAll": "Expand all",
|
||||||
|
"collapseAll": "Collapse all"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"showAmounts": "Show amounts",
|
"showAmounts": "Show amounts",
|
||||||
"hideAmounts": "Hide amounts"
|
"hideAmounts": "Hide amounts"
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,10 @@
|
||||||
"budgetVsActual": "Budget vs Réel",
|
"budgetVsActual": "Budget vs Réel",
|
||||||
"subtotalsOnTop": "Sous-totaux en haut",
|
"subtotalsOnTop": "Sous-totaux en haut",
|
||||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||||
|
"collapse": {
|
||||||
|
"expandAll": "Tout déplier",
|
||||||
|
"collapseAll": "Tout replier"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"showAmounts": "Afficher les montants",
|
"showAmounts": "Afficher les montants",
|
||||||
"hideAmounts": "Masquer les montants"
|
"hideAmounts": "Masquer les montants"
|
||||||
|
|
|
||||||
116
src/utils/collapsibleRows.test.ts
Normal file
116
src/utils/collapsibleRows.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
type CollapseAccessors,
|
||||||
|
collapsibleKeys,
|
||||||
|
parseStoredExpanded,
|
||||||
|
serializeExpanded,
|
||||||
|
visibleRows,
|
||||||
|
} from "./collapsibleRows";
|
||||||
|
|
||||||
|
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
|
||||||
|
interface Row {
|
||||||
|
k: string;
|
||||||
|
d: number;
|
||||||
|
p: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acc: CollapseAccessors<Row> = {
|
||||||
|
keyOf: (r) => r.k,
|
||||||
|
depthOf: (r) => r.d,
|
||||||
|
isParent: (r) => r.p,
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
|
||||||
|
|
||||||
|
// A two-group tree with one nested sub-group:
|
||||||
|
// Housing (parent, d0)
|
||||||
|
// Rent (leaf, d1)
|
||||||
|
// Utilities (parent, d1) <- intermediate parent
|
||||||
|
// Power (leaf, d2)
|
||||||
|
// Salary (parent, d0)
|
||||||
|
// Paycheck (leaf, d1)
|
||||||
|
// Misc (leaf, d0) <- top-level leaf, not a group
|
||||||
|
const tree: Row[] = [
|
||||||
|
row("housing", 0, true),
|
||||||
|
row("rent", 1, false),
|
||||||
|
row("utilities", 1, true),
|
||||||
|
row("power", 2, false),
|
||||||
|
row("salary", 0, true),
|
||||||
|
row("paycheck", 1, false),
|
||||||
|
row("misc", 0, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
|
||||||
|
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
|
||||||
|
|
||||||
|
describe("collapsibleRows.visibleRows", () => {
|
||||||
|
it("collapses every group by default (empty expanded set)", () => {
|
||||||
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||||
|
// Only depth-0 rows survive: the two group headers + the top-level leaf.
|
||||||
|
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
|
||||||
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
|
||||||
|
expect(visible.map((r) => r.k)).toEqual([
|
||||||
|
"housing",
|
||||||
|
"rent",
|
||||||
|
"utilities",
|
||||||
|
"power",
|
||||||
|
"salary",
|
||||||
|
"misc",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps groups independent — expanding one leaves the others collapsed", () => {
|
||||||
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
|
||||||
|
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows everything when all groups are expanded", () => {
|
||||||
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
|
||||||
|
expect(visible).toHaveLength(tree.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always keeps a top-level leaf that has no group", () => {
|
||||||
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
||||||
|
expect(visible.some((r) => r.k === "misc")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapsibleRows.collapsibleKeys", () => {
|
||||||
|
it("lists only top-level parent rows, in order", () => {
|
||||||
|
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when there is no hierarchy", () => {
|
||||||
|
const flat = [row("a", 0, false), row("b", 0, false)];
|
||||||
|
expect(collapsibleKeys(flat, acc)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
||||||
|
it("treats absent storage as the collapsed default (empty set)", () => {
|
||||||
|
expect(parseStoredExpanded(null).size).toBe(0);
|
||||||
|
expect(parseStoredExpanded("").size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a JSON string array back into a set", () => {
|
||||||
|
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to empty on corrupt or non-array JSON", () => {
|
||||||
|
expect(parseStoredExpanded("{not json").size).toBe(0);
|
||||||
|
expect(parseStoredExpanded('{"a":1}').size).toBe(0);
|
||||||
|
expect(parseStoredExpanded("42").size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops non-string members defensively", () => {
|
||||||
|
expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through serialize", () => {
|
||||||
|
const set = new Set(["a", "b"]);
|
||||||
|
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/utils/collapsibleRows.ts
Normal file
86
src/utils/collapsibleRows.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Collapse/expand of top-level category groups in the hierarchical comparable
|
||||||
|
* reports (real-vs-real Compare + Budget-vs-Actual) — issue #254.
|
||||||
|
*
|
||||||
|
* Pure helpers only; the React state + persistence glue lives in the
|
||||||
|
* `useCollapsibleGroups` hook.
|
||||||
|
*
|
||||||
|
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
|
||||||
|
* its own subtotal row visible but hides its entire subtree (every following
|
||||||
|
* depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first
|
||||||
|
* order — the order the compare/budget services already emit them in, and the
|
||||||
|
* same order `reorderRows` relies on.
|
||||||
|
*
|
||||||
|
* The accessors are passed in rather than read off fixed field names so each
|
||||||
|
* table can supply the *exact* depth expression it renders with (CategoryDelta
|
||||||
|
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
|
||||||
|
* `parent_id`). Keeping the collapse depth and the indentation depth identical
|
||||||
|
* guarantees the hidden rows are exactly the indented descendants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How the collapse logic reads hierarchy position + identity off a row. */
|
||||||
|
export interface CollapseAccessors<T> {
|
||||||
|
/** Stable per-group key (a category id, stringified). */
|
||||||
|
keyOf: (row: T) => string;
|
||||||
|
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
|
||||||
|
depthOf: (row: T) => number;
|
||||||
|
/** True when the row is a group header / subtotal (has children). */
|
||||||
|
isParent: (row: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps every row that is currently visible given which top-level groups are
|
||||||
|
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
|
||||||
|
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
|
||||||
|
*/
|
||||||
|
export function visibleRows<T>(
|
||||||
|
rows: T[],
|
||||||
|
acc: CollapseAccessors<T>,
|
||||||
|
isCollapsed: (row: T) => boolean,
|
||||||
|
): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
// While true we are inside a collapsed top-level parent's subtree and drop
|
||||||
|
// every deeper row until the next depth-0 row re-opens the flow.
|
||||||
|
let hidingSubtree = false;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (acc.depthOf(row) === 0) {
|
||||||
|
out.push(row);
|
||||||
|
hidingSubtree = acc.isParent(row) && isCollapsed(row);
|
||||||
|
} else if (!hidingSubtree) {
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys of every collapsible group (top-level parent rows), in encounter order. */
|
||||||
|
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the persisted value into the set of *expanded* group keys. The report
|
||||||
|
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
|
||||||
|
* value yields an empty set — i.e. all groups collapsed on a first-ever visit.
|
||||||
|
*/
|
||||||
|
export function parseStoredExpanded(raw: string | null): Set<string> {
|
||||||
|
if (!raw) return new Set();
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return new Set(parsed.filter((k): k is string => typeof k === "string"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt value: fall back to the collapsed default.
|
||||||
|
}
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialises the set of expanded group keys for persistence. */
|
||||||
|
export function serializeExpanded(expanded: Set<string>): string {
|
||||||
|
return JSON.stringify([...expanded]);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue