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"