Compare commits
No commits in common. "0a55a47ab504245eefee94b83c1d18bb6593bad0" and "ababa3c010f85d1c8df29e1a9fd6f465732c9196" have entirely different histories.
0a55a47ab5
...
ababa3c010
9 changed files with 20 additions and 437 deletions
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
## [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,10 +2,6 @@
|
||||||
|
|
||||||
## [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,10 +1,8 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import { ArrowUpDown } 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", {
|
||||||
|
|
@ -28,17 +26,6 @@ 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(() => {
|
||||||
|
|
@ -54,12 +41,6 @@ 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)]">
|
||||||
|
|
@ -107,22 +88,9 @@ 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 items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end 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"
|
||||||
|
|
@ -195,12 +163,11 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
{section.label}
|
{section.label}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
{reorderRows(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
|
||||||
|
|
@ -217,25 +184,6 @@ 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)]`
|
||||||
}`}>
|
}`}>
|
||||||
{isTopParent ? (
|
|
||||||
<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.category_color }}
|
|
||||||
/>
|
|
||||||
{row.category_name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -243,7 +191,6 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
/>
|
/>
|
||||||
{row.category_name}
|
{row.category_name}
|
||||||
</span>
|
</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,10 +1,8 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import { ArrowUpDown } 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 {
|
||||||
|
|
@ -59,16 +57,6 @@ 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({
|
||||||
|
|
@ -93,12 +81,6 @@ 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;
|
||||||
|
|
@ -149,12 +131,11 @@ export default function ComparePeriodTable({
|
||||||
{sectionLabels[section.type]}
|
{sectionLabels[section.type]}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
{reorderRows(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 (
|
||||||
|
|
@ -177,25 +158,6 @@ export default function ComparePeriodTable({
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isTopParent ? (
|
|
||||||
<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="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -203,7 +165,6 @@ export default function ComparePeriodTable({
|
||||||
/>
|
/>
|
||||||
{row.categoryName}
|
{row.categoryName}
|
||||||
</span>
|
</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">
|
||||||
|
|
@ -340,22 +301,9 @@ 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 items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
<div className="flex justify-end 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"
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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,10 +379,6 @@
|
||||||
"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,10 +379,6 @@
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/**
|
|
||||||
* 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