Compare commits
9 commits
4742f6444f
...
64b7d8d11b
| Author | SHA1 | Date | |
|---|---|---|---|
| 64b7d8d11b | |||
| 8971e443d8 | |||
| 0d324b89c4 | |||
| c5a3e0f696 | |||
| 9551399f5f | |||
| 18c7ef3ee9 | |||
| 66d0cd85ff | |||
| 4a5b5fb5fe | |||
| dbe249783e |
13 changed files with 206 additions and 242 deletions
|
|
@ -5,6 +5,17 @@
|
||||||
### 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 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)
|
||||||
|
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
|
||||||
|
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
|
||||||
|
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
|
||||||
|
|
||||||
## [0.6.3]
|
## [0.6.3]
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -5,6 +5,17 @@
|
||||||
### 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 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)
|
||||||
|
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
|
||||||
|
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
|
||||||
|
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
|
||||||
|
|
||||||
## [0.6.3]
|
## [0.6.3]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -18,58 +19,6 @@ const MONTH_KEYS = [
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
|
||||||
rows: T[],
|
|
||||||
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);
|
|
||||||
} else {
|
|
||||||
if (current) groups.push(current);
|
|
||||||
current = { parent: null, children: [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;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
reorderedChildren.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (subParent) {
|
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
|
||||||
}
|
|
||||||
return [...reorderedChildren, parent];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BudgetTableProps {
|
interface BudgetTableProps {
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||||
|
|
@ -230,13 +179,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
if (row.is_parent) {
|
if (row.is_parent) {
|
||||||
// Parent subtotal row: read-only, bold, distinct background
|
// Parent subtotal row: read-only, bold, distinct background
|
||||||
const parentDepth = row.depth ?? 0;
|
const parentDepth = row.depth ?? 0;
|
||||||
const isIntermediateParent = parentDepth === 1;
|
const isTopParent = parentDepth === 0;
|
||||||
|
const isIntermediateParent = parentDepth >= 1;
|
||||||
|
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
|
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`}
|
||||||
>
|
>
|
||||||
<td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
|
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -267,7 +218,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Category name - sticky */}
|
{/* Category name - sticky */}
|
||||||
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -36,14 +37,14 @@ export default function CategoryPieChart({
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
<p className="text-center text-[var(--muted-foreground)] py-6">{t("dashboard.noData")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]">
|
||||||
{hiddenCategories.size > 0 && (
|
{hiddenCategories.size > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
|
|
@ -66,8 +67,12 @@ export default function CategoryPieChart({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
<div
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={() => setIsChartHovered(true)}
|
||||||
|
onMouseLeave={() => setIsChartHovered(false)}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartPatternDefs
|
<ChartPatternDefs
|
||||||
prefix="cat-pie"
|
prefix="cat-pie"
|
||||||
|
|
@ -79,8 +84,8 @@ export default function CategoryPieChart({
|
||||||
nameKey="category_name"
|
nameKey="category_name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={50}
|
innerRadius={35}
|
||||||
outerRadius={100}
|
outerRadius={75}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{visibleData.map((item, index) => (
|
{visibleData.map((item, index) => (
|
||||||
|
|
@ -94,9 +99,11 @@ export default function CategoryPieChart({
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) =>
|
formatter={(value) => {
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(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={{
|
contentStyle={{
|
||||||
backgroundColor: "var(--card)",
|
backgroundColor: "var(--card)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
|
|
@ -110,13 +117,14 @@ export default function CategoryPieChart({
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const isHidden = hiddenCategories.has(item.category_name);
|
const isHidden = hiddenCategories.has(item.category_name);
|
||||||
|
const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
|
className={`flex items-center gap-1 text-xs ${isHidden ? "opacity-40" : ""}`}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
setContextMenu({ x: e.clientX, y: e.clientY, item });
|
||||||
|
|
@ -126,7 +134,7 @@ export default function CategoryPieChart({
|
||||||
>
|
>
|
||||||
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
|
||||||
<span className="text-[var(--muted-foreground)]">
|
<span className="text-[var(--muted-foreground)]">
|
||||||
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
|
{item.category_name}{isChartHovered && pct != null ? ` ${pct}%` : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Fragment, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetVsActualRow } from "../../shared/types";
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -25,55 +26,6 @@ interface BudgetVsActualTableProps {
|
||||||
|
|
||||||
const STORAGE_KEY = "subtotals-position";
|
const STORAGE_KEY = "subtotals-position";
|
||||||
|
|
||||||
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
|
|
||||||
rows: T[],
|
|
||||||
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);
|
|
||||||
} else {
|
|
||||||
if (current) groups.push(current);
|
|
||||||
current = { parent: null, children: [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;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
reorderedChildren.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (subParent) {
|
|
||||||
reorderedChildren.push(...subChildren, subParent);
|
|
||||||
}
|
|
||||||
return [...reorderedChildren, parent];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||||
|
|
@ -151,7 +103,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom bg-[var(--card)]">
|
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||||
{t("budget.category")}
|
{t("budget.category")}
|
||||||
</th>
|
</th>
|
||||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||||
|
|
@ -206,25 +158,32 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
||||||
return (
|
return (
|
||||||
<Fragment key={section.type}>
|
<Fragment key={section.type}>
|
||||||
<tr className="bg-[var(--muted)]/50">
|
<tr className="bg-[var(--muted)]">
|
||||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||||
{section.label}
|
{section.label}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||||
const isParent = row.is_parent;
|
const isParent = row.is_parent;
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
const isIntermediateParent = isParent && depth === 1;
|
const isTopParent = isParent && depth === 0;
|
||||||
const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
isParent && !isIntermediateParent ? "bg-[var(--muted)]/30 font-semibold" :
|
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||||
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
|
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
|
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||||
|
isTopParent
|
||||||
|
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||||
|
: isIntermediateParent
|
||||||
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
|
}`}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
|
@ -256,8 +215,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold text-sm">
|
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||||
<td className="px-3 py-2.5">{t(typeTotalKeys[section.type])}</td>
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(sectionTotals.monthActual)}
|
{cadFormatter(sectionTotals.monthActual)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -283,8 +242,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Grand totals */}
|
{/* Grand totals */}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
||||||
<td className="px-3 py-3">{t("common.total")}</td>
|
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||||
{cadFormatter(totals.monthActual)}
|
{cadFormatter(totals.monthActual)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ const initialState: ReportsState = {
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
budgetYear: now.getFullYear(),
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
budgetMonth: now.getMonth() + 1,
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||||
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||||
|
|
@ -224,18 +224,9 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigateBudgetMonth = useCallback((delta: -1 | 1) => {
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||||
let newMonth = state.budgetMonth + delta;
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||||
let newYear = state.budgetYear;
|
}, []);
|
||||||
if (newMonth < 1) {
|
|
||||||
newMonth = 12;
|
|
||||||
newYear -= 1;
|
|
||||||
} else if (newMonth > 12) {
|
|
||||||
newMonth = 1;
|
|
||||||
newYear += 1;
|
|
||||||
}
|
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
|
|
||||||
}, [state.budgetYear, state.budgetMonth]);
|
|
||||||
|
|
||||||
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
|
@ -249,5 +240,5 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId };
|
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,8 @@
|
||||||
"ytd": "Year-to-Date",
|
"ytd": "Year-to-Date",
|
||||||
"dollarVar": "$ Var",
|
"dollarVar": "$ Var",
|
||||||
"pctVar": "% Var",
|
"pctVar": "% Var",
|
||||||
"noData": "No budget or transaction data for this period."
|
"noData": "No budget or transaction data for this period.",
|
||||||
|
"titlePrefix": "Budget vs Actual for"
|
||||||
},
|
},
|
||||||
"dynamic": "Dynamic Report",
|
"dynamic": "Dynamic Report",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,8 @@
|
||||||
"ytd": "Cumul annuel",
|
"ytd": "Cumul annuel",
|
||||||
"dollarVar": "$ \u00c9cart",
|
"dollarVar": "$ \u00c9cart",
|
||||||
"pctVar": "% \u00c9cart",
|
"pctVar": "% \u00c9cart",
|
||||||
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
|
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
|
||||||
|
"titlePrefix": "Budget vs Réel pour le mois de"
|
||||||
},
|
},
|
||||||
"dynamic": "Rapport dynamique",
|
"dynamic": "Rapport dynamique",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
|
|
|
||||||
|
|
@ -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-4 gap-4 mb-6">
|
||||||
<div>
|
<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
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types";
|
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types";
|
||||||
import { getAllSources } from "../services/importSourceService";
|
import { getAllSources } from "../services/importSourceService";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import MonthNavigator from "../components/budget/MonthNavigator";
|
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
|
|
@ -48,8 +47,8 @@ function computeDateRange(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
||||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -93,6 +92,19 @@ export default function ReportsPage() {
|
||||||
return [];
|
return [];
|
||||||
}, [state.tab, state.categorySpending, state.categoryOverTime]);
|
}, [state.tab, state.categorySpending, state.categoryOverTime]);
|
||||||
|
|
||||||
|
const monthOptions = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
return Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const d = new Date(currentYear, currentMonth - i, 1);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = d.getMonth() + 1;
|
||||||
|
const label = new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(d);
|
||||||
|
return { key: `${y}-${m}`, value: `${y}-${m}`, label: label.charAt(0).toUpperCase() + label.slice(1) };
|
||||||
|
});
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0;
|
||||||
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1);
|
||||||
|
|
||||||
|
|
@ -100,16 +112,30 @@ export default function ReportsPage() {
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
{state.tab === "budgetVsActual" ? (
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
|
||||||
|
{t("reports.bva.titlePrefix")}
|
||||||
|
<select
|
||||||
|
value={`${state.budgetYear}-${state.budgetMonth}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [y, m] = e.target.value.split("-").map(Number);
|
||||||
|
setBudgetMonth(y, m);
|
||||||
|
}}
|
||||||
|
className="text-2xl font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-1 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{monthOptions.map((opt) => (
|
||||||
|
<option key={opt.key} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</h1>
|
||||||
|
) : (
|
||||||
|
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||||
|
)}
|
||||||
<PageHelp helpKey="reports" />
|
<PageHelp helpKey="reports" />
|
||||||
</div>
|
</div>
|
||||||
{state.tab === "budgetVsActual" ? (
|
{state.tab !== "budgetVsActual" && (
|
||||||
<MonthNavigator
|
|
||||||
year={state.budgetYear}
|
|
||||||
month={state.budgetMonth}
|
|
||||||
onNavigate={navigateBudgetMonth}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={state.period}
|
value={state.period}
|
||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,6 @@ export async function getBudgetVsActualData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index categories
|
// Index categories
|
||||||
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
|
||||||
const childrenByParent = new Map<number, Category[]>();
|
const childrenByParent = new Map<number, Category[]>();
|
||||||
for (const cat of allCategories) {
|
for (const cat of allCategories) {
|
||||||
if (cat.parent_id) {
|
if (cat.parent_id) {
|
||||||
|
|
@ -244,7 +243,7 @@ export async function getBudgetVsActualData(
|
||||||
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||||
|
|
||||||
// Compute leaf row values
|
// 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 sign = signFor(cat.type);
|
||||||
const monthMap = entryMap.get(cat.id);
|
const monthMap = entryMap.get(cat.id);
|
||||||
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
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 = {
|
const row: BudgetVsActualRow = {
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
|
|
@ -323,35 +322,41 @@ export async function getBudgetVsActualData(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build rows for a level-2 parent (intermediate parent with grandchildren)
|
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
||||||
function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] {
|
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
||||||
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
const subChildren = childrenByParent.get(cat.id) || [];
|
||||||
if (grandchildren.length === 0 && cat.is_inputable) {
|
const hasSubChildren = subChildren.some(
|
||||||
// Leaf at level 2
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||||
const leaf = buildLeaf(cat, grandparentId, 2);
|
);
|
||||||
|
|
||||||
|
if (!hasSubChildren && cat.is_inputable) {
|
||||||
|
const leaf = buildLeaf(cat, groupParentId, depth);
|
||||||
return isRowAllZero(leaf) ? [] : [leaf];
|
return isRowAllZero(leaf) ? [] : [leaf];
|
||||||
}
|
}
|
||||||
if (grandchildren.length === 0) return [];
|
if (!hasSubChildren) return [];
|
||||||
|
|
||||||
const gcRows: BudgetVsActualRow[] = [];
|
const childRows: BudgetVsActualRow[] = [];
|
||||||
if (cat.is_inputable) {
|
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)`;
|
direct.category_name = `${cat.name} (direct)`;
|
||||||
if (!isRowAllZero(direct)) gcRows.push(direct);
|
if (!isRowAllZero(direct)) childRows.push(direct);
|
||||||
}
|
}
|
||||||
for (const gc of grandchildren) {
|
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const leaf = buildLeaf(gc, cat.id, 2);
|
for (const child of sortedSubChildren) {
|
||||||
if (!isRowAllZero(leaf)) gcRows.push(leaf);
|
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);
|
const leafRows = childRows.filter((r) => !r.is_parent);
|
||||||
gcRows.sort((a, b) => {
|
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
||||||
if (a.category_id === cat.id) return -1;
|
return [subtotal, ...childRows];
|
||||||
if (b.category_id === cat.id) return 1;
|
|
||||||
return a.category_name.localeCompare(b.category_name);
|
|
||||||
});
|
|
||||||
return [subtotal, ...gcRows];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: BudgetVsActualRow[] = [];
|
const rows: BudgetVsActualRow[] = [];
|
||||||
|
|
@ -359,15 +364,15 @@ export async function getBudgetVsActualData(
|
||||||
|
|
||||||
for (const cat of topLevel) {
|
for (const cat of topLevel) {
|
||||||
const children = childrenByParent.get(cat.id) || [];
|
const children = childrenByParent.get(cat.id) || [];
|
||||||
const inputableChildren = children.filter((c) => c.is_inputable);
|
const hasChildren = children.some(
|
||||||
// Also check for non-inputable intermediate parents that have their own children
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
||||||
const intermediateParents = children.filter((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
|
// Standalone leaf at level 0
|
||||||
const leaf = buildLeaf(cat, null, 0);
|
const leaf = buildLeaf(cat, null, 0);
|
||||||
if (!isRowAllZero(leaf)) rows.push(leaf);
|
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||||
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
|
} else if (hasChildren) {
|
||||||
const allChildRows: BudgetVsActualRow[] = [];
|
const allChildRows: BudgetVsActualRow[] = [];
|
||||||
|
|
||||||
// Direct transactions on the parent itself
|
// Direct transactions on the parent itself
|
||||||
|
|
@ -377,25 +382,19 @@ export async function getBudgetVsActualData(
|
||||||
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level-2 leaves (direct children that are inputable and have no children)
|
// Process children in alphabetical order
|
||||||
for (const child of inputableChildren) {
|
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const child of sortedChildren) {
|
||||||
const grandchildren = childrenByParent.get(child.id) || [];
|
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);
|
const leaf = buildLeaf(child, cat.id, 1);
|
||||||
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
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;
|
if (allChildRows.length === 0) continue;
|
||||||
|
|
||||||
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
||||||
|
|
@ -403,51 +402,19 @@ export async function getBudgetVsActualData(
|
||||||
const parent = buildSubtotal(cat, leafRows, null, 0);
|
const parent = buildSubtotal(cat, leafRows, null, 0);
|
||||||
|
|
||||||
rows.push(parent);
|
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);
|
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) => {
|
rows.sort((a, b) => {
|
||||||
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
if (typeA !== typeB) return typeA - typeB;
|
if (typeA !== typeB) return typeA - typeB;
|
||||||
// Find the top-level group id
|
return rowOrder.get(a)! - rowOrder.get(b)!;
|
||||||
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 rows;
|
return rows;
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export interface BudgetYearRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
depth?: 0 | 1 | 2;
|
depth?: number;
|
||||||
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
months: number[]; // index 0-11 = Jan-Dec planned amounts
|
||||||
annual: number; // computed sum
|
annual: number; // computed sum
|
||||||
previousYearTotal: number; // total budget from the previous year
|
previousYearTotal: number; // total budget from the previous year
|
||||||
|
|
@ -332,7 +332,7 @@ export interface BudgetVsActualRow {
|
||||||
category_type: "expense" | "income" | "transfer";
|
category_type: "expense" | "income" | "transfer";
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
is_parent: boolean;
|
is_parent: boolean;
|
||||||
depth?: 0 | 1 | 2;
|
depth?: number;
|
||||||
monthActual: number;
|
monthActual: number;
|
||||||
monthBudget: number;
|
monthBudget: number;
|
||||||
monthVariation: number;
|
monthVariation: number;
|
||||||
|
|
|
||||||
38
src/utils/reorderRows.ts
Normal file
38
src/utils/reorderRows.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Shared utility for reordering budget table rows.
|
||||||
|
* Recursively moves subtotal (parent) rows below their children
|
||||||
|
* at every depth level when "subtotals on bottom" is enabled.
|
||||||
|
*/
|
||||||
|
export function reorderRows<
|
||||||
|
T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number },
|
||||||
|
>(rows: T[], subtotalsOnTop: boolean): T[] {
|
||||||
|
if (subtotalsOnTop) return rows;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
result.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush last group
|
||||||
|
if (currentParent) {
|
||||||
|
result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reorderGroup(rows, 0);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue