fix: order level-4 categories under parent and reduce pie chart space (#23) #26
7 changed files with 146 additions and 54 deletions
|
|
@ -5,6 +5,13 @@
|
|||
### 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 apparaissent maintenant sous leur parent au lieu d'en bas de section (#23)
|
||||
|
||||
### Modifié
|
||||
- Tableau de bord : le graphique circulaire prend moins d'espace (1/3 au lieu de 1/2) pour donner plus de place au tableau budget vs réel (#23)
|
||||
- Tableau de bord : la légende du graphique circulaire est maintenant repliée par défaut et dépliable au clic (#23)
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
### Added
|
||||
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
|
||||
|
||||
### Fixed
|
||||
- Dashboard: level-4 categories now appear under their parent instead of at the bottom of the section (#23)
|
||||
|
||||
### Changed
|
||||
- Dashboard: pie chart takes less space (1/3 instead of 1/2) to give more room to the budget vs actual table (#23)
|
||||
- Dashboard: pie chart legend is now collapsed by default and expandable on click (#23)
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||
|
|
@ -24,6 +24,7 @@ export default function CategoryPieChart({
|
|||
const { t } = useTranslation();
|
||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
|
||||
const [legendExpanded, setLegendExpanded] = useState(false);
|
||||
|
||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
|
||||
|
|
@ -67,7 +68,7 @@ export default function CategoryPieChart({
|
|||
)}
|
||||
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<ChartPatternDefs
|
||||
prefix="cat-pie"
|
||||
|
|
@ -79,8 +80,8 @@ export default function CategoryPieChart({
|
|||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={100}
|
||||
innerRadius={35}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{visibleData.map((item, index) => (
|
||||
|
|
@ -110,7 +111,16 @@ export default function CategoryPieChart({
|
|||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setLegendExpanded((prev) => !prev)}
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-1"
|
||||
>
|
||||
{legendExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
{t("charts.legend")}
|
||||
</button>
|
||||
{legendExpanded && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{data.map((item, index) => {
|
||||
const isHidden = hiddenCategories.has(item.category_name);
|
||||
return (
|
||||
|
|
@ -132,6 +142,8 @@ export default function CategoryPieChart({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ChartContextMenu
|
||||
|
|
|
|||
|
|
@ -508,7 +508,8 @@
|
|||
"showAll": "Show all",
|
||||
"total": "Total",
|
||||
"transactions": "transactions",
|
||||
"clickToShow": "Click to show"
|
||||
"clickToShow": "Click to show",
|
||||
"legend": "Legend"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
|
|
|
|||
|
|
@ -508,7 +508,8 @@
|
|||
"showAll": "Tout afficher",
|
||||
"total": "Total",
|
||||
"transactions": "transactions",
|
||||
"clickToShow": "Cliquer pour afficher"
|
||||
"clickToShow": "Cliquer pour afficher",
|
||||
"legend": "Légende"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
|
|
|
|||
|
|
@ -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-[minmax(0,1fr)_minmax(0,2fr)] gap-4 mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||
<CategoryPieChart
|
||||
|
|
|
|||
|
|
@ -404,22 +404,43 @@ export async function getBudgetVsActualData(
|
|||
|
||||
rows.push(parent);
|
||||
|
||||
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
|
||||
allChildRows.sort((a, b) => {
|
||||
if (a.category_id === cat.id && !a.is_parent) return -1;
|
||||
if (b.category_id === cat.id && !b.is_parent) return 1;
|
||||
// Sort: "(direct)" first, then group intermediate parents with their children
|
||||
// Step 1: separate depth-1 items and depth-2 items
|
||||
const directRow = allChildRows.find((r) => r.category_id === cat.id && !r.is_parent);
|
||||
const depth1Parents = allChildRows.filter((r) => r.is_parent && (r.depth ?? 0) === 1);
|
||||
const depth1Leaves = allChildRows.filter((r) => !r.is_parent && (r.depth ?? 0) === 1 && r.category_id !== cat.id);
|
||||
const depth2Items = allChildRows.filter((r) => (r.depth ?? 0) === 2);
|
||||
|
||||
// Step 2: build ordered list preserving parent-child grouping
|
||||
const orderedChildren: BudgetVsActualRow[] = [];
|
||||
if (directRow) orderedChildren.push(directRow);
|
||||
|
||||
// Collect all depth-1 items (both leaves and intermediate parents), sort alphabetically
|
||||
const depth1All: BudgetVsActualRow[] = [...depth1Leaves, ...depth1Parents];
|
||||
depth1All.sort((a, b) => a.category_name.localeCompare(b.category_name));
|
||||
|
||||
for (const d1 of depth1All) {
|
||||
orderedChildren.push(d1);
|
||||
if (d1.is_parent) {
|
||||
// Append depth-2 children of this intermediate parent, sorted alphabetically
|
||||
const children2 = depth2Items
|
||||
.filter((r) => r.parent_id === d1.category_id)
|
||||
.sort((a, b) => {
|
||||
// "(direct)" first
|
||||
if (a.category_id === d1.category_id) return -1;
|
||||
if (b.category_id === d1.category_id) return 1;
|
||||
return a.category_name.localeCompare(b.category_name);
|
||||
});
|
||||
rows.push(...allChildRows);
|
||||
orderedChildren.push(...children2);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(...orderedChildren);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// We use a stable approach: assign an index to each row based on its group and hierarchy
|
||||
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;
|
||||
|
|
@ -432,6 +453,19 @@ export async function getBudgetVsActualData(
|
|||
}
|
||||
return r.category_id;
|
||||
}
|
||||
|
||||
// Get the intermediate parent id for depth-2 items
|
||||
function getIntermediateParentId(r: BudgetVsActualRow): number | null {
|
||||
if ((r.depth ?? 0) !== 2) return null;
|
||||
// parent_id is the intermediate parent
|
||||
return r.parent_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,12 +476,42 @@ 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 top-level group:
|
||||
// depth-0 parent comes first
|
||||
if ((a.depth ?? 0) === 0 && (b.depth ?? 0) !== 0) return -1;
|
||||
if ((a.depth ?? 0) !== 0 && (b.depth ?? 0) === 0) return 1;
|
||||
|
||||
// Both are depth 1 or 2 — group by intermediate parent
|
||||
const ipA = a.is_parent && (a.depth ?? 0) === 1 ? a.category_id : getIntermediateParentId(a);
|
||||
const ipB = b.is_parent && (b.depth ?? 0) === 1 ? b.category_id : getIntermediateParentId(b);
|
||||
|
||||
// If both have same intermediate parent (or both null)
|
||||
if (ipA === ipB) {
|
||||
// parent row before children
|
||||
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
|
||||
// "(direct)" rows 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);
|
||||
}
|
||||
|
||||
// One is a depth-1 leaf, the other has an intermediate parent (or different parents)
|
||||
// Sort by the intermediate parent's name (depth-1 leaves have null ipA, sort them first among non-parent items)
|
||||
if (ipA === null && ipB !== null) {
|
||||
// a is a depth-1 leaf, b belongs to an intermediate parent group
|
||||
const ipBCat = catById.get(ipB);
|
||||
return a.category_name.localeCompare(ipBCat?.name ?? b.category_name);
|
||||
}
|
||||
if (ipA !== null && ipB === null) {
|
||||
const ipACat = catById.get(ipA);
|
||||
return (ipACat?.name ?? a.category_name).localeCompare(b.category_name);
|
||||
}
|
||||
|
||||
// Both have different intermediate parents — sort by intermediate parent name
|
||||
const ipACat = catById.get(ipA!);
|
||||
const ipBCat = catById.get(ipB!);
|
||||
return (ipACat?.name ?? "").localeCompare(ipBCat?.name ?? "");
|
||||
});
|
||||
|
||||
return rows;
|
||||
|
|
|
|||
Loading…
Reference in a new issue