Compare commits

..

1 commit

Author SHA1 Message Date
f5dfbb5ad4 fix: order level 4 categories under parent in budget vs actual table
Rework sorting in budgetService to keep level-2 groups (subtotal +
children) together under their intermediate parent instead of
flat-sorting alphabetically which scattered depth-2 rows to the bottom.

Also reduce pie chart size on dashboard (height 280->220, radius 100->85),
change grid layout to 2/5 pie + 3/5 table, and make pie chart legend
collapsible (collapsed by default) to give more space to the BVA table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:03:29 -04:00
7 changed files with 98 additions and 77 deletions

View file

@ -6,11 +6,11 @@
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16) - Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
### Corrigé ### Corrigé
- Tableau de bord : les catégories de niveau 4 apparaissent maintenant directement sous leur parent dans le tableau budget vs réel au lieu d'en bas de la section (#23) - Tableau de bord : les catégories de niveau 4 apparaissent maintenant sous leur parent dans le tableau réel vs budget (#23)
### Modifié ### Modifié
- Tableau de bord : graphique circulaire réduit en taille et étiquettes de la légende affichées seulement au survol pour donner plus d'espace au tableau budget vs réel (#23) - Tableau de bord : taille du graphique circulaire réduite et plus d'espace pour le tableau réel vs budget (#23)
- Tableau de bord : disposition du graphique circulaire et du tableau budget passée d'un ratio 1:1 à 1:2 pour une meilleure lisibilité du tableau (#23) - Tableau de bord : la légende du graphique circulaire est maintenant repliable (repliée par défaut) pour économiser de l'espace (#23)
## [0.6.3] ## [0.6.3]

View file

@ -6,11 +6,11 @@
- Budget table: previous year total column displayed as first data column for baseline reference (#16) - Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed ### Fixed
- Dashboard: level-4 categories now appear directly under their parent in the budget vs actual table instead of at the bottom of the section (#23) - Dashboard: level 4 categories now appear under their parent in the budget vs actual table (#23)
### Changed ### Changed
- Dashboard: pie chart reduced in size and legend labels only shown on hover to give more space to the budget vs actual table (#23) - Dashboard: reduced pie chart size and gave more space to the budget vs actual table (#23)
- Dashboard: pie chart and budget table layout changed from equal 1:1 to 1:2 ratio for better table readability (#23) - Dashboard: pie chart legend is now collapsible (collapsed by default) to save space (#23)
## [0.6.3] ## [0.6.3]

View file

@ -1,7 +1,7 @@
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { Eye } from "lucide-react"; import { Eye, ChevronDown, ChevronUp } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types"; import type { CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu"; import ChartContextMenu from "../shared/ChartContextMenu";
@ -24,7 +24,7 @@ export default function CategoryPieChart({
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null); const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const [showLegend, setShowLegend] = useState(false); const [legendExpanded, setLegendExpanded] = useState(false);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
const total = visibleData.reduce((sum, d) => sum + d.total, 0); const total = visibleData.reduce((sum, d) => sum + d.total, 0);
@ -67,12 +67,8 @@ export default function CategoryPieChart({
</div> </div>
)} )}
<div <div onContextMenu={handleContextMenu}>
onContextMenu={handleContextMenu} <ResponsiveContainer width="100%" height={220}>
onMouseEnter={() => setShowLegend(true)}
onMouseLeave={() => setShowLegend(false)}
>
<ResponsiveContainer width="100%" height={200}>
<PieChart> <PieChart>
<ChartPatternDefs <ChartPatternDefs
prefix="cat-pie" prefix="cat-pie"
@ -84,8 +80,8 @@ export default function CategoryPieChart({
nameKey="category_name" nameKey="category_name"
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={35} innerRadius={40}
outerRadius={75} outerRadius={85}
paddingAngle={2} paddingAngle={2}
> >
{visibleData.map((item, index) => ( {visibleData.map((item, index) => (
@ -115,31 +111,38 @@ export default function CategoryPieChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div <div className="mt-2">
className={`flex flex-wrap gap-x-4 gap-y-1 mt-2 transition-all duration-200 overflow-hidden ${ <button
showLegend ? "max-h-96 opacity-100" : "max-h-0 opacity-0" onClick={() => setLegendExpanded((prev) => !prev)}
}`} className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-1"
> >
{data.map((item, index) => { {legendExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
const isHidden = hiddenCategories.has(item.category_name); {t("charts.legend")}
return ( </button>
<button {legendExpanded && (
key={index} <div className="flex flex-wrap gap-x-4 gap-y-1">
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`} {data.map((item, index) => {
onContextMenu={(e) => { const isHidden = hiddenCategories.has(item.category_name);
e.preventDefault(); return (
setContextMenu({ x: e.clientX, y: e.clientY, item }); <button
}} key={index}
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined} className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
title={isHidden ? t("charts.clickToShow") : undefined} onContextMenu={(e) => {
> e.preventDefault();
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" /> setContextMenu({ x: e.clientX, y: e.clientY, item });
<span className="text-[var(--muted-foreground)]"> }}
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""} onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
</span> title={isHidden ? t("charts.clickToShow") : undefined}
</button> >
); <PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
})} <span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</button>
);
})}
</div>
)}
</div> </div>
{contextMenu && ( {contextMenu && (

View file

@ -508,7 +508,8 @@
"showAll": "Show all", "showAll": "Show all",
"total": "Total", "total": "Total",
"transactions": "transactions", "transactions": "transactions",
"clickToShow": "Click to show" "clickToShow": "Click to show",
"legend": "Legend"
}, },
"months": { "months": {
"jan": "Jan", "jan": "Jan",

View file

@ -508,7 +508,8 @@
"showAll": "Tout afficher", "showAll": "Tout afficher",
"total": "Total", "total": "Total",
"transactions": "transactions", "transactions": "transactions",
"clickToShow": "Cliquer pour afficher" "clickToShow": "Cliquer pour afficher",
"legend": "Légende"
}, },
"months": { "months": {
"jan": "Jan", "jan": "Jan",

View file

@ -126,8 +126,8 @@ export default function DashboardPage() {
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-6">
<div> <div className="lg:col-span-2">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2> <h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryPieChart <CategoryPieChart
data={categoryBreakdown} data={categoryBreakdown}
@ -137,7 +137,7 @@ export default function DashboardPage() {
onViewDetails={viewDetails} onViewDetails={viewDetails}
/> />
</div> </div>
<div> <div className="lg:col-span-3">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2> <h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2>
<BudgetVsActualTable data={budgetVsActual} /> <BudgetVsActualTable data={budgetVsActual} />
</div> </div>

View file

@ -404,50 +404,66 @@ export async function getBudgetVsActualData(
rows.push(parent); rows.push(parent);
// Sort preserving parent-child grouping: // Sort: "(direct)" first, then keep level-2 groups (subtotal + children) together,
// 1. "(direct)" first // sorted alphabetically by the subtotal name, with leaves sorted alphabetically too.
// 2. Sub-groups (depth-1 parent + its depth-2 children) stay together, sorted by parent name // Separate depth-1 leaves from depth-1 subtotal groups
// 3. Standalone depth-1 leaves sorted alphabetically
const directRow = allChildRows.find((r) => r.category_id === cat.id && !r.is_parent); const directRow = allChildRows.find((r) => r.category_id === cat.id && !r.is_parent);
const subGroups: { parent: BudgetVsActualRow; children: BudgetVsActualRow[] }[] = []; const level2Groups: { subtotal: BudgetVsActualRow; children: BudgetVsActualRow[] }[] = [];
const standaloneLeaves: BudgetVsActualRow[] = []; const level1Leaves: BudgetVsActualRow[] = [];
for (const row of allChildRows) { for (const r of allChildRows) {
if (row.category_id === cat.id && !row.is_parent) continue; // skip direct row if (r.category_id === cat.id && !r.is_parent) continue; // skip "(direct)" — handled separately
if (row.is_parent && row.depth === 1) { if (r.is_parent && (r.depth ?? 0) === 1) {
subGroups.push({ parent: row, children: [] }); // This is an intermediate parent subtotal — start a new group
} else if (row.depth === 2 && subGroups.length > 0) { level2Groups.push({ subtotal: r, children: [] });
// Find the matching sub-group for this child } else if ((r.depth ?? 0) === 2) {
const matchingGroup = subGroups.find((g) => g.parent.category_id === row.parent_id); // Find which group this belongs to (by parent_id)
if (matchingGroup) { const group = level2Groups.find((g) => g.subtotal.category_id === r.parent_id);
matchingGroup.children.push(row); if (group) {
group.children.push(r);
} else { } else {
standaloneLeaves.push(row); level1Leaves.push(r);
} }
} else { } else {
standaloneLeaves.push(row); level1Leaves.push(r);
} }
} }
// Sort sub-groups by parent name, children within each group alphabetically // Sort level-1 leaves alphabetically
subGroups.sort((a, b) => a.parent.category_name.localeCompare(b.parent.category_name)); level1Leaves.sort((a, b) => a.category_name.localeCompare(b.category_name));
for (const group of subGroups) { // Sort level-2 groups by subtotal name
group.children.sort((a, b) => { level2Groups.sort((a, b) => a.subtotal.category_name.localeCompare(b.subtotal.category_name));
// "(direct)" entries first within group // Sort children within each group
if (a.category_id === group.parent.category_id) return -1; for (const g of level2Groups) {
if (b.category_id === group.parent.category_id) return 1; g.children.sort((a, b) => {
// "(direct)" row first
if (a.category_id === g.subtotal.category_id) return -1;
if (b.category_id === g.subtotal.category_id) return 1;
return a.category_name.localeCompare(b.category_name); return a.category_name.localeCompare(b.category_name);
}); });
} }
standaloneLeaves.sort((a, b) => a.category_name.localeCompare(b.category_name));
const sortedChildren: BudgetVsActualRow[] = []; // Reassemble: (direct) first, then interleave level-1 leaves and level-2 groups alphabetically
if (directRow) sortedChildren.push(directRow); const sorted: BudgetVsActualRow[] = [];
for (const group of subGroups) { if (directRow) sorted.push(directRow);
sortedChildren.push(group.parent, ...group.children);
// Merge level1Leaves and level2Groups by name
let li = 0;
let gi = 0;
while (li < level1Leaves.length || gi < level2Groups.length) {
const leafName = li < level1Leaves.length ? level1Leaves[li].category_name : null;
const groupName = gi < level2Groups.length ? level2Groups[gi].subtotal.category_name : null;
if (leafName !== null && (groupName === null || leafName.localeCompare(groupName) <= 0)) {
sorted.push(level1Leaves[li]);
li++;
} else {
const g = level2Groups[gi];
sorted.push(g.subtotal, ...g.children);
gi++;
}
} }
sortedChildren.push(...standaloneLeaves);
rows.push(...sortedChildren); rows.push(...sorted);
} }
} }