diff --git a/src/App.tsx b/src/App.tsx index 3288074..436ae75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/reports/DynamicReport.tsx b/src/components/reports/DynamicReport.tsx deleted file mode 100644 index 3cd947b..0000000 --- a/src/components/reports/DynamicReport.tsx +++ /dev/null @@ -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("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: , label: t("reports.pivot.viewTable") }, - { mode: "chart", icon: , label: t("reports.pivot.viewChart") }, - { mode: "both", icon: , label: t("reports.pivot.viewBoth") }, - ]; - - return ( -
-
- {/* Content area */} -
- {/* Toolbar */} -
- {hasConfig && viewButtons.map(({ mode, icon, label }) => ( - - ))} -
- -
- - {/* Empty state */} - {!hasConfig && ( -
- {t("reports.pivot.noConfig")} -
- )} - - {/* Table */} - {hasConfig && (viewMode === "table" || viewMode === "both") && ( - - )} - - {/* Chart */} - {hasConfig && (viewMode === "chart" || viewMode === "both") && ( - - )} -
- - {/* Side panel */} - -
-
- ); -} diff --git a/src/components/reports/DynamicReportChart.tsx b/src/components/reports/DynamicReportChart.tsx deleted file mode 100644 index 0d25bb7..0000000 --- a/src/components/reports/DynamicReportChart.tsx +++ /dev/null @@ -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 = { 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 = {}; - seriesVals.forEach((sv, i) => { - colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length]; - }); - - return { chartData: data, seriesKeys: seriesVals, seriesColors: colors }; - }, [config, result]); - - if (chartData.length === 0) { - return ( -
-

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

-
- ); - } - - const categoryEntries = seriesKeys.map((key, index) => ({ - color: seriesColors[key], - index, - })); - - return ( -
- - - - - - cadFormatter(v)} - tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} - stroke="var(--border)" - width={80} - /> - cadFormatter(value ?? 0)} - contentStyle={{ - backgroundColor: "var(--card)", - border: "1px solid var(--border)", - borderRadius: "8px", - color: "var(--foreground)", - }} - labelStyle={{ color: "var(--foreground)" }} - itemStyle={{ color: "var(--foreground)" }} - /> - - {seriesKeys.map((key, index) => ( - - ))} - - -
- ); -} diff --git a/src/components/reports/DynamicReportPanel.tsx b/src/components/reports/DynamicReportPanel.tsx deleted file mode 100644 index 4be2dea..0000000 --- a/src/components/reports/DynamicReportPanel.tsx +++ /dev/null @@ -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>({}); - const menuRef = useRef(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 = { - rows: t("reports.pivot.rows"), - columns: t("reports.pivot.columns"), - filters: t("reports.pivot.filters"), - values: t("reports.pivot.values"), - }; - - return ( -
- {/* Available Fields */} -
-

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

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

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

- {filterFieldIds.length === 0 ? ( - - ) : ( -
- {filterFieldIds.map((fieldId) => { - const entry = config.filters[fieldId] || { include: [], exclude: [] }; - const hasActive = entry.include.length > 0 || entry.exclude.length > 0; - return ( -
-
- {fieldLabel(fieldId)} - -
-
- {(filterValues[fieldId] || []).map((val) => { - const isIncluded = entry.include.includes(val); - const isExcluded = entry.exclude.includes(val); - return ( - - ); - })} -
-
- ); - })} -
- )} -
- - {/* Values */} - removeFrom("values", id)} - accent - /> - - {/* Context menu */} - {menuTarget && ( -
-
{t("reports.pivot.addTo")}
- {menuTarget.type === "measure" ? ( - - ) : ( - getAvailableZones(menuTarget.id).map((zone) => ( - - )) - )} -
- )} -
- ); -} - -function ZoneSection({ - label, - items, - getLabel, - onRemove, - accent, -}: { - label: string; - items: string[]; - getLabel: (id: string) => string; - onRemove: (id: string) => void; - accent?: boolean; -}) { - return ( -
-

{label}

- {items.length === 0 ? ( - - ) : ( -
- {items.map((id) => ( - - {getLabel(id)} - - - ))} -
- )} -
- ); -} diff --git a/src/components/reports/DynamicReportTable.tsx b/src/components/reports/DynamicReportTable.tsx deleted file mode 100644 index e956631..0000000 --- a/src/components/reports/DynamicReportTable.tsx +++ /dev/null @@ -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; // row-dimension values - cells: Record>; // 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(); - - 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 = {}; - 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(); - 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> { - const result: Record> = {}; - 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 ( -
- {t("reports.pivot.noData")} -
- ); - } - - 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 ( -
- {rowDims.length > 1 && ( -
- -
- )} -
-
- - - {rowDims.map((dim) => ( - - ))} - {colValues.map((colVal) => - measures.map((m) => ( - - )) - )} - - - - {rowDims.length === 0 ? ( - - {colValues.map((colVal) => - measures.map((m) => ( - - )) - )} - - ) : ( - groups.map((group) => ( - - )) - )} - {/* Grand total */} - - - {colValues.map((colVal) => - measures.map((m) => ( - - )) - )} - - -
- {fieldLabel(dim)} - - {colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)} -
- {cadFormatter(grandTotals[colVal]?.[m] || 0)} -
- {t("reports.pivot.total")} - - {cadFormatter(grandTotals[colVal]?.[m] || 0)} -
- - - ); -} - -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 ? ( - - - {group.label} - - {depth < rowDims.length - 1 && } - {colValues.map((colVal) => - measures.map((m) => ( - - {cadFormatter(subtotals[colVal]?.[m] || 0)} - - )) - )} - - ) : null; - - if (isLeafLevel) { - // Render one table row per pivoted row (already deduplicated by row keys) - return ( - <> - {group.pivotedRows.map((pRow, i) => ( - - {rowDims.map((dim, di) => ( - - {pRow.rowKeys[dim] || ""} - - ))} - {colValues.map((colVal) => - measures.map((m) => ( - - {cadFormatter(pRow.cells[colVal]?.[m] || 0)} - - )) - )} - - ))} - - ); - } - - const childContent = group.children.map((child) => ( - - )); - - return ( - - {subtotalsOnTop && subtotalRow} - {childContent} - {!subtotalsOnTop && subtotalRow} - - ); -} diff --git a/src/components/reports/MonthlyTrendsTable.tsx b/src/components/reports/MonthlyTrendsTable.tsx index bccdfce..7615ced 100644 --- a/src/components/reports/MonthlyTrendsTable.tsx +++ b/src/components/reports/MonthlyTrendsTable.tsx @@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) { - {t("reports.pivot.month")} + {t("reports.month")} {t("dashboard.income")} diff --git a/src/components/reports/Sparkline.tsx b/src/components/reports/Sparkline.tsx new file mode 100644 index 0000000..156f36f --- /dev/null +++ b/src/components/reports/Sparkline.tsx @@ -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
; + } + + const chartData = data.map((value, index) => ({ index, value })); + + return ( + + + + + + + ); +} diff --git a/src/components/reports/ViewModeToggle.test.ts b/src/components/reports/ViewModeToggle.test.ts new file mode 100644 index 0000000..1f6d2ba --- /dev/null +++ b/src/components/reports/ViewModeToggle.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { readViewMode } from "./ViewModeToggle"; + +describe("readViewMode", () => { + const store = new Map(); + 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"); + }); +}); diff --git a/src/components/reports/ViewModeToggle.tsx b/src/components/reports/ViewModeToggle.tsx new file mode 100644 index 0000000..7f0861d --- /dev/null +++ b/src/components/reports/ViewModeToggle.tsx @@ -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: , label: t("reports.viewMode.chart") }, + { mode: "table", icon: , label: t("reports.viewMode.table") }, + ]; + + return ( +
+ {options.map(({ mode, icon, label }) => ( + + ))} +
+ ); +} diff --git a/src/components/shared/ChartContextMenu.tsx b/src/components/shared/ChartContextMenu.tsx index aa74f13..1e6bc00 100644 --- a/src/components/shared/ChartContextMenu.tsx +++ b/src/components/shared/ChartContextMenu.tsx @@ -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(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 ( -
-
- {categoryName} -
- - -
+ , + label: t("charts.viewTransactions"), + onClick: onViewDetails, + }, + { + icon: , + label: t("charts.hideCategory"), + onClick: onHide, + }, + ]} + /> ); } diff --git a/src/components/shared/ContextMenu.tsx b/src/components/shared/ContextMenu.tsx new file mode 100644 index 0000000..d19f2cf --- /dev/null +++ b/src/components/shared/ContextMenu.tsx @@ -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(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 ( +
+ {header && ( +
+ {header} +
+ )} + {items.map((item, i) => ( + + ))} +
+ ); +} diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 5abaa64..0a2a54f 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -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 }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c6fad2c..2c41f21 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7ae66b4..d83a3d6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/ReportsCategoryPage.tsx b/src/pages/ReportsCategoryPage.tsx new file mode 100644 index 0000000..a3831fa --- /dev/null +++ b/src/pages/ReportsCategoryPage.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function ReportsCategoryPage() { + const { t } = useTranslation(); + return ( +
+

{t("reports.hub.categoryZoom")}

+

{t("common.underConstruction")}

+
+ ); +} diff --git a/src/pages/ReportsComparePage.tsx b/src/pages/ReportsComparePage.tsx new file mode 100644 index 0000000..92b2567 --- /dev/null +++ b/src/pages/ReportsComparePage.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function ReportsComparePage() { + const { t } = useTranslation(); + return ( +
+

{t("reports.hub.compare")}

+

{t("common.underConstruction")}

+
+ ); +} diff --git a/src/pages/ReportsHighlightsPage.tsx b/src/pages/ReportsHighlightsPage.tsx new file mode 100644 index 0000000..dbbc798 --- /dev/null +++ b/src/pages/ReportsHighlightsPage.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function ReportsHighlightsPage() { + const { t } = useTranslation(); + return ( +
+

{t("reports.hub.highlights")}

+

{t("common.underConstruction")}

+
+ ); +} diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index da4dc90..5931cde 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -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([]); useEffect(() => { @@ -127,8 +126,8 @@ export default function ReportsPage() { <>
{([ - { mode: "chart" as const, icon: , label: t("reports.pivot.viewChart") }, - { mode: "table" as const, icon:
, label: t("reports.pivot.viewTable") }, + { mode: "chart" as const, icon: , label: t("reports.viewMode.chart") }, + { mode: "table" as const, icon:
, label: t("reports.viewMode.table") }, ]).map(({ mode, icon, label }) => (