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:
parent
2e7beea673
commit
84eca47afd
9 changed files with 461 additions and 10 deletions
64
src/components/dashboard/CategoryPieChart.tsx
Normal file
64
src/components/dashboard/CategoryPieChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/dashboard/PeriodSelector.tsx
Normal file
31
src/components/dashboard/PeriodSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/dashboard/RecentTransactionsList.tsx
Normal file
51
src/components/dashboard/RecentTransactionsList.tsx
Normal 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
134
src/hooks/useDashboard.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">{t("dashboard.title")}</h1>
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<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">
|
||||
{cards.map((card) => (
|
||||
|
|
@ -46,8 +65,9 @@ export default function DashboardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
<p>{t("dashboard.noData")}</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<CategoryPieChart data={categoryBreakdown} />
|
||||
<RecentTransactionsList transactions={recentTransactions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
106
src/services/dashboardService.ts
Normal file
106
src/services/dashboardService.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue