fix: sort level 4 categories under their parent in dashboard and budget tables (#23)

- Fix sub-group sorting so depth-2 children stay grouped with their
  intermediate parent instead of falling to the bottom of the section
- Reduce pie chart height and show labels only on hover
- Adjust grid layout to give more room to the budget vs actual table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
medic-bot 2026-03-08 13:06:01 -04:00
parent 8742c25945
commit 1951bb1228
6 changed files with 77 additions and 23 deletions

View file

@ -5,6 +5,14 @@
### Ajouté
- 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é
- Tableau de bord : les catégories de niveau 4 s'affichent maintenant sous leur parent au lieu d'en bas de la section (#23)
- Tableau de budget : les catégories de niveau 4 s'affichent maintenant sous leur parent au lieu d'en bas de la section (#23)
### Modifié
- Tableau de bord : les étiquettes du graphique circulaire n'apparaissent maintenant qu'au survol pour économiser de l'espace (#23)
- Tableau de bord : disposition du graphique circulaire et du tableau budget vs réel ajustée pour donner plus d'espace au tableau (#23)
## [0.6.3]
### Ajouté

View file

@ -5,6 +5,14 @@
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4 categories now display under their parent instead of at the bottom of the section (#23)
- Budget table: level 4 categories now display under their parent instead of at the bottom of the section (#23)
### Changed
- Dashboard: pie chart labels now only appear on hover to save space (#23)
- Dashboard: pie chart and budget vs actual table layout adjusted to give more room to the table (#23)
## [0.6.3]
### Added

View file

@ -43,7 +43,7 @@ export default function CategoryPieChart({
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)] group">
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
@ -67,7 +67,7 @@ export default function CategoryPieChart({
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={280}>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<ChartPatternDefs
prefix="cat-pie"
@ -110,7 +110,7 @@ export default function CategoryPieChart({
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 max-h-0 overflow-hidden opacity-0 group-hover:max-h-96 group-hover:opacity-100 transition-all duration-300">
{data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name);
return (

View file

@ -317,6 +317,7 @@ export function useBudget() {
}
// Sort by type, then within each type: keep hierarchy groups together
// Sub-group logic: depth-2 children must stay under their intermediate parent
function getTopGroupId(r: BudgetYearRow): number {
if ((r.depth ?? 0) === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
@ -328,6 +329,11 @@ export function useBudget() {
}
return r.category_id;
}
function getSubGroupId(r: BudgetYearRow): number {
if ((r.depth ?? 0) === 2 && r.parent_id !== null) return r.parent_id;
if ((r.depth ?? 0) === 1 && r.is_parent) return r.category_id;
return r.category_id;
}
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
@ -343,9 +349,23 @@ export function useBudget() {
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Same group: sort by depth, then parent before children at same depth
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
// Within same group: depth-0 parent always first
if (a.is_parent && (a.depth ?? 0) === 0) return -1;
if (b.is_parent && (b.depth ?? 0) === 0) return 1;
// "(direct)" of top-level parent always second
if (!a.is_parent && (a.depth ?? 0) === 1 && a.parent_id === a.category_id) return -1;
if (!b.is_parent && (b.depth ?? 0) === 1 && b.parent_id === b.category_id) return 1;
// Sub-group: keep depth-2 children with their intermediate parent
const subA = getSubGroupId(a);
const subB = getSubGroupId(b);
if (subA !== subB) {
const nameA = catById.get(subA)?.name ?? "";
const nameB = catById.get(subB)?.name ?? "";
return nameA.localeCompare(nameB);
}
// Same sub-group: parent before children
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
// "(direct)" within sub-group first
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);

View file

@ -126,7 +126,7 @@ export default function DashboardPage() {
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-4 mb-6">
<div>
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryPieChart

View file

@ -415,15 +415,10 @@ export async function getBudgetVsActualData(
}
// Sort by type, then within same type keep parent+children groups together
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
// Find the top-level group id
// Sub-group logic: depth-2 children must stay under their intermediate parent
function getGroupId(r: BudgetVsActualRow): number {
if (r.depth === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
// Walk up to find the root
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
@ -432,6 +427,15 @@ export async function getBudgetVsActualData(
}
return r.category_id;
}
function getSubGroupId(r: BudgetVsActualRow): number {
if ((r.depth ?? 0) === 2 && r.parent_id !== null) return r.parent_id;
if ((r.depth ?? 0) === 1 && r.is_parent) return r.category_id;
return r.category_id;
}
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
const groupA = getGroupId(a);
const groupB = getGroupId(b);
if (groupA !== groupB) {
@ -442,9 +446,23 @@ export async function getBudgetVsActualData(
if (orderA !== orderB) return orderA - orderB;
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
}
// Within same group: sort by depth, then parent before children
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
// Within same group: depth-0 parent always first
if (a.is_parent && (a.depth ?? 0) === 0) return -1;
if (b.is_parent && (b.depth ?? 0) === 0) return 1;
// "(direct)" of top-level parent always second
if (!a.is_parent && (a.depth ?? 0) === 1 && a.parent_id === a.category_id) return -1;
if (!b.is_parent && (b.depth ?? 0) === 1 && b.parent_id === b.category_id) return 1;
// Sub-group: keep depth-2 children with their intermediate parent
const subA = getSubGroupId(a);
const subB = getSubGroupId(b);
if (subA !== subB) {
const nameA = catById.get(subA)?.name ?? "";
const nameB = catById.get(subB)?.name ?? "";
return nameA.localeCompare(nameB);
}
// Same sub-group: parent before children
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
// "(direct)" within sub-group first
if (a.parent_id && a.category_id === a.parent_id) return -1;
if (b.parent_id && b.category_id === b.parent_id) return 1;
return a.category_name.localeCompare(b.category_name);