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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-08 23:49:16 +00:00
parent 2e7beea673
commit 84eca47afd
9 changed files with 461 additions and 10 deletions

View file

@ -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 (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
const total = data.reduce((sum, d) => sum + d.total, 0);
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
paddingAngle={2}
>
{data.map((item, index) => (
<Cell key={index} fill={item.category_color} />
))}
</Pie>
<Tooltip
formatter={(value) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(Number(value))
}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-1.5 text-sm">
<span
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
style={{ backgroundColor: item.category_color }}
/>
<span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-wrap gap-2">
{PERIODS.map((p) => (
<button
key={p}
onClick={() => onChange(p)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
p === value
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(`dashboard.period.${p}`)}
</button>
))}
</div>
);
}

View file

@ -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 (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.recentTransactions")}</h2>
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.recentTransactions")}</h2>
<div className="space-y-3">
{transactions.map((tx) => (
<div key={tx.id} className="flex items-center gap-3">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{tx.description}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{tx.date}
{tx.category_name ? ` · ${tx.category_name}` : ""}
</p>
</div>
<span
className={`text-sm font-medium flex-shrink-0 ${
tx.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{fmt.format(tx.amount)}
</span>
</div>
))}
</div>
</div>
);
}

134
src/hooks/useDashboard.ts Normal file
View file

@ -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 };
}

View file

@ -16,7 +16,16 @@
"balance": "Balance", "balance": "Balance",
"income": "Income", "income": "Income",
"expenses": "Expenses", "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": { "import": {
"title": "Import Statements", "title": "Import Statements",

View file

@ -16,7 +16,16 @@
"balance": "Solde", "balance": "Solde",
"income": "Revenus", "income": "Revenus",
"expenses": "Dépenses", "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": { "import": {
"title": "Importer des relevés", "title": "Importer des relevés",

View file

@ -1,33 +1,52 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react"; 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() { export default function DashboardPage() {
const { t } = useTranslation(); 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 = [ const cards = [
{ {
labelKey: "dashboard.balance", labelKey: "dashboard.balance",
value: "0,00 €", value: fmt.format(balance),
icon: Wallet, icon: Wallet,
color: "text-[var(--primary)]", color: balanceColor,
}, },
{ {
labelKey: "dashboard.income", labelKey: "dashboard.income",
value: "0,00 €", value: fmt.format(summary.incomeTotal),
icon: TrendingUp, icon: TrendingUp,
color: "text-[var(--positive)]", color: "text-[var(--positive)]",
}, },
{ {
labelKey: "dashboard.expenses", labelKey: "dashboard.expenses",
value: "0,00 €", value: fmt.format(Math.abs(summary.expenseTotal)),
icon: TrendingDown, icon: TrendingDown,
color: "text-[var(--negative)]", color: "text-[var(--negative)]",
}, },
]; ];
return ( return (
<div> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<h1 className="text-2xl font-bold mb-6">{t("dashboard.title")}</h1> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{cards.map((card) => ( {cards.map((card) => (
@ -46,8 +65,9 @@ export default function DashboardPage() {
))} ))}
</div> </div>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<p>{t("dashboard.noData")}</p> <CategoryPieChart data={categoryBreakdown} />
<RecentTransactionsList transactions={recentTransactions} />
</div> </div>
</div> </div>
); );

View file

@ -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<DashboardSummary> {
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<CategoryBreakdownItem[]> {
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<CategoryBreakdownItem[]>(
`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<RecentTransaction[]> {
const db = await getDb();
return db.select<RecentTransaction[]>(
`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]
);
}

View file

@ -202,6 +202,33 @@ export interface ImportReport {
errors: Array<{ rowIndex: number; message: string }>; 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 = export type ImportWizardStep =
| "source-list" | "source-list"
| "source-config" | "source-config"