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:
parent
2436f78023
commit
20b3a54ec7
13 changed files with 1084 additions and 10 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
src/components/reports/DynamicReport.tsx
Normal file
82
src/components/reports/DynamicReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/reports/DynamicReportChart.tsx
Normal file
135
src/components/reports/DynamicReportChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/components/reports/DynamicReportPanel.tsx
Normal file
264
src/components/reports/DynamicReportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
src/components/reports/DynamicReportTable.tsx
Normal file
259
src/components/reports/DynamicReportTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue