diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c44c5..4320f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [0.6.0] + +### Added +- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs +- Reports: "Show amounts" toggle displays values directly on chart bars and area curves +- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown +- Reports: source filter applies at SQL level for accurate filtered totals +- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual) +- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category +- Reports: legend hover highlights category across all months (Over Time chart) + +### Fixed +- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7) + ## [0.5.0] ### Added diff --git a/package.json b/package.json index 4be4b4d..3c64359 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.5.0", + "version": "0.6.0", "license": "GPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03102e5..572add8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Simpl Resultat", - "version": "0.5.2", + "version": "0.6.0", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index 2237e44..86c214b 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -142,43 +142,43 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")} -
+
- - - + + - - - - + - - - - - - - diff --git a/src/components/reports/CategoryBarChart.tsx b/src/components/reports/CategoryBarChart.tsx index c319299..2b7da86 100644 --- a/src/components/reports/CategoryBarChart.tsx +++ b/src/components/reports/CategoryBarChart.tsx @@ -8,6 +8,7 @@ import { Tooltip, ResponsiveContainer, Cell, + LabelList, } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryBreakdownItem } from "../../shared/types"; @@ -23,6 +24,7 @@ interface CategoryBarChartProps { onToggleHidden: (categoryName: string) => void; onShowAll: () => void; onViewDetails: (item: CategoryBreakdownItem) => void; + showAmounts?: boolean; } export default function CategoryBarChart({ @@ -31,9 +33,11 @@ export default function CategoryBarChart({ onToggleHidden, onShowAll, onViewDetails, + showAmounts, }: CategoryBarChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); + const [hoveredIndex, setHoveredIndex] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); @@ -112,11 +116,21 @@ export default function CategoryBarChart({ { hoveredRef.current = item; }} - onMouseLeave={() => { hoveredRef.current = null; }} + fillOpacity={hoveredIndex === null || hoveredIndex === index ? 1 : 0.3} + onMouseEnter={() => { hoveredRef.current = item; setHoveredIndex(index); }} + onMouseLeave={() => { hoveredRef.current = null; setHoveredIndex(null); }} cursor="context-menu" + style={{ transition: "fill-opacity 150ms" }} /> ))} + {showAmounts && ( + cadFormatter(Number(v))} + style={{ fill: "var(--foreground)", fontSize: 11 }} + /> + )} diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx index 8e4d728..a4024b2 100644 --- a/src/components/reports/CategoryOverTimeChart.tsx +++ b/src/components/reports/CategoryOverTimeChart.tsx @@ -9,6 +9,7 @@ import { ResponsiveContainer, Legend, CartesianGrid, + LabelList, } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types"; @@ -30,6 +31,7 @@ interface CategoryOverTimeChartProps { onToggleHidden: (categoryName: string) => void; onShowAll: () => void; onViewDetails: (item: CategoryBreakdownItem) => void; + showAmounts?: boolean; } export default function CategoryOverTimeChart({ @@ -38,9 +40,11 @@ export default function CategoryOverTimeChart({ onToggleHidden, onShowAll, onViewDetails, + showAmounts, }: CategoryOverTimeChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); + const [hoveredCategory, setHoveredCategory] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name)); @@ -109,7 +113,10 @@ export default function CategoryOverTimeChart({ width={80} /> cadFormatter(value ?? 0)} + formatter={(value: unknown, name: unknown) => { + if (hoveredCategory && name !== hoveredCategory) return [null, null]; + return [cadFormatter(Number(value) || 0), String(name)]; + }} labelFormatter={(label) => formatMonth(String(label))} contentStyle={{ backgroundColor: "var(--card)", @@ -119,18 +126,36 @@ export default function CategoryOverTimeChart({ }} labelStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }} + filterNull + /> + { + if (e && e.dataKey) setHoveredCategory(String(e.dataKey)); + }} + onMouseLeave={() => setHoveredCategory(null)} + wrapperStyle={{ cursor: "pointer" }} /> - {categoryEntries.map((c) => ( { hoveredRef.current = c.name; }} - onMouseLeave={() => { hoveredRef.current = null; }} + fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2} + onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }} + onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} cursor="context-menu" - /> + style={{ transition: "fill-opacity 150ms" }} + > + {showAmounts && ( + Number(v) ? cadFormatter(Number(v)) : ""} + style={{ fill: "#fff", fontSize: 10, fontWeight: 600, textShadow: "0 1px 2px rgba(0,0,0,0.5)" }} + /> + )} + ))} diff --git a/src/components/reports/CategoryOverTimeTable.tsx b/src/components/reports/CategoryOverTimeTable.tsx new file mode 100644 index 0000000..bb18bfc --- /dev/null +++ b/src/components/reports/CategoryOverTimeTable.tsx @@ -0,0 +1,111 @@ +import { useTranslation } from "react-i18next"; +import type { CategoryOverTimeData } from "../../shared/types"; + +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); + +function formatMonth(month: string): string { + const [year, m] = month.split("-"); + const date = new Date(Number(year), Number(m) - 1); + return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); +} + +interface CategoryOverTimeTableProps { + data: CategoryOverTimeData; + hiddenCategories?: Set; +} + +export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) { + const { t } = useTranslation(); + + if (data.data.length === 0) { + return ( +
+ {t("dashboard.noData")} +
+ ); + } + + const visibleCategories = hiddenCategories?.size + ? data.categories.filter((name) => !hiddenCategories.has(name)) + : data.categories; + + const months = data.data.map((d) => d.month); + + return ( +
+
+
+
{t("budget.category")} + {t("reports.bva.monthly")} + {t("reports.bva.ytd")}
+
{t("budget.actual")} + {t("budget.planned")} + {t("reports.bva.dollarVar")} + {t("reports.bva.pctVar")} + {t("budget.actual")} + {t("budget.planned")} + {t("reports.bva.dollarVar")} + {t("reports.bva.pctVar")}
+ + + + {months.map((month) => ( + + ))} + + + + + {visibleCategories.map((category) => { + const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record)[category] as number || 0), 0); + return ( + + + {months.map((month) => { + const monthData = data.data.find((d) => d.month === month); + const value = (monthData as Record)?.[category] as number || 0; + return ( + + ); + })} + + + ); + })} + + + {months.map((month) => { + const monthData = data.data.find((d) => d.month === month); + const monthTotal = visibleCategories.reduce( + (sum, cat) => sum + ((monthData as Record)?.[cat] as number || 0), + 0, + ); + return ( + + ); + })} + + + +
+ {t("budget.category")} + + {formatMonth(month)} + + {t("common.total")} +
+ + + {category} + + + {value ? cadFormatter(value) : "—"} + + {cadFormatter(rowTotal)} +
{t("common.total")} + {cadFormatter(monthTotal)} + + {cadFormatter( + visibleCategories.reduce( + (sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record)[cat] as number || 0), 0), + 0, + ), + )} +
+
+
+ ); +} diff --git a/src/components/reports/CategoryTable.tsx b/src/components/reports/CategoryTable.tsx new file mode 100644 index 0000000..e66e5fc --- /dev/null +++ b/src/components/reports/CategoryTable.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from "react-i18next"; +import type { CategoryBreakdownItem } from "../../shared/types"; + +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); + +interface CategoryTableProps { + data: CategoryBreakdownItem[]; + hiddenCategories?: Set; +} + +export default function CategoryTable({ data, hiddenCategories }: CategoryTableProps) { + const { t } = useTranslation(); + + const visibleData = hiddenCategories?.size + ? data.filter((d) => !hiddenCategories.has(d.category_name)) + : data; + + if (visibleData.length === 0) { + return ( +
+ {t("dashboard.noData")} +
+ ); + } + + const grandTotal = visibleData.reduce((sum, row) => sum + row.total, 0); + + return ( +
+
+ + + + + + + + + + {visibleData.map((row) => ( + + + + + + ))} + + + + + + +
+ {t("budget.category")} + + {t("common.total")} + + % +
+ + + {row.category_name} + + {cadFormatter(row.total)} + {grandTotal !== 0 ? `${((row.total / grandTotal) * 100).toFixed(1)}%` : "—"} +
{t("common.total")}{cadFormatter(grandTotal)}100%
+
+
+ ); +} diff --git a/src/components/reports/DynamicReportTable.tsx b/src/components/reports/DynamicReportTable.tsx index 2e1e742..dc77588 100644 --- a/src/components/reports/DynamicReportTable.tsx +++ b/src/components/reports/DynamicReportTable.tsx @@ -149,18 +149,18 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl )} -
+
- - + + {rowDims.map((dim) => ( - ))} {colValues.map((colVal) => measures.map((m) => ( - )) diff --git a/src/components/reports/MonthlyTrendsChart.tsx b/src/components/reports/MonthlyTrendsChart.tsx index 1b2f9ac..55424f0 100644 --- a/src/components/reports/MonthlyTrendsChart.tsx +++ b/src/components/reports/MonthlyTrendsChart.tsx @@ -7,6 +7,7 @@ import { Tooltip, ResponsiveContainer, CartesianGrid, + LabelList, } from "recharts"; import type { MonthlyTrendItem } from "../../shared/types"; @@ -21,9 +22,10 @@ function formatMonth(month: string): string { interface MonthlyTrendsChartProps { data: MonthlyTrendItem[]; + showAmounts?: boolean; } -export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) { +export default function MonthlyTrendsChart({ data, showAmounts }: MonthlyTrendsChartProps) { const { t } = useTranslation(); if (data.length === 0) { @@ -80,7 +82,16 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) { stroke="var(--positive)" fill="url(#gradientIncome)" strokeWidth={2} - /> + > + {showAmounts && ( + cadFormatter(Number(v))} + style={{ fill: "var(--positive)", fontSize: 10, fontWeight: 600 }} + /> + )} + + > + {showAmounts && ( + cadFormatter(Number(v))} + style={{ fill: "var(--negative)", fontSize: 10, fontWeight: 600 }} + /> + )} + diff --git a/src/components/reports/MonthlyTrendsTable.tsx b/src/components/reports/MonthlyTrendsTable.tsx new file mode 100644 index 0000000..8655e4b --- /dev/null +++ b/src/components/reports/MonthlyTrendsTable.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import type { MonthlyTrendItem } from "../../shared/types"; + +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); + +function formatMonth(month: string): string { + const [year, m] = month.split("-"); + const date = new Date(Number(year), Number(m) - 1); + return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); +} + +interface MonthlyTrendsTableProps { + data: MonthlyTrendItem[]; +} + +export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) { + const { t } = useTranslation(); + + if (data.length === 0) { + return ( +
+ {t("dashboard.noData")} +
+ ); + } + + const totals = data.reduce( + (acc, row) => ({ income: acc.income + row.income, expenses: acc.expenses + row.expenses }), + { income: 0, expenses: 0 }, + ); + + return ( +
+
+
+ {fieldLabel(dim)} + {colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
+ + + + + + + + + + {data.map((row) => ( + + + + + + + ))} + + + + + + + +
+ {t("reports.pivot.month")} + + {t("dashboard.income")} + + {t("dashboard.expenses")} + + {t("dashboard.net")} +
{formatMonth(row.month)}{cadFormatter(row.income)}{cadFormatter(row.expenses)}= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}> + {cadFormatter(row.income - row.expenses)} +
{t("common.total")}{cadFormatter(totals.income)}{cadFormatter(totals.expenses)}= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}> + {cadFormatter(totals.income - totals.expenses)} +
+
+
+ ); +} diff --git a/src/components/reports/ReportFilterPanel.tsx b/src/components/reports/ReportFilterPanel.tsx new file mode 100644 index 0000000..ec0aea0 --- /dev/null +++ b/src/components/reports/ReportFilterPanel.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Filter, Search } from "lucide-react"; +import type { ImportSource } from "../../shared/types"; + +interface ReportFilterPanelProps { + categories: { name: string; color: string }[]; + hiddenCategories: Set; + onToggleHidden: (name: string) => void; + onShowAll: () => void; + sources: ImportSource[]; + selectedSourceId: number | null; + onSourceChange: (id: number | null) => void; +} + +export default function ReportFilterPanel({ + categories, + hiddenCategories, + onToggleHidden, + onShowAll, + sources, + selectedSourceId, + onSourceChange, +}: ReportFilterPanelProps) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const [collapsed, setCollapsed] = useState(false); + + const filtered = search + ? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase())) + : categories; + + const allVisible = hiddenCategories.size === 0; + const allHidden = hiddenCategories.size === categories.length; + + return ( +
+ {/* Source filter */} + {sources.length > 1 && ( +
+
+ + {t("transactions.table.source")} +
+
+ +
+
+ )} + + {/* Category filter */} + {categories.length > 0 &&
+ + + {!collapsed && ( +
+
+
+ + setSearch(e.target.value)} + placeholder={t("reports.filters.search")} + className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> +
+
+ +
+ + +
+ +
+ {filtered.map((cat) => { + const visible = !hiddenCategories.has(cat.name); + return ( + + ); + })} +
+
+ )} +
} +
+ ); +} diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index d957ec1..56ac9c3 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -196,7 +196,7 @@ export default function TransactionTable({ onClick={() => toggleNotes(row)} className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${ row.notes - ? "text-[var(--primary)]" + ? "text-orange-500" : "text-[var(--muted-foreground)]" }`} title={t("transactions.notes.placeholder")} diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 7973c66..16ae339 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -18,6 +18,7 @@ interface ReportsState { period: DashboardPeriod; customDateFrom: string; customDateTo: string; + sourceId: number | null; monthlyTrends: MonthlyTrendItem[]; categorySpending: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; @@ -42,7 +43,8 @@ type ReportsAction = | { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] } | { type: "SET_PIVOT_CONFIG"; payload: PivotConfig } | { type: "SET_PIVOT_RESULT"; payload: PivotResult } - | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }; + | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } } + | { type: "SET_SOURCE_ID"; payload: number | null }; const now = new Date(); const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; @@ -53,6 +55,7 @@ const initialState: ReportsState = { period: "6months", customDateFrom: monthStartStr, customDateTo: todayStr, + sourceId: null, monthlyTrends: [], categorySpending: [], categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, @@ -91,6 +94,8 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState { 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": + return { ...state, sourceId: action.payload }; default: return state; } @@ -152,6 +157,7 @@ export function useReports() { customFrom?: string, customTo?: string, pivotCfg?: PivotConfig, + srcId?: number | null, ) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); @@ -161,21 +167,21 @@ export function useReports() { switch (tab) { case "trends": { const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getMonthlyTrends(dateFrom, dateTo); + const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); break; } case "byCategory": { const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getExpensesByCategory(dateFrom, dateTo); + const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); break; } case "overTime": { const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getCategoryOverTime(dateFrom, dateTo); + const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); break; @@ -207,8 +213,8 @@ export function useReports() { }, []); useEffect(() => { - fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig); - }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, fetchData]); + fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId); + }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]); const setTab = useCallback((tab: ReportTab) => { dispatch({ type: "SET_TAB", payload: tab }); @@ -239,5 +245,9 @@ export function useReports() { dispatch({ type: "SET_PIVOT_CONFIG", payload: config }); }, []); - return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig }; + const setSourceId = useCallback((id: number | null) => { + dispatch({ type: "SET_SOURCE_ID", payload: id }); + }, []); + + return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ea6ce81..5414caa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -17,6 +17,7 @@ "balance": "Balance", "income": "Income", "expenses": "Expenses", + "net": "Net", "noData": "No data available. Start by importing your bank statements.", "expensesByCategory": "Expenses by Category", "recentTransactions": "Recent Transactions", @@ -353,6 +354,12 @@ "showAmounts": "Show amounts", "hideAmounts": "Hide amounts" }, + "filters": { + "title": "Categories", + "search": "Search...", + "all": "All", + "none": "None" + }, "bva": { "monthly": "Monthly", "ytd": "Year-to-Date", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 91310c1..f02ecd5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -17,6 +17,7 @@ "balance": "Solde", "income": "Revenus", "expenses": "Dépenses", + "net": "Net", "noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.", "expensesByCategory": "Dépenses par catégorie", "recentTransactions": "Transactions récentes", @@ -353,6 +354,12 @@ "showAmounts": "Afficher les montants", "hideAmounts": "Masquer les montants" }, + "filters": { + "title": "Catégories", + "search": "Rechercher...", + "all": "Toutes", + "none": "Aucune" + }, "bva": { "monthly": "Mensuel", "ytd": "Cumul annuel", diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 1d74a7b..906a193 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -1,15 +1,21 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { Hash, Table, BarChart3 } from "lucide-react"; import { useReports } from "../hooks/useReports"; import { PageHelp } from "../components/shared/PageHelp"; -import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types"; +import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types"; +import { getAllSources } from "../services/importSourceService"; import PeriodSelector from "../components/dashboard/PeriodSelector"; import MonthNavigator from "../components/budget/MonthNavigator"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; +import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import CategoryBarChart from "../components/reports/CategoryBarChart"; +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"; const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"]; @@ -43,10 +49,19 @@ function computeDateRange( export default function ReportsPage() { const { t } = useTranslation(); - const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig } = useReports(); + const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId } = useReports(); + const [sources, setSources] = useState([]); + + useEffect(() => { + getAllSources().then(setSources); + }, []); const [hiddenCategories, setHiddenCategories] = useState>(new Set()); const [detailModal, setDetailModal] = useState(null); + const [showAmounts, setShowAmounts] = useState(() => localStorage.getItem("reports-show-amounts") === "true"); + const [viewMode, setViewMode] = useState<"chart" | "table">(() => + (localStorage.getItem("reports-view-mode") as "chart" | "table") || "chart" + ); const toggleHidden = useCallback((name: string) => { setHiddenCategories((prev) => { @@ -65,6 +80,22 @@ export default function ReportsPage() { const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo); + const filterCategories = useMemo(() => { + if (state.tab === "byCategory") { + return state.categorySpending.map((c) => ({ name: c.category_name, color: c.category_color })); + } + if (state.tab === "overTime") { + return state.categoryOverTime.categories.map((name) => ({ + name, + color: state.categoryOverTime.colors[name] || "#9ca3af", + })); + } + return []; + }, [state.tab, state.categorySpending, state.categoryOverTime]); + + const hasCategories = ["byCategory", "overTime"].includes(state.tab) && filterCategories.length > 0; + const showFilterPanel = hasCategories || (state.tab === "trends" && sources.length > 1); + return (
@@ -89,7 +120,7 @@ export default function ReportsPage() { )}
-
+
{TABS.map((tab) => (