Merge pull request 'refactor: pivot removal + sub-route skeletons + shared components (#69)' (#88) from issue-69-foundation-pivot-removal into main

This commit is contained in:
maximus 2026-04-14 18:33:17 +00:00
commit a50be5caf6
21 changed files with 333 additions and 1228 deletions

View file

@ -10,6 +10,10 @@ import CategoriesPage from "./pages/CategoriesPage";
import AdjustmentsPage from "./pages/AdjustmentsPage";
import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage";
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import SettingsPage from "./pages/SettingsPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
@ -101,6 +105,10 @@ export default function App() {
<Route path="/adjustments" element={<AdjustmentsPage />} />
<Route path="/budget" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />

View file

@ -1,106 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Table, BarChart3, Columns, Maximize2, Minimize2 } 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;
}
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [fullscreen, setFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
// Escape key exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [fullscreen]);
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={
fullscreen
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
: ""
}
>
<div className="flex gap-4 items-start">
{/* Content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-1">
{hasConfig && 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 className="flex-1" />
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
>
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
</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}
/>
</div>
</div>
);
}

View file

@ -1,143 +0,0 @@
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 colDims = config.columns;
const rowDim = config.rows[0];
const measure = config.values[0] || "periodic";
// X-axis = composite column key (or first row dimension if no columns)
const hasColDims = colDims.length > 0;
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Build composite column key per row
const getColKey = (r: typeof result.rows[0]) =>
colDims.map((d) => r.keys[d] || "").join(" — ");
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = hasColDims ? rowDim : undefined;
// Collect unique x and series values
const xValues = hasColDims
? [...new Set(result.rows.map(getColKey))].sort()
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].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) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
);
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
} else {
const matchingRows = result.rows.filter((r) =>
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
return entry;
});
const colors: Record<string, string> = {};
seriesVals.forEach((sv, i) => {
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
});
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
}, [config, result]);
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
</div>
);
}
const categoryEntries = seriesKeys.map((key, index) => ({
color: seriesColors[key],
index,
}));
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="name"
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="stack"
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,306 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps {
config: PivotConfig;
onChange: (config: PivotConfig) => void;
}
export default function DynamicReportPanel({ config, onChange }: 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);
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
const inRows = new Set(config.rows);
const inColumns = new Set(config.columns);
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
const assignedFields = new Set(
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
);
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).then((vals) => {
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
});
}
}
}, [filterFieldIds.join(",")]);
// 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]: { include: [], exclude: [] } };
}
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 toggleFilterInclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isIncluded = entry.include.includes(value);
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
// Remove from exclude if adding to include
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const toggleFilterExclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isExcluded = entry.exclude.includes(value);
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
// Remove from include if adding to exclude
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
// Context menu only shows zones where the field is NOT already assigned
const getAvailableZones = (fieldId: string): PivotZone[] => {
const zones: PivotZone[] = [];
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
return zones;
};
const zoneLabels: Record<PivotZone, string> = {
rows: t("reports.pivot.rows"),
columns: t("reports.pivot.columns"),
filters: t("reports.pivot.filters"),
values: t("reports.pivot.values"),
};
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) => {
const entry = config.filters[fieldId] || { include: [], exclude: [] };
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
return (
<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 isIncluded = entry.include.includes(val);
const isExcluded = entry.exclude.includes(val);
return (
<button
key={val}
onClick={() => toggleFilterInclude(fieldId, val)}
onContextMenu={(e) => {
e.preventDefault();
toggleFilterExclude(fieldId, val);
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isIncluded
? "bg-[var(--primary)] text-white"
: isExcluded
? "bg-[var(--negative)] text-white line-through"
: hasActive
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
title={t("reports.pivot.rightClickExclude")}
>
{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>
{menuTarget.type === "measure" ? (
<button
onClick={() => assignTo("values")}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels.values}
</button>
) : (
getAvailableZones(menuTarget.id).map((zone) => (
<button
key={zone}
onClick={() => assignTo(zone)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels[zone]}
</button>
))
)}
</div>
)}
</div>
);
}
function ZoneSection({
label,
items,
getLabel,
onRemove,
accent,
}: {
label: string;
items: string[];
getLabel: (id: string) => string;
onRemove: (id: string) => void;
accent?: boolean;
}) {
return (
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
{items.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<span
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
accent
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{getLabel(id)}
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}

View file

@ -1,295 +0,0 @@
import { Fragment, useState, useMemo } 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;
}
/** A pivoted row: one per unique combination of row dimensions */
interface PivotedRow {
rowKeys: Record<string, string>; // row-dimension values
cells: Record<string, Record<string, number>>; // colValue → measure → value
}
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
function pivotRows(
rows: PivotResultRow[],
rowDims: string[],
colDims: string[],
measures: string[],
): PivotedRow[] {
const map = new Map<string, PivotedRow>();
for (const row of rows) {
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
let pivoted = map.get(rowKey);
if (!pivoted) {
const rowKeys: Record<string, string> = {};
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
pivoted = { rowKeys, cells: {} };
map.set(rowKey, pivoted);
}
const colKey = colDims.length > 0
? colDims.map((d) => row.keys[d] || "").join("\0")
: "__all__";
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
for (const m of measures) {
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
}
}
return Array.from(map.values());
}
interface GroupNode {
key: string;
label: string;
pivotedRows: PivotedRow[];
children: GroupNode[];
}
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
if (depth >= rowDims.length) return [];
const dim = rowDims[depth];
const map = new Map<string, PivotedRow[]>();
for (const row of rows) {
const key = row.rowKeys[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,
pivotedRows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1),
});
}
return groups;
}
function computeSubtotals(
rows: PivotedRow[],
measures: string[],
colValues: string[],
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const colVal of colValues) {
result[colVal] = {};
for (const m of measures) {
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 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;
});
};
const rowDims = config.rows;
const colDims = config.columns;
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
const measures = config.values;
// Display label for a composite column key (joined with \0)
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
const pivotedRows = useMemo(
() => pivotRows(result.rows, rowDims, colDims, measures),
[result.rows, rowDims, colDims, measures],
);
if (pivotedRows.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 groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
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 overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{fieldLabel(dim)}
</th>
))}
{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)] bg-[var(--card)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th>
))
)}
</tr>
</thead>
<tbody>
{rowDims.length === 0 ? (
<tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group.key}
group={group}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={0}
subtotalsOnTop={subtotalsOnTop}
/>
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
</tbody>
</table>
</div>
</div>
);
}
function GroupRows({
group,
colValues,
measures,
rowDims,
depth,
subtotalsOnTop,
}: {
group: GroupNode;
colValues: string[];
measures: string[];
rowDims: string[];
depth: number;
subtotalsOnTop: boolean;
}) {
const isLeafLevel = depth === rowDims.length - 1;
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
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 one table row per pivoted row (already deduplicated by row keys)
return (
<>
{group.pivotedRows.map((pRow, 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}
>
{pRow.rowKeys[dim] || ""}
</td>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
))}
</>
);
}
const childContent = group.children.map((child) => (
<GroupRows
key={child.key}
group={child}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={depth + 1}
subtotalsOnTop={subtotalsOnTop}
/>
));
return (
<Fragment>
{subtotalsOnTop && subtotalRow}
{childContent}
{!subtotalsOnTop && subtotalRow}
</Fragment>
);
}

View file

@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.pivot.month")}
{t("reports.month")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")}

View file

@ -0,0 +1,39 @@
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
export interface SparklineProps {
data: number[];
color?: string;
width?: number | `${number}%`;
height?: number;
strokeWidth?: number;
}
export default function Sparkline({
data,
color = "var(--primary)",
width = "100%",
height = 32,
strokeWidth = 1.5,
}: SparklineProps) {
if (data.length === 0) {
return <div style={{ width, height }} />;
}
const chartData = data.map((value, index) => ({ index, value }));
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={strokeWidth}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readViewMode } from "./ViewModeToggle";
describe("readViewMode", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("returns fallback when key is missing", () => {
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'chart' when stored value is 'chart'", () => {
store.set("reports-viewmode-highlights", "chart");
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'table' when stored value is 'table'", () => {
store.set("reports-viewmode-highlights", "table");
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
});
it("ignores invalid stored values and returns fallback", () => {
store.set("reports-viewmode-highlights", "bogus");
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
});
it("respects custom fallback when provided", () => {
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
});
});

View file

@ -0,0 +1,52 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Table } from "lucide-react";
export type ViewMode = "chart" | "table";
export interface ViewModeToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
/** localStorage key used to persist the preference per section. */
storageKey?: string;
}
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "chart" || saved === "table" ? saved : fallback;
}
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
{options.map(({ mode, icon, label }) => (
<button
key={mode}
type="button"
onClick={() => onChange(mode)}
aria-pressed={value === mode}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EyeOff, List } from "lucide-react";
import ContextMenu from "./ContextMenu";
export interface ChartContextMenuProps {
x: number;
@ -20,60 +20,25 @@ export default function ChartContextMenu({
onClose,
}: ChartContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{categoryName}
</div>
<button
onClick={() => { onViewDetails(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<List size={14} />
{t("charts.viewTransactions")}
</button>
<button
onClick={() => { onHide(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<EyeOff size={14} />
{t("charts.hideCategory")}
</button>
</div>
<ContextMenu
x={x}
y={y}
header={categoryName}
onClose={onClose}
items={[
{
icon: <List size={14} />,
label: t("charts.viewTransactions"),
onClick: onViewDetails,
},
{
icon: <EyeOff size={14} />,
label: t("charts.hideCategory"),
onClick: onHide,
},
]}
/>
);
}

View file

@ -0,0 +1,77 @@
import { useEffect, useRef, type ReactNode } from "react";
export interface ContextMenuItem {
icon?: ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
header?: ReactNode;
items: ContextMenuItem[];
onClose: () => void;
}
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
{header && (
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{header}
</div>
)}
{items.map((item, i) => (
<button
key={i}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}

View file

@ -6,10 +6,8 @@ import type {
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
PivotConfig,
PivotResult,
} from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
@ -29,8 +27,6 @@ interface ReportsState {
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
pivotConfig: PivotConfig;
pivotResult: PivotResult;
isLoading: boolean;
error: string | null;
}
@ -45,8 +41,6 @@ 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 } }
| { type: "SET_SOURCE_ID"; payload: number | null }
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
@ -68,8 +62,6 @@ const initialState: ReportsState = {
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
isLoading: false,
error: null,
};
@ -94,10 +86,6 @@ 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 };
case "SET_SOURCE_ID":
@ -120,7 +108,6 @@ export function useReports() {
budgetMonth: number,
customFrom?: string,
customTo?: string,
pivotCfg?: PivotConfig,
srcId?: number | null,
catType?: CategoryTypeFilter,
) => {
@ -157,16 +144,6 @@ 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 data = await getDynamicReportData(pivotCfg);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
break;
}
}
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
@ -178,8 +155,8 @@ export function useReports() {
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
@ -197,10 +174,6 @@ export function useReports() {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
const setPivotConfig = useCallback((config: PivotConfig) => {
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
}, []);
const setSourceId = useCallback((id: number | null) => {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
@ -209,5 +182,5 @@ export function useReports() {
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
}, []);
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType };
}

View file

@ -381,33 +381,23 @@
"noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
},
"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)",
"level3": "Category (Level 3)",
"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",
"fullscreen": "Full screen",
"exitFullscreen": "Exit full screen",
"rightClickExclude": "Right-click to exclude"
"month": "Month",
"viewMode": {
"chart": "Chart",
"table": "Table"
},
"hub": {
"title": "Reports",
"explore": "Explore",
"highlights": "Highlights",
"trends": "Trends",
"compare": "Compare",
"categoryZoom": "Category Analysis"
},
"empty": {
"noData": "No data for this period",
"importCta": "Import a statement"
},
"help": {
"title": "How to use Reports",
@ -415,8 +405,7 @@
"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",
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
"Category Over Time tracks how spending in each category evolves"
]
}
},
@ -745,7 +734,6 @@
"Expenses by Category: spending breakdown (pie chart)",
"Category Over Time: track how each category evolves (line chart)",
"Budget vs Actual: monthly and year-to-date comparison table",
"Dynamic Report: customizable pivot table",
"SVG patterns (lines, dots, crosshatch) to distinguish categories",
"Context menu (right-click) to hide a category or view its transactions",
"Transaction detail by category with sortable columns (date, description, amount)",
@ -848,7 +836,8 @@
"total": "Total",
"darkMode": "Dark mode",
"lightMode": "Light mode",
"close": "Close"
"close": "Close",
"underConstruction": "Under construction"
},
"license": {
"title": "License",

View file

@ -381,33 +381,23 @@
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
"titlePrefix": "Budget vs Réel pour le mois de"
},
"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)",
"level3": "Catégorie (Niveau 3)",
"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",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter plein écran",
"rightClickExclude": "Clic-droit pour exclure"
"month": "Mois",
"viewMode": {
"chart": "Graphique",
"table": "Tableau"
},
"hub": {
"title": "Rapports",
"explore": "Explorer",
"highlights": "Faits saillants",
"trends": "Tendances",
"compare": "Comparables",
"categoryZoom": "Analyse par catégorie"
},
"empty": {
"noData": "Aucune donnée pour cette période",
"importCta": "Importer un relevé"
},
"help": {
"title": "Comment utiliser les Rapports",
@ -415,8 +405,7 @@
"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",
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
]
}
},
@ -745,7 +734,6 @@
"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",
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
@ -848,7 +836,8 @@
"total": "Total",
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"close": "Fermer"
"close": "Fermer",
"underConstruction": "En construction"
},
"license": {
"title": "Licence",

View file

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export default function ReportsCategoryPage() {
const { t } = useTranslation();
return (
<div className="p-8 text-center text-[var(--muted-foreground)]">
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.categoryZoom")}</h1>
<p>{t("common.underConstruction")}</p>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export default function ReportsComparePage() {
const { t } = useTranslation();
return (
<div className="p-8 text-center text-[var(--muted-foreground)]">
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.compare")}</h1>
<p>{t("common.underConstruction")}</p>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export default function ReportsHighlightsPage() {
const { t } = useTranslation();
return (
<div className="p-8 text-center text-[var(--muted-foreground)]">
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.highlights")}</h1>
<p>{t("common.underConstruction")}</p>
</div>
);
}

View file

@ -13,16 +13,15 @@ import CategoryTable from "../components/reports/CategoryTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import DynamicReport from "../components/reports/DynamicReport";
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
export default function ReportsPage() {
const { t, i18n } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType } = useReports();
const [sources, setSources] = useState<ImportSource[]>([]);
useEffect(() => {
@ -127,8 +126,8 @@ export default function ReportsPage() {
<>
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
{([
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.viewMode.table") },
]).map(({ mode, icon, label }) => (
<button
key={mode}
@ -219,13 +218,6 @@ export default function ReportsPage() {
{state.tab === "budgetVsActual" && (
<BudgetVsActualTable data={state.budgetVsActual} />
)}
{state.tab === "dynamic" && (
<DynamicReport
config={state.pivotConfig}
result={state.pivotResult}
onConfigChange={setPivotConfig}
/>
)}
</div>
{showFilterPanel && (
<ReportFilterPanel

View file

@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export default function ReportsTrendsPage() {
const { t } = useTranslation();
return (
<div className="p-8 text-center text-[var(--muted-foreground)]">
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.trends")}</h1>
<p>{t("common.underConstruction")}</p>
</div>
);
}

View file

@ -4,10 +4,6 @@ import type {
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
PivotConfig,
PivotFieldId,
PivotResult,
PivotResultRow,
} from "../shared/types";
export async function getMonthlyTrends(
@ -170,189 +166,3 @@ 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(grandparent_cat.name, parent_cat.name, c.name, 'Uncategorized')", alias: "level1" },
level2: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN parent_cat.name WHEN parent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level2" },
level3: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level3" },
};
function needsCategoryJoin(fields: PivotFieldId[]): boolean {
return fields.some((f) => f === "type" || f === "level1" || f === "level2" || f === "level3");
}
export async function getDynamicReportData(
config: PivotConfig,
): 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;
// Apply filter values (include / exclude)
for (const fieldId of filterFields) {
const entry = config.filters[fieldId];
if (!entry) continue;
const def = FIELD_SQL[fieldId as PivotFieldId];
if (entry.include && entry.include.length > 0) {
const placeholders = entry.include.map(() => {
const p = `$${paramIndex}`;
paramIndex++;
return p;
});
whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`);
params.push(...entry.include);
}
if (entry.exclude && entry.exclude.length > 0) {
const placeholders = entry.exclude.map(() => {
const p = `$${paramIndex}`;
paramIndex++;
return p;
});
whereClauses.push(`${def.select} NOT IN (${placeholders.join(", ")})`);
params.push(...entry.exclude);
}
}
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
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_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 (composite key when multiple column dimensions)
const colDims = config.columns;
const columnValues = colDims.length > 0
? [...new Set(rows.map((r) => colDims.map((d) => r.keys[d] || "").join("\0")))].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)",
level3: "Catégorie (Niveau 3)",
periodic: "Montant périodique",
ytd: "Cumul annuel (YTD)",
};
return { rows, columnValues, dimensionLabels };
}
export async function getDynamicFilterValues(
fieldId: PivotFieldId,
): Promise<string[]> {
const db = await getDb();
const def = FIELD_SQL[fieldId];
const useCatJoin = needsCategoryJoin([fieldId]);
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
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id`
: "";
const rows = await db.select<Array<{ val: string }>>(
`SELECT DISTINCT ${def.select} AS val FROM transactions t ${joinSQL} ORDER BY val`,
[],
);
return rows.map((r) => r.val);
}

View file

@ -276,36 +276,7 @@ export interface RecentTransaction {
// --- Report Types ---
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual" | "dynamic";
// --- Pivot / Dynamic Report Types ---
export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2" | "level3";
export type PivotMeasureId = "periodic" | "ytd";
export type PivotZone = "rows" | "columns" | "filters" | "values";
export interface PivotFilterEntry {
include: string[]; // included values (empty = all)
exclude: string[]; // excluded values
}
export interface PivotConfig {
rows: PivotFieldId[];
columns: PivotFieldId[];
filters: Record<string, PivotFilterEntry>; // field → include/exclude entries
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 type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
export interface MonthlyTrendItem {
month: string; // "2025-01"