fix: display level 4+ categories under their parent in dashboard (#23) #28

Merged
maximus merged 4 commits from fix/simpl-resultat-23-dashboard-category-ordering into main 2026-03-10 01:26:20 +00:00
4 changed files with 82 additions and 81 deletions
Showing only changes of commit 4a5b5fb5fe - Show all commits

View file

@ -23,51 +23,35 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
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 && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
// Recursively move subtotal (parent) rows below their children at every depth level
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
const result: T[] = [];
let currentParent: T | null = null;
let currentChildren: T[] = [];
for (const row of groupRows) {
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
// Flush previous group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
currentChildren = [];
}
currentParent = row;
} else if (currentParent) {
currentChildren.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
result.push(row);
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
// Flush last group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
return result;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
return reorderGroup(rows, 0);
}
interface BudgetTableProps {

View file

@ -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 } from "../../utils/chartPatterns";
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu";
interface CategoryPieChartProps {
@ -23,6 +23,7 @@ export default function CategoryPieChart({
}: CategoryPieChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [isChartHovered, setIsChartHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
@ -66,7 +67,11 @@ export default function CategoryPieChart({
</div>
)}
<div onContextMenu={handleContextMenu}>
<div
onContextMenu={handleContextMenu}
onMouseEnter={() => setIsChartHovered(true)}
onMouseLeave={() => setIsChartHovered(false)}
>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<ChartPatternDefs
@ -112,6 +117,31 @@ export default function CategoryPieChart({
</ResponsiveContainer>
</div>
<div
className={`flex flex-wrap gap-x-4 gap-y-1 mt-2 transition-opacity duration-200 ${isChartHovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
>
{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}

View file

@ -30,48 +30,35 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
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 && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
// Recursively move subtotal (parent) rows below their children at every depth level
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
const result: T[] = [];
let currentParent: T | null = null;
let currentChildren: T[] = [];
for (const row of groupRows) {
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
// Flush previous group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
currentChildren = [];
}
currentParent = row;
} else if (currentParent) {
currentChildren.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
result.push(row);
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
// Flush last group
if (currentParent) {
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
return result;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
return reorderGroup(rows, 0);
}
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {

View file

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