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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 09:46:53 -05:00
parent 446f6effab
commit b353165f61
5 changed files with 121 additions and 10 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.3.0", "version": "0.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.3.0", "version": "0.3.7",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",

View file

@ -1,6 +1,6 @@
import { useState, useRef, useEffect, Fragment } from "react"; import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
@ -16,6 +16,32 @@ const MONTH_KEYS = [
"months.sep", "months.oct", "months.nov", "months.dec", "months.sep", "months.oct", "months.nov", "months.dec",
] as const; ] as const;
const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number }>(
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 { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; 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 [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null); const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
const [editingValue, setEditingValue] = useState(""); 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<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const annualInputRef = useRef<HTMLInputElement>(null); const annualInputRef = useRef<HTMLInputElement>(null);
@ -258,7 +296,17 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
}; };
return ( return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto"> <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
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"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap"> <table className="w-full text-sm whitespace-nowrap">
<thead> <thead>
<tr className="border-b border-[var(--border)]"> <tr className="border-b border-[var(--border)]">
@ -289,7 +337,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{t(typeLabelKeys[type])} {t(typeLabelKeys[type])}
</td> </td>
</tr> </tr>
{group.map((row) => renderRow(row))} {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
</Fragment> </Fragment>
); );
})} })}
@ -305,6 +353,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
); );
} }

View file

@ -1,5 +1,6 @@
import { Fragment } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { BudgetVsActualRow } from "../../shared/types"; import type { BudgetVsActualRow } from "../../shared/types";
const cadFormatter = (value: number) => const cadFormatter = (value: number) =>
@ -22,8 +23,46 @@ interface BudgetVsActualTableProps {
data: BudgetVsActualRow[]; data: BudgetVsActualRow[];
} }
const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number }>(
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) { export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
const { t } = useTranslation(); 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) { if (data.length === 0) {
return ( return (
@ -68,7 +107,17 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null; const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto"> <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)]">
<button
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"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-[var(--border)]"> <tr className="border-b border-[var(--border)]">
@ -117,7 +166,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
{section.label} {section.label}
</td> </td>
</tr> </tr>
{section.rows.map((row) => { {reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent; const isParent = row.is_parent;
const isChild = row.parent_id !== null && !row.is_parent; const isChild = row.parent_id !== null && !row.is_parent;
return ( return (
@ -187,6 +236,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
); );
} }

View file

@ -26,8 +26,12 @@
"6months": "6 months", "6months": "6 months",
"12months": "12 months", "12months": "12 months",
"year": "This year", "year": "This year",
"all": "All" "all": "All",
"custom": "Custom"
}, },
"dateFrom": "From",
"dateTo": "To",
"apply": "Apply",
"help": { "help": {
"title": "How to use the Dashboard", "title": "How to use the Dashboard",
"tips": [ "tips": [
@ -343,6 +347,8 @@
"overTime": "Category Over Time", "overTime": "Category Over Time",
"trends": "Monthly Trends", "trends": "Monthly Trends",
"budgetVsActual": "Budget vs Actual", "budgetVsActual": "Budget vs Actual",
"subtotalsOnTop": "Subtotals on top",
"subtotalsOnBottom": "Subtotals on bottom",
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",
"ytd": "Year-to-Date", "ytd": "Year-to-Date",

View file

@ -26,8 +26,12 @@
"6months": "6 mois", "6months": "6 mois",
"12months": "12 mois", "12months": "12 mois",
"year": "Cette année", "year": "Cette année",
"all": "Tout" "all": "Tout",
"custom": "Personnalisé"
}, },
"dateFrom": "Du",
"dateTo": "Au",
"apply": "Appliquer",
"help": { "help": {
"title": "Comment utiliser le tableau de bord", "title": "Comment utiliser le tableau de bord",
"tips": [ "tips": [
@ -343,6 +347,8 @@
"overTime": "Catégories dans le temps", "overTime": "Catégories dans le temps",
"trends": "Tendances mensuelles", "trends": "Tendances mensuelles",
"budgetVsActual": "Budget vs R\u00e9el", "budgetVsActual": "Budget vs R\u00e9el",
"subtotalsOnTop": "Sous-totaux en haut",
"subtotalsOnBottom": "Sous-totaux en bas",
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",
"ytd": "Cumul annuel", "ytd": "Cumul annuel",