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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- Delete keywords from the "All Keywords" view
|
||||||
|
|
||||||
## [0.3.8]
|
## [0.3.8]
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ simpl-resultat/
|
||||||
│ │ ├── import/ # 13 composants (wizard d'import)
|
│ │ ├── import/ # 13 composants (wizard d'import)
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # 4 composants (graphiques)
|
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
||||||
│ │ ├── settings/ # 2 composants
|
│ │ ├── settings/ # 2 composants
|
||||||
│ │ ├── shared/ # 4 composants réutilisables
|
│ │ ├── shared/ # 4 composants réutilisables
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── 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)
|
- 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)
|
- 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
|
- 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
|
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
- 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
|
- 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
|
- 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
|
## 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,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
BudgetVsActualRow,
|
BudgetVsActualRow,
|
||||||
|
PivotConfig,
|
||||||
|
PivotResult,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||||
import { getExpensesByCategory } from "../services/dashboardService";
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
|
|
||||||
|
|
@ -22,6 +24,8 @@ interface ReportsState {
|
||||||
budgetYear: number;
|
budgetYear: number;
|
||||||
budgetMonth: number;
|
budgetMonth: number;
|
||||||
budgetVsActual: BudgetVsActualRow[];
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
|
pivotConfig: PivotConfig;
|
||||||
|
pivotResult: PivotResult;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +40,8 @@ type ReportsAction =
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
| { 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 } };
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -53,6 +59,8 @@ const initialState: ReportsState = {
|
||||||
budgetYear: now.getFullYear(),
|
budgetYear: now.getFullYear(),
|
||||||
budgetMonth: now.getMonth() + 1,
|
budgetMonth: now.getMonth() + 1,
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
|
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||||
|
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -77,6 +85,10 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
case "SET_BUDGET_VS_ACTUAL":
|
case "SET_BUDGET_VS_ACTUAL":
|
||||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
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":
|
case "SET_CUSTOM_DATES":
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
default:
|
default:
|
||||||
|
|
@ -136,6 +148,7 @@ export function useReports() {
|
||||||
budgetMonth: number,
|
budgetMonth: number,
|
||||||
customFrom?: string,
|
customFrom?: string,
|
||||||
customTo?: string,
|
customTo?: string,
|
||||||
|
pivotCfg?: PivotConfig,
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -170,6 +183,17 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||||
break;
|
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) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
|
@ -181,8 +205,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo);
|
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, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -209,5 +233,9 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
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",
|
"pctVar": "% Var",
|
||||||
"noData": "No budget or transaction data for this period."
|
"noData": "No budget or transaction data for this period."
|
||||||
},
|
},
|
||||||
|
"dynamic": "Dynamic Report",
|
||||||
"export": "Export",
|
"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": {
|
"help": {
|
||||||
"title": "How to use Reports",
|
"title": "How to use Reports",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Switch between Trends, By Category, and Over Time views using the tabs",
|
"Switch between Trends, By Category, and Over Time views using the tabs",
|
||||||
"Use the period selector to adjust the time range for all charts",
|
"Use the period selector to adjust the time range for all charts",
|
||||||
"Monthly Trends shows your income and expenses over time",
|
"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",
|
"pctVar": "% \u00c9cart",
|
||||||
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
|
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode."
|
||||||
},
|
},
|
||||||
|
"dynamic": "Rapport dynamique",
|
||||||
"export": "Exporter",
|
"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": {
|
"help": {
|
||||||
"title": "Comment utiliser les Rapports",
|
"title": "Comment utiliser les Rapports",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
|
"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",
|
"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",
|
"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 CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
|
import DynamicReport from "../components/reports/DynamicReport";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
|
|
||||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
|
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
||||||
|
|
||||||
function computeDateRange(
|
function computeDateRange(
|
||||||
period: DashboardPeriod,
|
period: DashboardPeriod,
|
||||||
|
|
@ -41,7 +42,7 @@ function computeDateRange(
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
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 [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -131,6 +132,15 @@ export default function ReportsPage() {
|
||||||
{state.tab === "budgetVsActual" && (
|
{state.tab === "budgetVsActual" && (
|
||||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||||
)}
|
)}
|
||||||
|
{state.tab === "dynamic" && (
|
||||||
|
<DynamicReport
|
||||||
|
config={state.pivotConfig}
|
||||||
|
result={state.pivotResult}
|
||||||
|
onConfigChange={setPivotConfig}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<TransactionDetailModal
|
<TransactionDetailModal
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import type {
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
CategoryOverTimeItem,
|
CategoryOverTimeItem,
|
||||||
|
PivotConfig,
|
||||||
|
PivotFieldId,
|
||||||
|
PivotResult,
|
||||||
|
PivotResultRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
export async function getMonthlyTrends(
|
export async function getMonthlyTrends(
|
||||||
|
|
@ -147,3 +151,206 @@ export async function getCategoryOverTime(
|
||||||
categoryIds,
|
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 ---
|
// --- 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 {
|
export interface MonthlyTrendItem {
|
||||||
month: string; // "2025-01"
|
month: string; // "2025-01"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue