From b353165f615b4381a9029ed563e81c38fbc425fa Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 21 Feb 2026 09:46:53 -0500 Subject: [PATCH] feat: add toggle to position subtotals above or below detail rows Add a toggle button to BudgetVsActualTable and BudgetTable that lets users choose whether parent subtotal rows appear before or after their children. The preference is persisted in localStorage and shared across both tables. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 4 +- src/components/budget/BudgetTable.tsx | 55 +++++++++++++++++- .../reports/BudgetVsActualTable.tsx | 56 ++++++++++++++++++- src/i18n/locales/en.json | 8 ++- src/i18n/locales/fr.json | 8 ++- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index a0bc8d5..ea6b02d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simpl_result_scaffold", - "version": "0.3.0", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simpl_result_scaffold", - "version": "0.3.0", + "version": "0.3.7", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 7fe3981..4e6386b 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle } from "lucide-react"; +import { AlertTriangle, ArrowUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; const fmt = new Intl.NumberFormat("en-CA", { @@ -16,6 +16,32 @@ const MONTH_KEYS = [ "months.sep", "months.oct", "months.nov", "months.dec", ] as const; +const STORAGE_KEY = "subtotals-position"; + +function reorderRows( + rows: T[], + subtotalsOnTop: boolean, +): T[] { + if (subtotalsOnTop) return rows; + const groups: { parent: T | null; children: T[] }[] = []; + let current: { parent: T | null; children: T[] } | null = null; + for (const row of rows) { + if (row.is_parent) { + if (current) groups.push(current); + current = { parent: row, children: [] }; + } else if (current && row.parent_id === current.parent?.category_id) { + current.children.push(row); + } else { + if (current) groups.push(current); + current = { parent: null, children: [row] }; + } + } + if (current) groups.push(current); + return groups.flatMap(({ parent, children }) => + parent ? [...children, parent] : children, + ); +} + interface BudgetTableProps { rows: BudgetYearRow[]; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; @@ -27,6 +53,18 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null); const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null); const [editingValue, setEditingValue] = useState(""); + const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === null ? true : stored === "top"; + }); + + const toggleSubtotals = () => { + setSubtotalsOnTop((prev) => { + const next = !prev; + localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom"); + return next; + }); + }; const inputRef = useRef(null); const annualInputRef = useRef(null); @@ -258,7 +296,17 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu }; return ( -
+
+
+ +
+
@@ -289,7 +337,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t(typeLabelKeys[type])} - {group.map((row) => renderRow(row))} + {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} ); })} @@ -305,6 +353,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
+
); } diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index 18b4a68..3fdf4ac 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -1,5 +1,6 @@ -import { Fragment } from "react"; +import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ArrowUpDown } from "lucide-react"; import type { BudgetVsActualRow } from "../../shared/types"; const cadFormatter = (value: number) => @@ -22,8 +23,46 @@ interface BudgetVsActualTableProps { data: BudgetVsActualRow[]; } +const STORAGE_KEY = "subtotals-position"; + +function reorderRows( + rows: T[], + subtotalsOnTop: boolean, +): T[] { + if (subtotalsOnTop) return rows; + const groups: { parent: T | null; children: T[] }[] = []; + let current: { parent: T | null; children: T[] } | null = null; + for (const row of rows) { + if (row.is_parent) { + if (current) groups.push(current); + current = { parent: row, children: [] }; + } else if (current && row.parent_id === current.parent?.category_id) { + current.children.push(row); + } else { + if (current) groups.push(current); + current = { parent: null, children: [row] }; + } + } + if (current) groups.push(current); + return groups.flatMap(({ parent, children }) => + parent ? [...children, parent] : children, + ); +} + export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { const { t } = useTranslation(); + const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === null ? true : stored === "top"; + }); + + const toggleSubtotals = () => { + setSubtotalsOnTop((prev) => { + const next = !prev; + localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom"); + return next; + }); + }; if (data.length === 0) { return ( @@ -68,7 +107,17 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; return ( -
+
+
+ +
+
@@ -117,7 +166,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {section.label} - {section.rows.map((row) => { + {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent; const isChild = row.parent_id !== null && !row.is_parent; return ( @@ -187,6 +236,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
+
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index da4cf38..e768145 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -26,8 +26,12 @@ "6months": "6 months", "12months": "12 months", "year": "This year", - "all": "All" + "all": "All", + "custom": "Custom" }, + "dateFrom": "From", + "dateTo": "To", + "apply": "Apply", "help": { "title": "How to use the Dashboard", "tips": [ @@ -343,6 +347,8 @@ "overTime": "Category Over Time", "trends": "Monthly Trends", "budgetVsActual": "Budget vs Actual", + "subtotalsOnTop": "Subtotals on top", + "subtotalsOnBottom": "Subtotals on bottom", "bva": { "monthly": "Monthly", "ytd": "Year-to-Date", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0e54f33..030bc3e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -26,8 +26,12 @@ "6months": "6 mois", "12months": "12 mois", "year": "Cette année", - "all": "Tout" + "all": "Tout", + "custom": "Personnalisé" }, + "dateFrom": "Du", + "dateTo": "Au", + "apply": "Appliquer", "help": { "title": "Comment utiliser le tableau de bord", "tips": [ @@ -343,6 +347,8 @@ "overTime": "Catégories dans le temps", "trends": "Tendances mensuelles", "budgetVsActual": "Budget vs R\u00e9el", + "subtotalsOnTop": "Sous-totaux en haut", + "subtotalsOnBottom": "Sous-totaux en bas", "bva": { "monthly": "Mensuel", "ytd": "Cumul annuel",