Compare commits
1 commit
b0d81522b2
...
f5dfbb5ad4
| Author | SHA1 | Date | |
|---|---|---|---|
| f5dfbb5ad4 |
7 changed files with 118 additions and 37 deletions
|
|
@ -5,6 +5,13 @@
|
||||||
### Ajouté
|
### 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)
|
- 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 dans le tableau réel vs budget (#23)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Tableau de bord : taille du graphique circulaire réduite et plus d'espace pour le tableau réel vs budget (#23)
|
||||||
|
- Tableau de bord : la légende du graphique circulaire est maintenant repliable (repliée par défaut) pour économiser de l'espace (#23)
|
||||||
|
|
||||||
## [0.6.3]
|
## [0.6.3]
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@
|
||||||
### Added
|
### Added
|
||||||
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
|
- 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 in the budget vs actual table (#23)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dashboard: reduced pie chart size and gave more space to the budget vs actual table (#23)
|
||||||
|
- Dashboard: pie chart legend is now collapsible (collapsed by default) to save space (#23)
|
||||||
|
|
||||||
## [0.6.3]
|
## [0.6.3]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||||
import ChartContextMenu from "../shared/ChartContextMenu";
|
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||||
|
|
@ -24,6 +24,7 @@ export default function CategoryPieChart({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
|
||||||
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 [legendExpanded, setLegendExpanded] = useState(false);
|
||||||
|
|
||||||
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
|
||||||
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
|
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
|
||||||
|
|
@ -67,7 +68,7 @@ export default function CategoryPieChart({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
<div onContextMenu={handleContextMenu}>
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartPatternDefs
|
<ChartPatternDefs
|
||||||
prefix="cat-pie"
|
prefix="cat-pie"
|
||||||
|
|
@ -79,8 +80,8 @@ export default function CategoryPieChart({
|
||||||
nameKey="category_name"
|
nameKey="category_name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={50}
|
innerRadius={40}
|
||||||
outerRadius={100}
|
outerRadius={85}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{visibleData.map((item, index) => (
|
{visibleData.map((item, index) => (
|
||||||
|
|
@ -110,27 +111,38 @@ export default function CategoryPieChart({
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
<div className="mt-2">
|
||||||
{data.map((item, index) => {
|
<button
|
||||||
const isHidden = hiddenCategories.has(item.category_name);
|
onClick={() => setLegendExpanded((prev) => !prev)}
|
||||||
return (
|
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-1"
|
||||||
<button
|
>
|
||||||
key={index}
|
{legendExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
{t("charts.legend")}
|
||||||
onContextMenu={(e) => {
|
</button>
|
||||||
e.preventDefault();
|
{legendExpanded && (
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
}}
|
{data.map((item, index) => {
|
||||||
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
|
const isHidden = hiddenCategories.has(item.category_name);
|
||||||
title={isHidden ? t("charts.clickToShow") : undefined}
|
return (
|
||||||
>
|
<button
|
||||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
key={index}
|
||||||
<span className="text-[var(--muted-foreground)]">
|
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
||||||
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
|
onContextMenu={(e) => {
|
||||||
</span>
|
e.preventDefault();
|
||||||
</button>
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
|
|
|
||||||
|
|
@ -508,7 +508,8 @@
|
||||||
"showAll": "Show all",
|
"showAll": "Show all",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"transactions": "transactions",
|
"transactions": "transactions",
|
||||||
"clickToShow": "Click to show"
|
"clickToShow": "Click to show",
|
||||||
|
"legend": "Legend"
|
||||||
},
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"jan": "Jan",
|
"jan": "Jan",
|
||||||
|
|
|
||||||
|
|
@ -508,7 +508,8 @@
|
||||||
"showAll": "Tout afficher",
|
"showAll": "Tout afficher",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"transactions": "transactions",
|
"transactions": "transactions",
|
||||||
"clickToShow": "Cliquer pour afficher"
|
"clickToShow": "Cliquer pour afficher",
|
||||||
|
"legend": "Légende"
|
||||||
},
|
},
|
||||||
"months": {
|
"months": {
|
||||||
"jan": "Jan",
|
"jan": "Jan",
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,8 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-6">
|
||||||
<div>
|
<div className="lg:col-span-2">
|
||||||
<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
|
||||||
data={categoryBreakdown}
|
data={categoryBreakdown}
|
||||||
|
|
@ -137,7 +137,7 @@ export default function DashboardPage() {
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -404,13 +404,66 @@ export async function getBudgetVsActualData(
|
||||||
|
|
||||||
rows.push(parent);
|
rows.push(parent);
|
||||||
|
|
||||||
// Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves
|
// Sort: "(direct)" first, then keep level-2 groups (subtotal + children) together,
|
||||||
allChildRows.sort((a, b) => {
|
// sorted alphabetically by the subtotal name, with leaves sorted alphabetically too.
|
||||||
if (a.category_id === cat.id && !a.is_parent) return -1;
|
// Separate depth-1 leaves from depth-1 subtotal groups
|
||||||
if (b.category_id === cat.id && !b.is_parent) return 1;
|
const directRow = allChildRows.find((r) => r.category_id === cat.id && !r.is_parent);
|
||||||
return a.category_name.localeCompare(b.category_name);
|
const level2Groups: { subtotal: BudgetVsActualRow; children: BudgetVsActualRow[] }[] = [];
|
||||||
});
|
const level1Leaves: BudgetVsActualRow[] = [];
|
||||||
rows.push(...allChildRows);
|
|
||||||
|
for (const r of allChildRows) {
|
||||||
|
if (r.category_id === cat.id && !r.is_parent) continue; // skip "(direct)" — handled separately
|
||||||
|
if (r.is_parent && (r.depth ?? 0) === 1) {
|
||||||
|
// This is an intermediate parent subtotal — start a new group
|
||||||
|
level2Groups.push({ subtotal: r, children: [] });
|
||||||
|
} else if ((r.depth ?? 0) === 2) {
|
||||||
|
// Find which group this belongs to (by parent_id)
|
||||||
|
const group = level2Groups.find((g) => g.subtotal.category_id === r.parent_id);
|
||||||
|
if (group) {
|
||||||
|
group.children.push(r);
|
||||||
|
} else {
|
||||||
|
level1Leaves.push(r);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
level1Leaves.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort level-1 leaves alphabetically
|
||||||
|
level1Leaves.sort((a, b) => a.category_name.localeCompare(b.category_name));
|
||||||
|
// Sort level-2 groups by subtotal name
|
||||||
|
level2Groups.sort((a, b) => a.subtotal.category_name.localeCompare(b.subtotal.category_name));
|
||||||
|
// Sort children within each group
|
||||||
|
for (const g of level2Groups) {
|
||||||
|
g.children.sort((a, b) => {
|
||||||
|
// "(direct)" row first
|
||||||
|
if (a.category_id === g.subtotal.category_id) return -1;
|
||||||
|
if (b.category_id === g.subtotal.category_id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassemble: (direct) first, then interleave level-1 leaves and level-2 groups alphabetically
|
||||||
|
const sorted: BudgetVsActualRow[] = [];
|
||||||
|
if (directRow) sorted.push(directRow);
|
||||||
|
|
||||||
|
// Merge level1Leaves and level2Groups by name
|
||||||
|
let li = 0;
|
||||||
|
let gi = 0;
|
||||||
|
while (li < level1Leaves.length || gi < level2Groups.length) {
|
||||||
|
const leafName = li < level1Leaves.length ? level1Leaves[li].category_name : null;
|
||||||
|
const groupName = gi < level2Groups.length ? level2Groups[gi].subtotal.category_name : null;
|
||||||
|
if (leafName !== null && (groupName === null || leafName.localeCompare(groupName) <= 0)) {
|
||||||
|
sorted.push(level1Leaves[li]);
|
||||||
|
li++;
|
||||||
|
} else {
|
||||||
|
const g = level2Groups[gi];
|
||||||
|
sorted.push(g.subtotal, ...g.children);
|
||||||
|
gi++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(...sorted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue