diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9f011..05fc276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/docs/architecture.md b/docs/architecture.md index a6ff1d8..e1fd2f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index f704d0e..7ac2f39 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -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 diff --git a/src/components/reports/DynamicReport.tsx b/src/components/reports/DynamicReport.tsx new file mode 100644 index 0000000..85f7e03 --- /dev/null +++ b/src/components/reports/DynamicReport.tsx @@ -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("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: , label: t("reports.pivot.viewTable") }, + { mode: "chart", icon: , label: t("reports.pivot.viewChart") }, + { mode: "both", icon: , label: t("reports.pivot.viewBoth") }, + ]; + + return ( +
+ {/* Content area */} +
+ {/* View toggle */} + {hasConfig && ( +
+ {viewButtons.map(({ mode, icon, label }) => ( + + ))} +
+ )} + + {/* Empty state */} + {!hasConfig && ( +
+ {t("reports.pivot.noConfig")} +
+ )} + + {/* Table */} + {hasConfig && (viewMode === "table" || viewMode === "both") && ( + + )} + + {/* Chart */} + {hasConfig && (viewMode === "chart" || viewMode === "both") && ( + + )} +
+ + {/* Side panel */} + +
+ ); +} diff --git a/src/components/reports/DynamicReportChart.tsx b/src/components/reports/DynamicReportChart.tsx new file mode 100644 index 0000000..27e0385 --- /dev/null +++ b/src/components/reports/DynamicReportChart.tsx @@ -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 = { 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 = {}; + 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 ( +
+

{t("reports.pivot.noData")}

+
+ ); + } + + const categoryEntries = seriesKeys.map((key, index) => ({ + color: seriesColors[key], + index, + })); + + return ( +
+ + + + + + cadFormatter(v)} + tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} + stroke="var(--border)" + width={80} + /> + 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)" }} + /> + + {seriesKeys.map((key, index) => ( + + ))} + + +
+ ); +} diff --git a/src/components/reports/DynamicReportPanel.tsx b/src/components/reports/DynamicReportPanel.tsx new file mode 100644 index 0000000..618c07e --- /dev/null +++ b/src/components/reports/DynamicReportPanel.tsx @@ -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>({}); + const menuRef = useRef(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 ( +
+ {/* Available Fields */} +
+

{t("reports.pivot.availableFields")}

+
+ {availableFields.map((f) => ( + + ))} + {availableMeasures.map((m) => ( + + ))} + {availableFields.length === 0 && availableMeasures.length === 0 && ( + + )} +
+
+ + {/* Rows */} + removeFrom("rows", id)} + /> + + {/* Columns */} + removeFrom("columns", id)} + /> + + {/* Filters */} +
+

{t("reports.pivot.filters")}

+ {filterFieldIds.length === 0 ? ( + + ) : ( +
+ {filterFieldIds.map((fieldId) => ( +
+
+ {fieldLabel(fieldId)} + +
+
+ {(filterValues[fieldId] || []).map((val) => { + const selected = (config.filters[fieldId] || []).includes(val); + const isActiveFilter = (config.filters[fieldId] || []).length > 0; + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ + {/* Values */} + removeFrom("values", id)} + accent + /> + + {/* Context menu */} + {menuTarget && ( +
+
{t("reports.pivot.addTo")}
+ {zoneOptions + .filter((opt) => (menuTarget.type === "measure") === opt.forMeasure) + .map((opt) => ( + + ))} +
+ )} +
+ ); +} + +function ZoneSection({ + label, + items, + getLabel, + onRemove, + accent, +}: { + label: string; + items: string[]; + getLabel: (id: string) => string; + onRemove: (id: string) => void; + accent?: boolean; +}) { + return ( +
+

{label}

+ {items.length === 0 ? ( + + ) : ( +
+ {items.map((id) => ( + + {getLabel(id)} + + + ))} +
+ )} +
+ ); +} diff --git a/src/components/reports/DynamicReportTable.tsx b/src/components/reports/DynamicReportTable.tsx new file mode 100644 index 0000000..4f76cc5 --- /dev/null +++ b/src/components/reports/DynamicReportTable.tsx @@ -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(); + 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> { + // colValue → measure → sum + const result: Record> = {}; + 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 ( +
+ {t("reports.pivot.noData")} +
+ ); + } + + 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 ( +
+ {rowDims.length > 1 && ( +
+ +
+ )} +
+
+ + + {/* Row dimension headers */} + {rowDims.map((dim) => ( + + ))} + {/* Column headers: colValue × measure */} + {colValues.map((colVal) => + measures.map((m) => ( + + )) + )} + + + + {rowDims.length === 0 ? ( + // No row dims — single row with totals + + {colValues.map((colVal) => + measures.map((m) => { + const val = grandTotals[colVal]?.[m] || 0; + return ( + + ); + }) + )} + + ) : ( + groups.map((group) => ( + + )) + )} + {/* Grand total */} + + + {colValues.map((colVal) => + measures.map((m) => ( + + )) + )} + + +
+ {fieldLabel(dim)} + + {colDim ? `${colVal} — ${measureLabel(m)}` : measureLabel(m)} +
+ {cadFormatter(val)} +
+ {t("reports.pivot.total")} + + {cadFormatter(grandTotals[colVal]?.[m] || 0)} +
+ + + ); +} + +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 ? ( + + + {group.label} + + {depth < rowDims.length - 1 && } + {colValues.map((colVal) => + measures.map((m) => ( + + {cadFormatter(subtotals[colVal]?.[m] || 0)} + + )) + )} + + ) : null; + + if (isLeafLevel) { + // Render leaf rows: one per unique combination of remaining keys + return ( + <> + {group.rows.map((row, i) => ( + + {rowDims.map((dim, di) => ( + + {di === depth ? row.keys[dim] || "" : di > depth ? row.keys[dim] || "" : ""} + + ))} + {colValues.map((colVal) => + measures.map((m) => { + const matchesCol = !colDim || row.keys[colDim] === colVal; + return ( + + {matchesCol ? cadFormatter(row.measures[m] || 0) : ""} + + ); + }) + )} + + ))} + + ); + } + + const childContent = group.children.map((child) => ( + + )); + + return ( + + {subtotalsOnTop && subtotalRow} + {childContent} + {!subtotalsOnTop && subtotalRow} + + ); +} diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 2dd8fc5..e8404e4 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -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 }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f07a8ab..f6fa186 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" ] } }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c96c8f7..f39cd49 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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" ] } }, diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 4c9586b..90d25c6 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -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>(new Set()); const [detailModal, setDetailModal] = useState(null); @@ -131,6 +132,15 @@ export default function ReportsPage() { {state.tab === "budgetVsActual" && ( )} + {state.tab === "dynamic" && ( + + )} {detailModal && ( = { + 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 { + 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>>(sql, params); + + // Build PivotResultRow array + const rows: PivotResultRow[] = rawRows.map((raw) => { + const keys: Record = {}; + for (const fieldId of allDimensions) { + keys[fieldId] = String(raw[FIELD_SQL[fieldId].alias] ?? ""); + } + const measures: Record = {}; + 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(); + 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 = { + 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 { + 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>( + `SELECT DISTINCT ${def.select} AS val FROM transactions t ${joinSQL} ${whereSQL} ORDER BY val`, + params, + ); + return rows.map((r) => r.val); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 79a5217..8c3b416 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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; // field → selected values (empty = all) + values: PivotMeasureId[]; +} + +export interface PivotResultRow { + keys: Record; // dimension values + measures: Record; // measure values +} + +export interface PivotResult { + rows: PivotResultRow[]; + columnValues: string[]; // distinct values for column dimension + dimensionLabels: Record; // field id → display label +} export interface MonthlyTrendItem { month: string; // "2025-01"