fix: address reviewer feedback (#23)
- Make reorderRows() recursive to support subtotals toggle at all depth levels (not just depth 0-1) - Restore pie chart legend (show on hover only to save space) - Give more horizontal space to budget table (3/4 grid vs 2/3)
This commit is contained in:
parent
dbe249783e
commit
4a5b5fb5fe
4 changed files with 82 additions and 81 deletions
|
|
@ -23,51 +23,35 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
|
||||||
subtotalsOnTop: boolean,
|
subtotalsOnTop: boolean,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (subtotalsOnTop) return rows;
|
if (subtotalsOnTop) return rows;
|
||||||
// Group depth-0 parents with all their descendants, then move subtotals to bottom
|
|
||||||
const groups: { parent: T | null; children: T[] }[] = [];
|
// Recursively move subtotal (parent) rows below their children at every depth level
|
||||||
let current: { parent: T | null; children: T[] } | null = null;
|
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
|
||||||
for (const row of rows) {
|
const result: T[] = [];
|
||||||
if (row.is_parent && (row.depth ?? 0) === 0) {
|
let currentParent: T | null = null;
|
||||||
if (current) groups.push(current);
|
let currentChildren: T[] = [];
|
||||||
current = { parent: row, children: [] };
|
|
||||||
} else if (current) {
|
for (const row of groupRows) {
|
||||||
current.children.push(row);
|
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
|
||||||
} else {
|
// Flush previous group
|
||||||
if (current) groups.push(current);
|
if (currentParent) {
|
||||||
current = { parent: null, children: [row] };
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
}
|
currentChildren = [];
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
subParent = child;
|
currentParent = row;
|
||||||
} else if (subParent && child.parent_id === subParent.category_id) {
|
} else if (currentParent) {
|
||||||
subChildren.push(child);
|
currentChildren.push(row);
|
||||||
} else {
|
} else {
|
||||||
if (subParent) {
|
result.push(row);
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
|
||||||
subParent = null;
|
|
||||||
subChildren.length = 0;
|
|
||||||
}
|
|
||||||
reorderedChildren.push(child);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subParent) {
|
// Flush last group
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
if (currentParent) {
|
||||||
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
}
|
}
|
||||||
return [...reorderedChildren, parent];
|
return result;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return reorderGroup(rows, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BudgetTableProps {
|
interface BudgetTableProps {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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 } from "lucide-react";
|
||||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||||
|
|
||||||
interface CategoryPieChartProps {
|
interface CategoryPieChartProps {
|
||||||
|
|
@ -23,6 +23,7 @@ export default function CategoryPieChart({
|
||||||
}: CategoryPieChartProps) {
|
}: CategoryPieChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||||
|
const [isChartHovered, setIsChartHovered] = useState(false);
|
||||||
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 visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||||
|
|
@ -66,7 +67,11 @@ export default function CategoryPieChart({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
<div
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={() => setIsChartHovered(true)}
|
||||||
|
onMouseLeave={() => setIsChartHovered(false)}
|
||||||
|
>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartPatternDefs
|
<ChartPatternDefs
|
||||||
|
|
@ -112,6 +117,31 @@ export default function CategoryPieChart({
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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 && (
|
{contextMenu && (
|
||||||
<ChartContextMenu
|
<ChartContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
|
|
|
||||||
|
|
@ -30,48 +30,35 @@ function reorderRows<T extends { is_parent: boolean; parent_id: number | null; c
|
||||||
subtotalsOnTop: boolean,
|
subtotalsOnTop: boolean,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (subtotalsOnTop) return rows;
|
if (subtotalsOnTop) return rows;
|
||||||
const groups: { parent: T | null; children: T[] }[] = [];
|
|
||||||
let current: { parent: T | null; children: T[] } | null = null;
|
// Recursively move subtotal (parent) rows below their children at every depth level
|
||||||
for (const row of rows) {
|
function reorderGroup(groupRows: T[], parentDepth: number): T[] {
|
||||||
if (row.is_parent && (row.depth ?? 0) === 0) {
|
const result: T[] = [];
|
||||||
if (current) groups.push(current);
|
let currentParent: T | null = null;
|
||||||
current = { parent: row, children: [] };
|
let currentChildren: T[] = [];
|
||||||
} else if (current) {
|
|
||||||
current.children.push(row);
|
for (const row of groupRows) {
|
||||||
} else {
|
if (row.is_parent && (row.depth ?? 0) === parentDepth) {
|
||||||
if (current) groups.push(current);
|
// Flush previous group
|
||||||
current = { parent: null, children: [row] };
|
if (currentParent) {
|
||||||
}
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
}
|
currentChildren = [];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
subParent = child;
|
currentParent = row;
|
||||||
} else if (subParent && child.parent_id === subParent.category_id) {
|
} else if (currentParent) {
|
||||||
subChildren.push(child);
|
currentChildren.push(row);
|
||||||
} else {
|
} else {
|
||||||
if (subParent) {
|
result.push(row);
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
|
||||||
subParent = null;
|
|
||||||
subChildren.length = 0;
|
|
||||||
}
|
|
||||||
reorderedChildren.push(child);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subParent) {
|
// Flush last group
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
if (currentParent) {
|
||||||
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
}
|
}
|
||||||
return [...reorderedChildren, parent];
|
return result;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return reorderGroup(rows, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="lg:col-span-1">
|
||||||
<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
|
||||||
|
|
@ -137,7 +137,7 @@ export default function DashboardPage() {
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2>
|
||||||
<BudgetVsActualTable data={budgetVsActual} />
|
<BudgetVsActualTable data={budgetVsActual} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue