fix: display level 4+ categories under their parent in dashboard (#23) #28
8 changed files with 77 additions and 117 deletions
|
|
@ -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+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
|
||||
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
|
||||
|
||||
### Modifié
|
||||
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
|
||||
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -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 appear under their parent instead of at the bottom of the section (#23)
|
||||
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
|
||||
|
||||
### Changed
|
||||
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
|
||||
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
|
||||
|
||||
## [0.6.3]
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const MONTH_KEYS = [
|
|||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }>(
|
||||
rows: T[],
|
||||
subtotalsOnTop: boolean,
|
||||
): T[] {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||
|
||||
interface CategoryPieChartProps {
|
||||
|
|
@ -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"
|
||||
|
|
@ -79,8 +79,8 @@ export default function CategoryPieChart({
|
|||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={100}
|
||||
innerRadius={40}
|
||||
outerRadius={85}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{visibleData.map((item, index) => (
|
||||
|
|
@ -94,9 +94,11 @@ export default function CategoryPieChart({
|
|||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
||||
}
|
||||
formatter={(value) => {
|
||||
const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value));
|
||||
const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : "";
|
||||
return `${formatted}${pct}`;
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
|
|
@ -110,29 +112,6 @@ export default function CategoryPieChart({
|
|||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||
{data.map((item, index) => {
|
||||
const isHidden = hiddenCategories.has(item.category_name);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||
}}
|
||||
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
|
||||
title={isHidden ? t("charts.clickToShow") : undefined}
|
||||
>
|
||||
<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>
|
||||
|
||||
{contextMenu && (
|
||||
<ChartContextMenu
|
||||
x={contextMenu.x}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface BudgetVsActualTableProps {
|
|||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }>(
|
||||
rows: T[],
|
||||
subtotalsOnTop: boolean,
|
||||
): T[] {
|
||||
|
|
@ -215,7 +215,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
const isParent = row.is_parent;
|
||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||
const isIntermediateParent = isParent && depth === 1;
|
||||
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
return (
|
||||
<tr
|
||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ export default function DashboardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||||
<div className="lg:col-span-1">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||
<CategoryPieChart
|
||||
data={categoryBreakdown}
|
||||
|
|
@ -137,7 +137,7 @@ export default function DashboardPage() {
|
|||
onViewDetails={viewDetails}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2>
|
||||
<BudgetVsActualTable data={budgetVsActual} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -231,7 +231,6 @@ export async function getBudgetVsActualData(
|
|||
}
|
||||
|
||||
// Index categories
|
||||
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
||||
const childrenByParent = new Map<number, Category[]>();
|
||||
for (const cat of allCategories) {
|
||||
if (cat.parent_id) {
|
||||
|
|
@ -244,7 +243,7 @@ export async function getBudgetVsActualData(
|
|||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||
|
||||
// Compute leaf row values
|
||||
function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
||||
const sign = signFor(cat.type);
|
||||
const monthMap = entryMap.get(cat.id);
|
||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||
|
|
@ -281,7 +280,7 @@ export async function getBudgetVsActualData(
|
|||
};
|
||||
}
|
||||
|
||||
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow {
|
||||
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
|
||||
const row: BudgetVsActualRow = {
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
|
|
@ -323,35 +322,40 @@ export async function getBudgetVsActualData(
|
|||
);
|
||||
}
|
||||
|
||||
// Build rows for a level-2 parent (intermediate parent with grandchildren)
|
||||
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
|
||||
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||
if (grandchildren.length === 0 && cat.is_inputable) {
|
||||
// Leaf at level 2
|
||||
const leaf = buildLeaf(cat, grandparentId, 2);
|
||||
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
||||
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
||||
const subChildren = childrenByParent.get(cat.id) || [];
|
||||
const hasSubChildren = subChildren.some(
|
||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||
);
|
||||
|
||||
if (!hasSubChildren && cat.is_inputable) {
|
||||
const leaf = buildLeaf(cat, groupParentId, depth);
|
||||
return isRowAllZero(leaf) ? [] : [leaf];
|
||||
}
|
||||
if (grandchildren.length === 0) return [];
|
||||
if (!hasSubChildren) return [];
|
||||
|
||||
const gcRows: BudgetVsActualRow[] = [];
|
||||
const childRows: BudgetVsActualRow[] = [];
|
||||
if (cat.is_inputable) {
|
||||
const direct = buildLeaf(cat, cat.id, 2);
|
||||
const direct = buildLeaf(cat, cat.id, depth + 1);
|
||||
direct.category_name = `${cat.name} (direct)`;
|
||||
if (!isRowAllZero(direct)) gcRows.push(direct);
|
||||
if (!isRowAllZero(direct)) childRows.push(direct);
|
||||
}
|
||||
for (const gc of grandchildren) {
|
||||
const leaf = buildLeaf(gc, cat.id, 2);
|
||||
if (!isRowAllZero(leaf)) gcRows.push(leaf);
|
||||
for (const child of subChildren) {
|
||||
const grandchildren = childrenByParent.get(child.id) || [];
|
||||
if (grandchildren.length > 0) {
|
||||
const subRows = buildSubGroup(child, cat.id, depth + 1);
|
||||
childRows.push(...subRows);
|
||||
} else if (child.is_inputable) {
|
||||
const leaf = buildLeaf(child, cat.id, depth + 1);
|
||||
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
||||
}
|
||||
}
|
||||
if (gcRows.length === 0) return [];
|
||||
if (childRows.length === 0) return [];
|
||||
|
||||
const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1);
|
||||
gcRows.sort((a, b) => {
|
||||
if (a.category_id === cat.id) return -1;
|
||||
if (b.category_id === cat.id) return 1;
|
||||
return a.category_name.localeCompare(b.category_name);
|
||||
});
|
||||
return [subtotal, ...gcRows];
|
||||
const leafRows = childRows.filter((r) => !r.is_parent);
|
||||
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
||||
return [subtotal, ...childRows];
|
||||
}
|
||||
|
||||
const rows: BudgetVsActualRow[] = [];
|
||||
|
|
@ -359,15 +363,15 @@ export async function getBudgetVsActualData(
|
|||
|
||||
for (const cat of topLevel) {
|
||||
const children = childrenByParent.get(cat.id) || [];
|
||||
const inputableChildren = children.filter((c) => c.is_inputable);
|
||||
// Also check for non-inputable intermediate parents that have their own children
|
||||
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
|
||||
const hasChildren = children.some(
|
||||
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||
);
|
||||
|
||||
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
|
||||
if (!hasChildren && cat.is_inputable) {
|
||||
// Standalone leaf at level 0
|
||||
const leaf = buildLeaf(cat, null, 0);
|
||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
||||
} else if (hasChildren) {
|
||||
const allChildRows: BudgetVsActualRow[] = [];
|
||||
|
||||
// Direct transactions on the parent itself
|
||||
|
|
@ -377,25 +381,18 @@ export async function getBudgetVsActualData(
|
|||
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||
}
|
||||
|
||||
// Level-2 leaves (direct children that are inputable and have no children)
|
||||
for (const child of inputableChildren) {
|
||||
// Process all children in sort order (preserves tree structure)
|
||||
for (const child of children) {
|
||||
const grandchildren = childrenByParent.get(child.id) || [];
|
||||
if (grandchildren.length === 0) {
|
||||
if (grandchildren.length > 0) {
|
||||
const subRows = buildSubGroup(child, cat.id, 1);
|
||||
allChildRows.push(...subRows);
|
||||
} else if (child.is_inputable) {
|
||||
const leaf = buildLeaf(child, cat.id, 1);
|
||||
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
||||
} else {
|
||||
// This child has its own children — it's an intermediate parent at level 1
|
||||
const subRows = buildLevel2Group(child, cat.id);
|
||||
allChildRows.push(...subRows);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-inputable intermediate parents at level 1
|
||||
for (const ip of intermediateParents) {
|
||||
const subRows = buildLevel2Group(ip, cat.id);
|
||||
allChildRows.push(...subRows);
|
||||
}
|
||||
|
||||
if (allChildRows.length === 0) continue;
|
||||
|
||||
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||
|
|
@ -403,51 +400,19 @@ export async function getBudgetVsActualData(
|
|||
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||
|
||||
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;
|
||||
return a.category_name.localeCompare(b.category_name);
|
||||
});
|
||||
rows.push(...allChildRows);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by type, then within same type keep parent+children groups together
|
||||
// Sort by type only, preserving tree order within groups (already built correctly)
|
||||
const rowOrder = new Map<BudgetVsActualRow, number>();
|
||||
rows.forEach((r, i) => rowOrder.set(r, i));
|
||||
|
||||
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
|
||||
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);
|
||||
if (!pCat || !pCat.parent_id) return pid;
|
||||
pid = pCat.parent_id;
|
||||
}
|
||||
return r.category_id;
|
||||
}
|
||||
const groupA = getGroupId(a);
|
||||
const groupB = getGroupId(b);
|
||||
if (groupA !== groupB) {
|
||||
const catA = catById.get(groupA);
|
||||
const catB = catById.get(groupB);
|
||||
const orderA = catA?.sort_order ?? 999;
|
||||
const orderB = catB?.sort_order ?? 999;
|
||||
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);
|
||||
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);
|
||||
return rowOrder.get(a)! - rowOrder.get(b)!;
|
||||
});
|
||||
|
||||
return rows;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export interface BudgetYearRow {
|
|||
category_type: "expense" | "income" | "transfer";
|
||||
parent_id: number | null;
|
||||
is_parent: boolean;
|
||||
depth?: 0 | 1 | 2;
|
||||
depth?: number;
|
||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||
annual: number; // computed sum
|
||||
previousYearTotal: number; // total budget from the previous year
|
||||
|
|
@ -332,7 +332,7 @@ export interface BudgetVsActualRow {
|
|||
category_type: "expense" | "income" | "transfer";
|
||||
parent_id: number | null;
|
||||
is_parent: boolean;
|
||||
depth?: 0 | 1 | 2;
|
||||
depth?: number;
|
||||
monthActual: number;
|
||||
monthBudget: number;
|
||||
monthVariation: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue