From 84eca47afd1dfc68ab266831a3154403cdde3fe8 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sun, 8 Feb 2026 23:49:16 +0000 Subject: [PATCH] feat: implement dashboard with KPI cards, category pie chart, and recent transactions Wire dashboard to real DB data with period selector (month/3m/6m/12m/all), expense breakdown donut chart by category, and last 10 transactions list. Co-Authored-By: Claude Opus 4.6 --- src/components/dashboard/CategoryPieChart.tsx | 64 +++++++++ src/components/dashboard/PeriodSelector.tsx | 31 ++++ .../dashboard/RecentTransactionsList.tsx | 51 +++++++ src/hooks/useDashboard.ts | 134 ++++++++++++++++++ src/i18n/locales/en.json | 11 +- src/i18n/locales/fr.json | 11 +- src/pages/DashboardPage.tsx | 36 +++-- src/services/dashboardService.ts | 106 ++++++++++++++ src/shared/types/index.ts | 27 ++++ 9 files changed, 461 insertions(+), 10 deletions(-) create mode 100644 src/components/dashboard/CategoryPieChart.tsx create mode 100644 src/components/dashboard/PeriodSelector.tsx create mode 100644 src/components/dashboard/RecentTransactionsList.tsx create mode 100644 src/hooks/useDashboard.ts create mode 100644 src/services/dashboardService.ts diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx new file mode 100644 index 0000000..4b5e396 --- /dev/null +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; +import type { CategoryBreakdownItem } from "../../shared/types"; + +interface CategoryPieChartProps { + data: CategoryBreakdownItem[]; +} + +export default function CategoryPieChart({ data }: CategoryPieChartProps) { + const { t } = useTranslation(); + + if (data.length === 0) { + return ( +
+

{t("dashboard.expensesByCategory")}

+

{t("dashboard.noData")}

+
+ ); + } + + const total = data.reduce((sum, d) => sum + d.total, 0); + + return ( +
+

{t("dashboard.expensesByCategory")}

+ + + + {data.map((item, index) => ( + + ))} + + + new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(Number(value)) + } + /> + + +
+ {data.map((item, index) => ( +
+ + + {item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""} + +
+ ))} +
+
+ ); +} diff --git a/src/components/dashboard/PeriodSelector.tsx b/src/components/dashboard/PeriodSelector.tsx new file mode 100644 index 0000000..6eec01d --- /dev/null +++ b/src/components/dashboard/PeriodSelector.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next"; +import type { DashboardPeriod } from "../../shared/types"; + +const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"]; + +interface PeriodSelectorProps { + value: DashboardPeriod; + onChange: (period: DashboardPeriod) => void; +} + +export default function PeriodSelector({ value, onChange }: PeriodSelectorProps) { + const { t } = useTranslation(); + + return ( +
+ {PERIODS.map((p) => ( + + ))} +
+ ); +} diff --git a/src/components/dashboard/RecentTransactionsList.tsx b/src/components/dashboard/RecentTransactionsList.tsx new file mode 100644 index 0000000..ca79987 --- /dev/null +++ b/src/components/dashboard/RecentTransactionsList.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import type { RecentTransaction } from "../../shared/types"; + +interface RecentTransactionsListProps { + transactions: RecentTransaction[]; +} + +export default function RecentTransactionsList({ transactions }: RecentTransactionsListProps) { + const { t } = useTranslation(); + + if (transactions.length === 0) { + return ( +
+

{t("dashboard.recentTransactions")}

+

{t("dashboard.noData")}

+
+ ); + } + + const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }); + + return ( +
+

{t("dashboard.recentTransactions")}

+
+ {transactions.map((tx) => ( +
+ +
+

{tx.description}

+

+ {tx.date} + {tx.category_name ? ` · ${tx.category_name}` : ""} +

+
+ = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`} + > + {fmt.format(tx.amount)} + +
+ ))} +
+
+ ); +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts new file mode 100644 index 0000000..fd78bfb --- /dev/null +++ b/src/hooks/useDashboard.ts @@ -0,0 +1,134 @@ +import { useReducer, useCallback, useEffect, useRef } from "react"; +import type { + DashboardPeriod, + DashboardSummary, + CategoryBreakdownItem, + RecentTransaction, +} from "../shared/types"; +import { + getDashboardSummary, + getExpensesByCategory, + getRecentTransactions, +} from "../services/dashboardService"; + +interface DashboardState { + summary: DashboardSummary; + categoryBreakdown: CategoryBreakdownItem[]; + recentTransactions: RecentTransaction[]; + period: DashboardPeriod; + isLoading: boolean; + error: string | null; +} + +type DashboardAction = + | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_ERROR"; payload: string | null } + | { + type: "SET_DATA"; + payload: { + summary: DashboardSummary; + categoryBreakdown: CategoryBreakdownItem[]; + recentTransactions: RecentTransaction[]; + }; + } + | { type: "SET_PERIOD"; payload: DashboardPeriod }; + +const initialState: DashboardState = { + summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, + categoryBreakdown: [], + recentTransactions: [], + period: "month", + isLoading: false, + error: null, +}; + +function reducer(state: DashboardState, action: DashboardAction): DashboardState { + switch (action.type) { + case "SET_LOADING": + return { ...state, isLoading: action.payload }; + case "SET_ERROR": + return { ...state, error: action.payload, isLoading: false }; + case "SET_DATA": + return { + ...state, + summary: action.payload.summary, + categoryBreakdown: action.payload.categoryBreakdown, + recentTransactions: action.payload.recentTransactions, + isLoading: false, + }; + case "SET_PERIOD": + return { ...state, period: action.payload }; + default: + return state; + } +} + +function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } { + if (period === "all") return {}; + + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const day = now.getDate(); + + const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + + let from: Date; + switch (period) { + case "month": + from = new Date(year, month, 1); + break; + case "3months": + from = new Date(year, month - 2, 1); + break; + case "6months": + from = new Date(year, month - 5, 1); + break; + case "12months": + from = new Date(year, month - 11, 1); + break; + } + + const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`; + + return { dateFrom, dateTo }; +} + +export function useDashboard() { + const [state, dispatch] = useReducer(reducer, initialState); + const fetchIdRef = useRef(0); + + const fetchData = useCallback(async (period: DashboardPeriod) => { + const fetchId = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + dispatch({ type: "SET_ERROR", payload: null }); + + try { + const { dateFrom, dateTo } = computeDateRange(period); + const [summary, categoryBreakdown, recentTransactions] = await Promise.all([ + getDashboardSummary(dateFrom, dateTo), + getExpensesByCategory(dateFrom, dateTo), + getRecentTransactions(10), + ]); + + if (fetchId !== fetchIdRef.current) return; + dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } }); + } catch (e) { + if (fetchId !== fetchIdRef.current) return; + dispatch({ + type: "SET_ERROR", + payload: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + useEffect(() => { + fetchData(state.period); + }, [state.period, fetchData]); + + const setPeriod = useCallback((period: DashboardPeriod) => { + dispatch({ type: "SET_PERIOD", payload: period }); + }, []); + + return { state, setPeriod }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b0dfcb5..5318edc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -16,7 +16,16 @@ "balance": "Balance", "income": "Income", "expenses": "Expenses", - "noData": "No data available. Start by importing your bank statements." + "noData": "No data available. Start by importing your bank statements.", + "expensesByCategory": "Expenses by Category", + "recentTransactions": "Recent Transactions", + "period": { + "month": "This month", + "3months": "3 months", + "6months": "6 months", + "12months": "12 months", + "all": "All" + } }, "import": { "title": "Import Statements", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 630cf81..098abdd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -16,7 +16,16 @@ "balance": "Solde", "income": "Revenus", "expenses": "Dépenses", - "noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires." + "noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.", + "expensesByCategory": "Dépenses par catégorie", + "recentTransactions": "Transactions récentes", + "period": { + "month": "Ce mois", + "3months": "3 mois", + "6months": "6 mois", + "12months": "12 mois", + "all": "Tout" + } }, "import": { "title": "Importer des relevés", diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 9cf5de6..e4e5c81 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,33 +1,52 @@ import { useTranslation } from "react-i18next"; import { Wallet, TrendingUp, TrendingDown } from "lucide-react"; +import { useDashboard } from "../hooks/useDashboard"; +import PeriodSelector from "../components/dashboard/PeriodSelector"; +import CategoryPieChart from "../components/dashboard/CategoryPieChart"; +import RecentTransactionsList from "../components/dashboard/RecentTransactionsList"; + +const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }); export default function DashboardPage() { const { t } = useTranslation(); + const { state, setPeriod } = useDashboard(); + const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state; + + const balance = summary.totalAmount; + const balanceColor = + balance > 0 + ? "text-[var(--positive)]" + : balance < 0 + ? "text-[var(--negative)]" + : "text-[var(--primary)]"; const cards = [ { labelKey: "dashboard.balance", - value: "0,00 €", + value: fmt.format(balance), icon: Wallet, - color: "text-[var(--primary)]", + color: balanceColor, }, { labelKey: "dashboard.income", - value: "0,00 €", + value: fmt.format(summary.incomeTotal), icon: TrendingUp, color: "text-[var(--positive)]", }, { labelKey: "dashboard.expenses", - value: "0,00 €", + value: fmt.format(Math.abs(summary.expenseTotal)), icon: TrendingDown, color: "text-[var(--negative)]", }, ]; return ( -
-

{t("dashboard.title")}

+
+
+

{t("dashboard.title")}

+ +
{cards.map((card) => ( @@ -46,8 +65,9 @@ export default function DashboardPage() { ))}
-
-

{t("dashboard.noData")}

+
+ +
); diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts new file mode 100644 index 0000000..25aac81 --- /dev/null +++ b/src/services/dashboardService.ts @@ -0,0 +1,106 @@ +import { getDb } from "./db"; +import type { + DashboardSummary, + CategoryBreakdownItem, + RecentTransaction, +} from "../shared/types"; + +export async function getDashboardSummary( + dateFrom?: string, + dateTo?: string +): Promise { + const db = await getDb(); + + const whereClauses: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (dateFrom) { + whereClauses.push(`date >= $${paramIndex}`); + params.push(dateFrom); + paramIndex++; + } + if (dateTo) { + whereClauses.push(`date <= $${paramIndex}`); + params.push(dateTo); + paramIndex++; + } + + const whereSQL = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + + const rows = await db.select< + Array<{ + totalCount: number; + totalAmount: number; + incomeTotal: number; + expenseTotal: number; + }> + >( + `SELECT + COUNT(*) AS totalCount, + COALESCE(SUM(amount), 0) AS totalAmount, + COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS incomeTotal, + COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0) AS expenseTotal + FROM transactions + ${whereSQL}`, + params + ); + + return rows[0] ?? { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }; +} + +export async function getExpensesByCategory( + dateFrom?: string, + dateTo?: string +): Promise { + const db = await getDb(); + + const whereClauses: string[] = ["t.amount < 0"]; + const params: unknown[] = []; + let paramIndex = 1; + + if (dateFrom) { + whereClauses.push(`t.date >= $${paramIndex}`); + params.push(dateFrom); + paramIndex++; + } + if (dateTo) { + whereClauses.push(`t.date <= $${paramIndex}`); + params.push(dateTo); + paramIndex++; + } + + const whereSQL = `WHERE ${whereClauses.join(" AND ")}`; + + return db.select( + `SELECT + t.category_id, + COALESCE(c.name, 'Uncategorized') AS category_name, + COALESCE(c.color, '#9ca3af') AS category_color, + ABS(SUM(t.amount)) AS total + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + ${whereSQL} + GROUP BY t.category_id + ORDER BY total DESC`, + params + ); +} + +export async function getRecentTransactions( + limit: number = 10 +): Promise { + const db = await getDb(); + + return db.select( + `SELECT + t.id, t.date, t.description, t.amount, + c.name AS category_name, c.color AS category_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + ORDER BY t.date DESC, t.id DESC + LIMIT $1`, + [limit] + ); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 46f2323..5e8309f 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -202,6 +202,33 @@ export interface ImportReport { errors: Array<{ rowIndex: number; message: string }>; } +// --- Dashboard Types --- + +export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all"; + +export interface DashboardSummary { + totalCount: number; + totalAmount: number; + incomeTotal: number; + expenseTotal: number; +} + +export interface CategoryBreakdownItem { + category_id: number | null; + category_name: string; + category_color: string; + total: number; +} + +export interface RecentTransaction { + id: number; + date: string; + description: string; + amount: number; + category_name: string | null; + category_color: string | null; +} + export type ImportWizardStep = | "source-list" | "source-config"