feat: add Dynamic Report (pivot table) tab to Reports page

Implement a pivot table feature allowing users to compose custom reports
by assigning dimensions (Year, Month, Type, Level 1/2) to rows, columns,
and filters, with periodic and YTD measures as values. Includes a side
panel for configuration, a dynamic table with subtotals, and a stacked
bar chart visualization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-22 08:26:50 -05:00
parent 2436f78023
commit 20b3a54ec7
13 changed files with 1084 additions and 10 deletions

View file

@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values
- Delete keywords from the "All Keywords" view
## [0.3.8]

View file

@ -34,7 +34,7 @@ simpl-resultat/
│ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # 4 composants (graphiques)
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
│ │ ├── settings/ # 2 composants
│ │ ├── shared/ # 4 composants réutilisables
│ │ └── transactions/ # 5 composants

View file

@ -250,6 +250,7 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
@ -268,6 +269,21 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
- Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
### Rapport dynamique
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
1. Cliquez sur un champ disponible dans le panneau de droite
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
3. Le tableau et/ou le graphique se mettent à jour automatiquement
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
5. Basculez entre les vues Tableau, Graphique ou Les deux
6. Cliquez sur le X pour retirer un champ d'une zone
---
## 10. Paramètres

View file

@ -0,0 +1,82 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Table, BarChart3, Columns } from "lucide-react";
import type { PivotConfig, PivotResult } from "../../shared/types";
import DynamicReportPanel from "./DynamicReportPanel";
import DynamicReportTable from "./DynamicReportTable";
import DynamicReportChart from "./DynamicReportChart";
type ViewMode = "table" | "chart" | "both";
interface DynamicReportProps {
config: PivotConfig;
result: PivotResult;
onConfigChange: (config: PivotConfig) => void;
dateFrom?: string;
dateTo?: string;
}
export default function DynamicReport({ config, result, onConfigChange, dateFrom, dateTo }: DynamicReportProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
];
return (
<div className="flex gap-4 items-start">
{/* Content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* View toggle */}
{hasConfig && (
<div className="flex gap-1">
{viewButtons.map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
)}
{/* Empty state */}
{!hasConfig && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noConfig")}
</div>
)}
{/* Table */}
{hasConfig && (viewMode === "table" || viewMode === "both") && (
<DynamicReportTable config={config} result={result} />
)}
{/* Chart */}
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
<DynamicReportChart config={config} result={result} />
)}
</div>
{/* Side panel */}
<DynamicReportPanel
config={config}
onChange={onConfigChange}
dateFrom={dateFrom}
dateTo={dateTo}
/>
</div>
);
}

View file

@ -0,0 +1,135 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
CartesianGrid,
} from "recharts";
import type { PivotConfig, PivotResult } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
// Generate distinct colors for series
const SERIES_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
];
interface DynamicReportChartProps {
config: PivotConfig;
result: PivotResult;
}
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
const { t } = useTranslation();
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
if (result.rows.length === 0) {
return { chartData: [], seriesKeys: [], seriesColors: {} };
}
const colDim = config.columns[0];
const rowDim = config.rows[0];
const measure = config.values[0] || "periodic";
// X-axis = first column dimension (or first row dimension if no columns)
const xDim = colDim || rowDim;
if (!xDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = colDim ? rowDim : undefined;
// Collect unique x and series values
const xValues = [...new Set(result.rows.map((r) => r.keys[xDim]))].sort();
const seriesVals = seriesDim
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
: [measure];
// Build chart data: one entry per x value
const data = xValues.map((xVal) => {
const entry: Record<string, string | number> = { name: xVal };
if (seriesDim) {
for (const sv of seriesVals) {
const matchingRows = result.rows.filter(
(r) => r.keys[xDim] === xVal && r.keys[seriesDim] === sv
);
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
} else {
const matchingRows = result.rows.filter((r) => r.keys[xDim] === xVal);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
return entry;
});
const colors: Record<string, string> = {};
seriesVals.forEach((sv, i) => {
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
});
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
}, [config, result]);
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
</div>
);
}
const categoryEntries = seriesKeys.map((key, index) => ({
color: seriesColors[key],
index,
}));
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="name"
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="stack"
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,264 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps {
config: PivotConfig;
onChange: (config: PivotConfig) => void;
dateFrom?: string;
dateTo?: string;
}
export default function DynamicReportPanel({ config, onChange, dateFrom, dateTo }: DynamicReportPanelProps) {
const { t } = useTranslation();
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
const menuRef = useRef<HTMLDivElement>(null);
// Fields currently assigned somewhere
const assignedFields = new Set([...config.rows, ...config.columns, ...Object.keys(config.filters) as PivotFieldId[]]);
const assignedMeasures = new Set(config.values);
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
// Load filter values when a field is added to filters
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
useEffect(() => {
for (const fieldId of filterFieldIds) {
if (!filterValues[fieldId]) {
getDynamicFilterValues(fieldId as PivotFieldId, dateFrom, dateTo).then((vals) => {
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
});
}
}
}, [filterFieldIds.join(","), dateFrom, dateTo]);
// Close menu on outside click
useEffect(() => {
if (!menuTarget) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuTarget(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuTarget]);
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
};
const assignTo = useCallback((zone: PivotZone) => {
if (!menuTarget) return;
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (menuTarget.type === "measure") {
if (zone === "values") {
next.values = [...next.values, menuTarget.id as PivotMeasureId];
}
} else {
const fieldId = menuTarget.id as PivotFieldId;
if (zone === "rows") next.rows = [...next.rows, fieldId];
else if (zone === "columns") next.columns = [...next.columns, fieldId];
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: [] };
}
setMenuTarget(null);
onChange(next);
}, [menuTarget, config, onChange]);
const removeFrom = (zone: PivotZone, id: string) => {
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
else if (zone === "filters") {
const { [id]: _, ...rest } = next.filters;
next.filters = rest;
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
onChange(next);
};
const toggleFilterValue = (fieldId: string, value: string) => {
const current = config.filters[fieldId] || [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
onChange({ ...config, filters: { ...config.filters, [fieldId]: next } });
};
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
const zoneOptions: { zone: PivotZone; label: string; forMeasure: boolean }[] = [
{ zone: "rows", label: t("reports.pivot.rows"), forMeasure: false },
{ zone: "columns", label: t("reports.pivot.columns"), forMeasure: false },
{ zone: "filters", label: t("reports.pivot.filters"), forMeasure: false },
{ zone: "values", label: t("reports.pivot.values"), forMeasure: true },
];
return (
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
{/* Available Fields */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
<div className="flex flex-wrap gap-1.5">
{availableFields.map((f) => (
<button
key={f}
onClick={(e) => handleFieldClick(f, "field", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
>
{fieldLabel(f)}
</button>
))}
{availableMeasures.map((m) => (
<button
key={m}
onClick={(e) => handleFieldClick(m, "measure", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
>
{measureLabel(m)}
</button>
))}
{availableFields.length === 0 && availableMeasures.length === 0 && (
<span className="text-xs text-[var(--muted-foreground)]"></span>
)}
</div>
</div>
{/* Rows */}
<ZoneSection
label={t("reports.pivot.rows")}
items={config.rows}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("rows", id)}
/>
{/* Columns */}
<ZoneSection
label={t("reports.pivot.columns")}
items={config.columns}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("columns", id)}
/>
{/* Filters */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
{filterFieldIds.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="space-y-2">
{filterFieldIds.map((fieldId) => (
<div key={fieldId}>
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
<X size={12} />
</button>
</div>
<div className="flex flex-wrap gap-1">
{(filterValues[fieldId] || []).map((val) => {
const selected = (config.filters[fieldId] || []).includes(val);
const isActiveFilter = (config.filters[fieldId] || []).length > 0;
return (
<button
key={val}
onClick={() => toggleFilterValue(fieldId, val)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
selected
? "bg-[var(--primary)] text-white"
: isActiveFilter
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{/* Values */}
<ZoneSection
label={t("reports.pivot.values")}
items={config.values}
getLabel={measureLabel}
onRemove={(id) => removeFrom("values", id)}
accent
/>
{/* Context menu */}
{menuTarget && (
<div
ref={menuRef}
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
style={{ left: menuTarget.x, top: menuTarget.y }}
>
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
{zoneOptions
.filter((opt) => (menuTarget.type === "measure") === opt.forMeasure)
.map((opt) => (
<button
key={opt.zone}
onClick={() => assignTo(opt.zone)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
function ZoneSection({
label,
items,
getLabel,
onRemove,
accent,
}: {
label: string;
items: string[];
getLabel: (id: string) => string;
onRemove: (id: string) => void;
accent?: boolean;
}) {
return (
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
{items.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<span
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
accent
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{getLabel(id)}
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,259 @@
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
const STORAGE_KEY = "pivot-subtotals-position";
interface DynamicReportTableProps {
config: PivotConfig;
result: PivotResult;
}
interface GroupNode {
key: string;
label: string;
rows: PivotResultRow[];
children: GroupNode[];
}
function buildGroups(rows: PivotResultRow[], rowDims: string[], depth: number): GroupNode[] {
if (depth >= rowDims.length) return [];
const dim = rowDims[depth];
const map = new Map<string, PivotResultRow[]>();
for (const row of rows) {
const key = row.keys[dim] || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
const groups: GroupNode[] = [];
for (const [key, groupRows] of map) {
groups.push({
key,
label: key,
rows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1),
});
}
return groups;
}
function computeSubtotals(rows: PivotResultRow[], measures: string[], colDim: string | undefined): Record<string, Record<string, number>> {
// colValue → measure → sum
const result: Record<string, Record<string, number>> = {};
for (const row of rows) {
const colKey = colDim ? (row.keys[colDim] || "") : "__all__";
if (!result[colKey]) result[colKey] = {};
for (const m of measures) {
result[colKey][m] = (result[colKey][m] || 0) + (row.measures[m] || 0);
}
}
return result;
}
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
if (result.rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noData")}
</div>
);
}
const rowDims = config.rows;
const colDim = config.columns[0] || undefined;
const colValues = colDim ? result.columnValues : ["__all__"];
const measures = config.values;
// Build row groups from first row dimension
const groups = rowDims.length > 0 ? buildGroups(result.rows, rowDims, 0) : [];
// Grand totals
const grandTotals = computeSubtotals(result.rows, measures, colDim);
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{rowDims.length > 1 && (
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
{/* Row dimension headers */}
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
{fieldLabel(dim)}
</th>
))}
{/* Column headers: colValue × measure */}
{colValues.map((colVal) =>
measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{colDim ? `${colVal}${measureLabel(m)}` : measureLabel(m)}
</th>
))
)}
</tr>
</thead>
<tbody>
{rowDims.length === 0 ? (
// No row dims — single row with totals
<tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) =>
measures.map((m) => {
const val = grandTotals[colVal]?.[m] || 0;
return (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(val)}
</td>
);
})
)}
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group.key}
group={group}
colDim={colDim}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={0}
subtotalsOnTop={subtotalsOnTop}
/>
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-2">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
</tbody>
</table>
</div>
</div>
);
}
function GroupRows({
group,
colDim,
colValues,
measures,
rowDims,
depth,
subtotalsOnTop,
}: {
group: GroupNode;
colDim: string | undefined;
colValues: string[];
measures: string[];
rowDims: string[];
depth: number;
subtotalsOnTop: boolean;
}) {
const isLeafLevel = depth === rowDims.length - 1;
const subtotals = computeSubtotals(group.rows, measures, colDim);
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
{group.label}
</td>
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(subtotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : null;
if (isLeafLevel) {
// Render leaf rows: one per unique combination of remaining keys
return (
<>
{group.rows.map((row, i) => (
<tr key={i} className="border-b border-[var(--border)]/50">
{rowDims.map((dim, di) => (
<td key={dim} className="px-3 py-1.5" style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}>
{di === depth ? row.keys[dim] || "" : di > depth ? row.keys[dim] || "" : ""}
</td>
))}
{colValues.map((colVal) =>
measures.map((m) => {
const matchesCol = !colDim || row.keys[colDim] === colVal;
return (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{matchesCol ? cadFormatter(row.measures[m] || 0) : ""}
</td>
);
})
)}
</tr>
))}
</>
);
}
const childContent = group.children.map((child) => (
<GroupRows
key={child.key}
group={child}
colDim={colDim}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={depth + 1}
subtotalsOnTop={subtotalsOnTop}
/>
));
return (
<Fragment>
{subtotalsOnTop && subtotalRow}
{childContent}
{!subtotalsOnTop && subtotalRow}
</Fragment>
);
}

View file

@ -6,8 +6,10 @@ import type {
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
PivotConfig,
PivotResult,
} from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
@ -22,6 +24,8 @@ interface ReportsState {
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
pivotConfig: PivotConfig;
pivotResult: PivotResult;
isLoading: boolean;
error: string | null;
}
@ -36,6 +40,8 @@ type ReportsAction =
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date();
@ -53,6 +59,8 @@ const initialState: ReportsState = {
budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() + 1,
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
isLoading: false,
error: null,
};
@ -77,6 +85,10 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
case "SET_BUDGET_VS_ACTUAL":
return { ...state, budgetVsActual: action.payload, isLoading: false };
case "SET_PIVOT_CONFIG":
return { ...state, pivotConfig: action.payload };
case "SET_PIVOT_RESULT":
return { ...state, pivotResult: action.payload, isLoading: false };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
default:
@ -136,6 +148,7 @@ export function useReports() {
budgetMonth: number,
customFrom?: string,
customTo?: string,
pivotCfg?: PivotConfig,
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
@ -170,6 +183,17 @@ export function useReports() {
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
break;
}
case "dynamic": {
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
break;
}
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getDynamicReportData(pivotCfg, dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
break;
}
}
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
@ -181,8 +205,8 @@ export function useReports() {
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, fetchData]);
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
@ -209,5 +233,9 @@ export function useReports() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth };
const setPivotConfig = useCallback((config: PivotConfig) => {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []);
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig };
}

View file

@ -356,14 +356,38 @@
"pctVar": "% Var",
"noData": "No budget or transaction data for this period."
},
"dynamic": "Dynamic Report",
"export": "Export",
"pivot": {
"availableFields": "Available Fields",
"rows": "Rows",
"columns": "Columns",
"filters": "Filters",
"values": "Values",
"addTo": "Add to...",
"year": "Year",
"month": "Month",
"categoryType": "Type",
"level1": "Category (Level 1)",
"level2": "Category (Level 2)",
"periodic": "Periodic Amount",
"ytd": "Year-to-Date (YTD)",
"subtotal": "Subtotal",
"total": "Total",
"viewTable": "Table",
"viewChart": "Chart",
"viewBoth": "Both",
"noConfig": "Add fields to generate the report",
"noData": "No data for this configuration"
},
"help": {
"title": "How to use Reports",
"tips": [
"Switch between Trends, By Category, and Over Time views using the tabs",
"Use the period selector to adjust the time range for all charts",
"Monthly Trends shows your income and expenses over time",
"Category Over Time tracks how spending in each category evolves"
"Category Over Time tracks how spending in each category evolves",
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
]
}
},

View file

@ -356,14 +356,38 @@
"pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
},
"dynamic": "Rapport dynamique",
"export": "Exporter",
"pivot": {
"availableFields": "Champs disponibles",
"rows": "Lignes",
"columns": "Colonnes",
"filters": "Filtres",
"values": "Valeurs",
"addTo": "Ajouter à...",
"year": "Année",
"month": "Mois",
"categoryType": "Type",
"level1": "Catégorie (Niveau 1)",
"level2": "Catégorie (Niveau 2)",
"periodic": "Montant périodique",
"ytd": "Cumul annuel (YTD)",
"subtotal": "Sous-total",
"total": "Total",
"viewTable": "Tableau",
"viewChart": "Graphique",
"viewBoth": "Les deux",
"noConfig": "Ajoutez des champs pour générer le rapport",
"noData": "Aucune donnée pour cette configuration"
},
"help": {
"title": "Comment utiliser les Rapports",
"tips": [
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
"Catégories dans le temps suit l'évolution des dépenses par catégorie",
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
]
}
},

View file

@ -9,9 +9,10 @@ import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
function computeDateRange(
period: DashboardPeriod,
@ -41,7 +42,7 @@ function computeDateRange(
export default function ReportsPage() {
const { t } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth } = useReports();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -131,6 +132,15 @@ export default function ReportsPage() {
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
)}
{state.tab === "dynamic" && (
<DynamicReport
config={state.pivotConfig}
result={state.pivotResult}
onConfigChange={setPivotConfig}
dateFrom={dateFrom}
dateTo={dateTo}
/>
)}
{detailModal && (
<TransactionDetailModal

View file

@ -4,6 +4,10 @@ import type {
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
PivotConfig,
PivotFieldId,
PivotResult,
PivotResultRow,
} from "../shared/types";
export async function getMonthlyTrends(
@ -147,3 +151,206 @@ export async function getCategoryOverTime(
categoryIds,
};
}
// --- Dynamic Report (Pivot Table) ---
const FIELD_SQL: Record<PivotFieldId, { select: string; alias: string }> = {
year: { select: "strftime('%Y', t.date)", alias: "year" },
month: { select: "strftime('%Y-%m', t.date)", alias: "month" },
type: { select: "COALESCE(c.type, 'expense')", alias: "type" },
level1: { select: "COALESCE(parent_cat.name, c.name, 'Uncategorized')", alias: "level1" },
level2: { select: "COALESCE(CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE NULL END, 'Uncategorized')", alias: "level2" },
};
function needsCategoryJoin(fields: PivotFieldId[]): boolean {
return fields.some((f) => f === "type" || f === "level1" || f === "level2");
}
export async function getDynamicReportData(
config: PivotConfig,
dateFrom?: string,
dateTo?: string,
): Promise<PivotResult> {
const db = await getDb();
const allDimensions = [...config.rows, ...config.columns];
const filterFields = Object.keys(config.filters) as PivotFieldId[];
const allFields = [...new Set([...allDimensions, ...filterFields])];
const useCatJoin = needsCategoryJoin(allFields);
// Build SELECT columns
const selectParts: string[] = [];
const groupByParts: string[] = [];
for (const fieldId of allDimensions) {
const def = FIELD_SQL[fieldId];
selectParts.push(`${def.select} AS ${def.alias}`);
groupByParts.push(def.alias);
}
// Measures
const hasPeriodic = config.values.includes("periodic");
const hasYtd = config.values.includes("ytd");
if (hasPeriodic) {
selectParts.push("ABS(SUM(t.amount)) AS periodic");
}
// Build WHERE
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`t.date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
// Apply filter values
for (const fieldId of filterFields) {
const values = config.filters[fieldId];
if (values && values.length > 0) {
const def = FIELD_SQL[fieldId as PivotFieldId];
const placeholders = values.map(() => {
const p = `$${paramIndex}`;
paramIndex++;
return p;
});
whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`);
params.push(...values);
}
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
const groupBySQL = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(", ")}` : "";
const orderBySQL = groupByParts.length > 0 ? `ORDER BY ${groupByParts.join(", ")}` : "";
const joinSQL = useCatJoin
? `LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id`
: "";
const sql = `SELECT ${selectParts.join(", ")}
FROM transactions t
${joinSQL}
${whereSQL}
${groupBySQL}
${orderBySQL}`;
const rawRows = await db.select<Array<Record<string, unknown>>>(sql, params);
// Build PivotResultRow array
const rows: PivotResultRow[] = rawRows.map((raw) => {
const keys: Record<string, string> = {};
for (const fieldId of allDimensions) {
keys[fieldId] = String(raw[FIELD_SQL[fieldId].alias] ?? "");
}
const measures: Record<string, number> = {};
if (hasPeriodic) {
measures.periodic = Number(raw.periodic) || 0;
}
return { keys, measures };
});
// Compute YTD if requested
if (hasYtd && rows.length > 0) {
// YTD = cumulative sum from January of the year, grouped by row dimensions (excluding month)
const rowDims = config.rows.filter((f) => f !== "month");
const colDims = config.columns.filter((f) => f !== "month");
const groupDims = [...rowDims, ...colDims];
// Sort rows by year then month for accumulation
const sorted = [...rows].sort((a, b) => {
const aKey = (a.keys.year || a.keys.month?.slice(0, 4) || "") + (a.keys.month || "");
const bKey = (b.keys.year || b.keys.month?.slice(0, 4) || "") + (b.keys.month || "");
return aKey.localeCompare(bKey);
});
// Accumulate by group key + year
const accumulators = new Map<string, number>();
for (const row of sorted) {
const year = row.keys.year || row.keys.month?.slice(0, 4) || "";
const groupKey = groupDims.map((d) => row.keys[d] || "").join("|") + "|" + year;
const prev = accumulators.get(groupKey) || 0;
const current = prev + (row.measures.periodic || 0);
accumulators.set(groupKey, current);
row.measures.ytd = current;
}
// Restore original order
const rowMap = new Map(sorted.map((r) => {
const key = Object.values(r.keys).join("|");
return [key, r];
}));
for (let i = 0; i < rows.length; i++) {
const key = Object.values(rows[i].keys).join("|");
const updated = rowMap.get(key);
if (updated) {
rows[i].measures.ytd = updated.measures.ytd;
}
}
}
// Extract distinct column values
const columnDim = config.columns[0];
const columnValues = columnDim
? [...new Set(rows.map((r) => r.keys[columnDim]))].sort()
: [];
// Dimension labels
const dimensionLabels: Record<string, string> = {
year: "Année",
month: "Mois",
type: "Type",
level1: "Catégorie (Niveau 1)",
level2: "Catégorie (Niveau 2)",
periodic: "Montant périodique",
ytd: "Cumul annuel (YTD)",
};
return { rows, columnValues, dimensionLabels };
}
export async function getDynamicFilterValues(
fieldId: PivotFieldId,
dateFrom?: string,
dateTo?: string,
): Promise<string[]> {
const db = await getDb();
const def = FIELD_SQL[fieldId];
const useCatJoin = needsCategoryJoin([fieldId]);
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`t.date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
const joinSQL = useCatJoin
? `LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id`
: "";
const rows = await db.select<Array<{ val: string }>>(
`SELECT DISTINCT ${def.select} AS val FROM transactions t ${joinSQL} ${whereSQL} ORDER BY val`,
params,
);
return rows.map((r) => r.val);
}

View file

@ -274,7 +274,31 @@ export interface RecentTransaction {
// --- Report Types ---
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual" | "dynamic";
// --- Pivot / Dynamic Report Types ---
export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2";
export type PivotMeasureId = "periodic" | "ytd";
export type PivotZone = "rows" | "columns" | "filters" | "values";
export interface PivotConfig {
rows: PivotFieldId[];
columns: PivotFieldId[];
filters: Record<string, string[]>; // field → selected values (empty = all)
values: PivotMeasureId[];
}
export interface PivotResultRow {
keys: Record<string, string>; // dimension values
measures: Record<string, number>; // measure values
}
export interface PivotResult {
rows: PivotResultRow[];
columnValues: string[]; // distinct values for column dimension
dimensionLabels: Record<string, string>; // field id → display label
}
export interface MonthlyTrendItem {
month: string; // "2025-01"