feat: advanced features — dashboard, budgets, history, export

Backend:
- GET /api/v1/dashboard?month=YYYY-MM: KPIs, by_category, 6-month trend, budget alerts
- GET/POST/PUT/DELETE /api/v1/budgets: budget envelopes with spent_cents/remaining_cents
- POST /api/v1/budgets/rollover: copy budgets from M-1 to target month
- GET /api/v1/history?year=YYYY: monthly summary for the year
- GET /api/v1/export/csv|pdf?month=YYYY-MM: StreamingResponse exports (WeasyPrint PDF)
- New schemas: dashboard, budget, history
- Services: dashboard_service, budget_service
- Routers mounted in main.py

Frontend:
- DashboardPage: 4 KPI cards, PieChart (expenses by category), BarChart (6-month trend),
  month navigation, budget alert badges, CSV/PDF export buttons
- BudgetsPage: progress bars (green/orange/red), create/edit form, delete, rollover M-1
- HistoryPage: annual table with month click → dashboard, LineChart revenues/expenses
- CategoriesPage: list by type with create/edit/delete (was missing from Phase 2)
- TransactionsPage: added CSV/PDF export buttons
- App.tsx: full routing with ProtectedRoute + Layout for all authenticated pages
- New hooks: useDashboard, useBudgets (with mutations), useHistory
- API types + client updated for all new endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nox (OpenClaw)
2026-03-17 16:48:26 +00:00
parent 9f7378cb69
commit e3fac99045
21 changed files with 2026 additions and 12 deletions
+27 -3
View File
@@ -1,10 +1,34 @@
import { Routes, Route } from "react-router-dom";
import HomePage from "./pages/HomePage";
import { Routes, Route, Navigate } from "react-router-dom";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import DashboardPage from "./pages/DashboardPage";
import TransactionsPage from "./pages/TransactionsPage";
import CategoriesPage from "./pages/CategoriesPage";
import BudgetsPage from "./pages/BudgetsPage";
import HistoryPage from "./pages/HistoryPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected routes with layout */}
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/budgets" element={<BudgetsPage />} />
<Route path="/history" element={<HistoryPage />} />
</Route>
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+79
View File
@@ -13,6 +13,11 @@ import type {
UpdateTransactionPayload,
CreateCategoryPayload,
UpdateCategoryPayload,
DashboardData,
Budget,
CreateBudgetPayload,
UpdateBudgetPayload,
HistoryData,
} from "./types";
// ---------------------------------------------------------------------------
@@ -226,3 +231,77 @@ export async function updateCategory(
export async function deleteCategory(id: string): Promise<void> {
await apiClient.delete(`/categories/${id}`);
}
// ---------------------------------------------------------------------------
// Dashboard API
// ---------------------------------------------------------------------------
export async function getDashboard(month?: string): Promise<DashboardData> {
const { data } = await apiClient.get<DashboardData>("/dashboard", {
params: month ? { month } : undefined,
});
return data;
}
// ---------------------------------------------------------------------------
// Budgets API
// ---------------------------------------------------------------------------
export async function getBudgets(month?: string): Promise<Budget[]> {
const { data } = await apiClient.get<Budget[]>("/budgets", {
params: month ? { month } : undefined,
});
return data;
}
export async function createBudget(payload: CreateBudgetPayload): Promise<Budget> {
const { data } = await apiClient.post<Budget>("/budgets", payload);
return data;
}
export async function updateBudget(id: string, payload: UpdateBudgetPayload): Promise<Budget> {
const { data } = await apiClient.put<Budget>(`/budgets/${id}`, payload);
return data;
}
export async function deleteBudget(id: string): Promise<void> {
await apiClient.delete(`/budgets/${id}`);
}
export async function rolloverBudgets(month: string): Promise<Budget[]> {
const { data } = await apiClient.post<Budget[]>("/budgets/rollover", null, {
params: { month },
});
return data;
}
// ---------------------------------------------------------------------------
// History API
// ---------------------------------------------------------------------------
export async function getHistory(year?: number): Promise<HistoryData> {
const { data } = await apiClient.get<HistoryData>("/history", {
params: year ? { year } : undefined,
});
return data;
}
// ---------------------------------------------------------------------------
// Export API (returns blob URL)
// ---------------------------------------------------------------------------
export function exportCsvUrl(month: string): string {
return `/api/v1/export/csv?month=${month}`;
}
export function exportPdfUrl(month: string): string {
return `/api/v1/export/pdf?month=${month}`;
}
export async function downloadExport(url: string, filename: string): Promise<void> {
const { data } = await apiClient.get<Blob>(url.replace("/api/v1", ""), {
responseType: "blob",
});
const objectUrl = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
}
+71
View File
@@ -82,3 +82,74 @@ export interface UpdateCategoryPayload {
color?: string;
icon?: string;
}
// Dashboard
export interface CategoryExpense {
category_id: string;
category_name: string;
color: string | null;
amount_cents: number;
}
export interface MonthlyTrend {
month: string;
income_cents: number;
expense_cents: number;
}
export interface BudgetAlert {
budget_id: string;
category_name: string;
limit_cents: number;
spent_cents: number;
percentage: number;
}
export interface DashboardData {
month: string;
balance_cents: number;
total_income_cents: number;
total_expense_cents: number;
net_cents: number;
by_category: CategoryExpense[];
monthly_trend: MonthlyTrend[];
budget_alerts: BudgetAlert[];
}
// Budgets
export interface Budget {
id: string;
category_id: string;
category_name: string;
category_color: string | null;
month: string;
limit_cents: number;
spent_cents: number;
remaining_cents: number;
created_at: string;
updated_at: string;
}
export interface CreateBudgetPayload {
category_id: string;
month: string;
limit_cents: number;
}
export interface UpdateBudgetPayload {
limit_cents: number;
}
// History
export interface MonthSummary {
month: string;
income_cents: number;
expense_cents: number;
balance_cents: number;
transaction_count: number;
}
export interface HistoryData {
year: number;
months: MonthSummary[];
}
+49
View File
@@ -0,0 +1,49 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createBudget,
deleteBudget,
getBudgets,
rolloverBudgets,
updateBudget,
} from "../api/client";
import type { CreateBudgetPayload, UpdateBudgetPayload } from "../api/types";
export function useBudgets(month?: string) {
return useQuery({
queryKey: ["budgets", month],
queryFn: () => getBudgets(month),
});
}
export function useCreateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateBudgetPayload) => createBudget(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useUpdateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateBudgetPayload }) =>
updateBudget(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useDeleteBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteBudget(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useRolloverBudgets() {
const qc = useQueryClient();
return useMutation({
mutationFn: (month: string) => rolloverBudgets(month),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getDashboard } from "../api/client";
export function useDashboard(month?: string) {
return useQuery({
queryKey: ["dashboard", month],
queryFn: () => getDashboard(month),
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getHistory } from "../api/client";
export function useHistory(year?: number) {
return useQuery({
queryKey: ["history", year],
queryFn: () => getHistory(year),
});
}
+316
View File
@@ -0,0 +1,316 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, CopyPlus } from "lucide-react";
import {
useBudgets,
useCreateBudget,
useUpdateBudget,
useDeleteBudget,
useRolloverBudgets,
} from "../hooks/useBudgets";
import { useCategories } from "../hooks/useCategories";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import { formatCurrency, currentMonth } from "../utils/format";
import type { Budget } from "../api/types";
function prevMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 1) return `${y - 1}-12`;
return `${y}-${String(m - 1).padStart(2, "0")}`;
}
function nextMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 12) return `${y + 1}-01`;
return `${y}-${String(m + 1).padStart(2, "0")}`;
}
function formatMonthLabel(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
new Date(y, m - 1, 1)
);
}
function ProgressBar({ spent, limit }: { spent: number; limit: number }) {
const pct = limit > 0 ? Math.min((spent / limit) * 100, 100) : 0;
const color =
pct >= 100 ? "bg-red-500" : pct >= 70 ? "bg-orange-400" : "bg-green-500";
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
);
}
interface BudgetFormProps {
month: string;
editing?: Budget;
onClose: () => void;
}
function BudgetForm({ month, editing, onClose }: BudgetFormProps) {
const { data: categoriesData } = useCategories();
const createBudget = useCreateBudget();
const updateBudget = useUpdateBudget();
const { addToast } = useToast();
const expenseCategories = categoriesData?.filter(
(c) => c.type === "expense" || c.type === "both"
) ?? [];
const [categoryId, setCategoryId] = useState(editing?.category_id ?? "");
const [limitEuros, setLimitEuros] = useState(
editing ? String(editing.limit_cents / 100) : ""
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const limit_cents = Math.round(Number(limitEuros) * 100);
if (!limit_cents || limit_cents <= 0) {
addToast({ type: "error", message: "Limite invalide" });
return;
}
try {
if (editing) {
await updateBudget.mutateAsync({ id: editing.id, payload: { limit_cents } });
addToast({ type: "success", message: "Budget modifié" });
} else {
if (!categoryId) {
addToast({ type: "error", message: "Sélectionnez une catégorie" });
return;
}
await createBudget.mutateAsync({ category_id: categoryId, month, limit_cents });
addToast({ type: "success", message: "Budget créé" });
}
onClose();
} catch {
addToast({ type: "error", message: "Erreur lors de la sauvegarde" });
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-lg font-semibold">
{editing ? "Modifier le budget" : "Nouveau budget"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{!editing && (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">
Catégorie
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
required
>
<option value="">Sélectionner</option>
{expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">
Limite ()
</label>
<input
type="number"
min="0.01"
step="0.01"
value={limitEuros}
onChange={(e) => setLimitEuros(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="ex: 500"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm text-slate-600 hover:bg-slate-100"
>
Annuler
</button>
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
{editing ? "Enregistrer" : "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
export default function BudgetsPage() {
const [month, setMonth] = useState(currentMonth());
const { data: budgets, isLoading } = useBudgets(month);
const deleteBudget = useDeleteBudget();
const rollover = useRolloverBudgets();
const { addToast } = useToast();
const [formOpen, setFormOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Budget | undefined>();
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteBudget.mutateAsync(deleteTarget.id);
addToast({ type: "success", message: "Budget supprimé" });
} catch {
addToast({ type: "error", message: "Erreur lors de la suppression" });
} finally {
setDeleteTarget(undefined);
}
};
const handleRollover = async () => {
try {
const created = await rollover.mutateAsync(month);
if (created.length === 0) {
addToast({ type: "info", message: "Aucun budget à reconduire (déjà existants)" });
} else {
addToast({ type: "success", message: `${created.length} budget(s) reconduit(s)` });
}
} catch {
addToast({ type: "error", message: "Erreur lors de la reconduction" });
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => setMonth(prevMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800 capitalize">
{formatMonthLabel(month)}
</h1>
<button
onClick={() => setMonth(nextMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleRollover}
disabled={rollover.isPending}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
<CopyPlus size={15} />
Reconduire M-1
</button>
<button
onClick={() => { setEditingBudget(undefined); setFormOpen(true); }}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<Plus size={15} />
Nouveau budget
</button>
</div>
</div>
{/* Budget list */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-xl bg-slate-100" />
))}
</div>
) : !budgets || budgets.length === 0 ? (
<div className="rounded-xl border bg-white p-10 text-center text-slate-400">
Aucun budget défini pour ce mois.
</div>
) : (
<div className="space-y-3">
{budgets.map((budget) => {
const pct = budget.limit_cents > 0
? Math.round((budget.spent_cents / budget.limit_cents) * 100)
: 0;
const over = budget.spent_cents > budget.limit_cents;
return (
<div
key={budget.id}
className="rounded-xl border bg-white p-4 shadow-sm"
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{budget.category_color && (
<span
className="h-3 w-3 rounded-full"
style={{ background: budget.category_color }}
/>
)}
<span className="font-medium text-slate-800">
{budget.category_name}
</span>
{over && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
Dépassé
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setEditingBudget(budget); setFormOpen(true); }}
className="rounded p-1 text-slate-400 hover:text-slate-700"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(budget)}
className="rounded p-1 text-slate-400 hover:text-red-600"
>
<Trash2 size={14} />
</button>
</div>
</div>
<ProgressBar spent={budget.spent_cents} limit={budget.limit_cents} />
<div className="mt-1.5 flex justify-between text-xs text-slate-500">
<span>{formatCurrency(budget.spent_cents)} dépensés</span>
<span className={over ? "font-semibold text-red-600" : ""}>
{pct}% limite {formatCurrency(budget.limit_cents)}
</span>
</div>
</div>
);
})}
</div>
)}
{/* Form modal */}
{formOpen && (
<BudgetForm
month={month}
editing={editingBudget}
onClose={() => { setFormOpen(false); setEditingBudget(undefined); }}
/>
)}
{/* Delete confirm */}
{deleteTarget && (
<DeleteConfirmModal
message={`Supprimer le budget "${deleteTarget.category_name}" ?`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(undefined)}
/>
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { useCategories, useDeleteCategory } from "../hooks/useCategories";
import CategoryModal from "../components/CategoryModal";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import type { Category } from "../api/types";
export default function CategoriesPage() {
const { data: categories, isLoading } = useCategories();
const deleteMutation = useDeleteCategory();
const { addToast } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const [editingCat, setEditingCat] = useState<Category | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Category | undefined>();
function openCreate() {
setEditingCat(undefined);
setModalOpen(true);
}
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
addToast({ type: "success", message: "Catégorie supprimée" });
setDeleteTarget(undefined);
},
onError: () => addToast({ type: "error", message: "Erreur lors de la suppression" }),
});
}
const expense = categories?.filter((c) => c.type === "expense" || c.type === "both") ?? [];
const income = categories?.filter((c) => c.type === "income") ?? [];
function CategoryRow({ cat }: { cat: Category }) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-white px-4 py-3 shadow-sm">
{cat.color && (
<span className="h-4 w-4 rounded-full shrink-0" style={{ background: cat.color }} />
)}
<span className="flex-1 text-sm font-medium text-slate-800">{cat.name}</span>
{cat.is_default && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">
Défaut
</span>
)}
<button
onClick={() => { setEditingCat(cat); setModalOpen(true); }}
className="p-1 text-slate-400 hover:text-slate-700"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(cat)}
className="p-1 text-slate-400 hover:text-red-600"
disabled={cat.is_default}
>
<Trash2 size={14} />
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-slate-800">Catégories</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<Plus size={16} />
Nouvelle
</button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-lg bg-slate-100" />
))}
</div>
) : (
<>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
Dépenses
</h2>
<div className="space-y-2">
{expense.map((c) => <CategoryRow key={c.id} cat={c} />)}
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
Revenus
</h2>
<div className="space-y-2">
{income.map((c) => <CategoryRow key={c.id} cat={c} />)}
</div>
</section>
</>
)}
<CategoryModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
category={editingCat}
/>
{deleteTarget && (
<DeleteConfirmModal
message={`Supprimer la catégorie "${deleteTarget.name}" ?`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(undefined)}
/>
)}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import {
ChevronLeft,
ChevronRight,
TrendingUp,
TrendingDown,
Wallet,
AlertTriangle,
Download,
} from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
import { downloadExport } from "../api/client";
import { formatCurrency, currentMonth } from "../utils/format";
import { useToast } from "../context/ToastContext";
const PIE_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#3b82f6",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#84cc16",
];
function prevMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 1) return `${y - 1}-12`;
return `${y}-${String(m - 1).padStart(2, "0")}`;
}
function nextMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 12) return `${y + 1}-01`;
return `${y}-${String(m + 1).padStart(2, "0")}`;
}
function formatMonthLabel(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
new Date(y, m - 1, 1)
);
}
function formatShortMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "short" }).format(new Date(y, m - 1, 1));
}
interface KpiCardProps {
label: string;
value: number;
icon: React.ReactNode;
color: string;
signed?: boolean;
}
function KpiCard({ label, value, icon, color, signed }: KpiCardProps) {
const formatted = signed
? (value >= 0 ? "+" : "") + formatCurrency(value)
: formatCurrency(value);
return (
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">{label}</p>
<span className={`rounded-full p-2 ${color}`}>{icon}</span>
</div>
<p className={`mt-2 text-2xl font-bold ${signed && value < 0 ? "text-red-600" : signed && value > 0 ? "text-green-600" : "text-slate-900"}`}>
{formatted}
</p>
</div>
);
}
export default function DashboardPage() {
const [month, setMonth] = useState(currentMonth());
const { data, isLoading } = useDashboard(month);
const { addToast } = useToast();
const navigate = useNavigate();
const handleExportCsv = async () => {
try {
await downloadExport(`/api/v1/export/csv?month=${month}`, `transactions_${month}.csv`);
} catch {
addToast({ type: "error", message: "Erreur lors de l'export CSV" });
}
};
const handleExportPdf = async () => {
try {
await downloadExport(`/api/v1/export/pdf?month=${month}`, `transactions_${month}.pdf`);
} catch {
addToast({ type: "error", message: "Erreur lors de l'export PDF" });
}
};
const trendData = data?.monthly_trend.map((t) => ({
name: formatShortMonth(t.month),
Revenus: t.income_cents / 100,
Dépenses: t.expense_cents / 100,
})) ?? [];
const pieData = data?.by_category.map((c) => ({
name: c.category_name,
value: c.amount_cents / 100,
color: c.color ?? undefined,
})) ?? [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => setMonth(prevMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800 capitalize">
{formatMonthLabel(month)}
</h1>
<button
onClick={() => setMonth(nextMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
disabled={month >= currentMonth()}
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleExportCsv}
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
>
<Download size={14} /> CSV
</button>
<button
onClick={handleExportPdf}
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
>
<Download size={14} /> PDF
</button>
</div>
</div>
{/* Budget alerts */}
{data?.budget_alerts && data.budget_alerts.length > 0 && (
<div className="space-y-2">
{data.budget_alerts.map((alert) => (
<div
key={alert.budget_id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm ${
alert.percentage >= 100
? "border-red-200 bg-red-50 text-red-800"
: "border-orange-200 bg-orange-50 text-orange-800"
}`}
>
<AlertTriangle size={16} className="shrink-0" />
<span>
<strong>{alert.category_name}</strong> :{" "}
{formatCurrency(alert.spent_cents)} dépensés sur{" "}
{formatCurrency(alert.limit_cents)} ({alert.percentage}%)
{alert.percentage >= 100 && " — Budget dépassé !"}
</span>
<button
onClick={() => navigate("/budgets")}
className="ml-auto shrink-0 font-medium underline"
>
Gérer
</button>
</div>
))}
</div>
)}
{/* KPI Cards */}
{isLoading ? (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-28 animate-pulse rounded-xl bg-slate-100" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KpiCard
label="Solde courant"
value={data?.balance_cents ?? 0}
icon={<Wallet size={18} className="text-indigo-600" />}
color="bg-indigo-50"
/>
<KpiCard
label="Revenus du mois"
value={data?.total_income_cents ?? 0}
icon={<TrendingUp size={18} className="text-green-600" />}
color="bg-green-50"
/>
<KpiCard
label="Dépenses du mois"
value={data?.total_expense_cents ?? 0}
icon={<TrendingDown size={18} className="text-red-600" />}
color="bg-red-50"
/>
<KpiCard
label="Solde net du mois"
value={data?.net_cents ?? 0}
icon={<TrendingUp size={18} className="text-blue-600" />}
color="bg-blue-50"
signed
/>
</div>
)}
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Bar chart — monthly trend */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Tendance 6 derniers mois
</h2>
{isLoading ? (
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={trendData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
<Legend />
<Bar dataKey="Revenus" fill="#10b981" radius={[4, 4, 0, 0]} />
<Bar dataKey="Dépenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie chart — expenses by category */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Dépenses par catégorie
</h2>
{isLoading ? (
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
) : pieData.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-slate-400">
Aucune dépense ce mois
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(0)}%`
}
labelLine={false}
>
{pieData.map((entry, index) => (
<Cell
key={entry.name}
fill={entry.color ?? PIE_COLORS[index % PIE_COLORS.length]}
/>
))}
</Pie>
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useHistory } from "../hooks/useHistory";
import { formatCurrency } from "../utils/format";
const MONTH_NAMES = [
"Jan", "Fév", "Mar", "Avr", "Mai", "Jun",
"Jul", "Aoû", "Sep", "Oct", "Nov", "Déc",
];
export default function HistoryPage() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const { data, isLoading } = useHistory(year);
const navigate = useNavigate();
const chartData = data?.months.map((m, i) => ({
name: MONTH_NAMES[i],
Revenus: m.income_cents / 100,
Dépenses: m.expense_cents / 100,
})) ?? [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => setYear((y) => y - 1)}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800">{year}</h1>
<button
onClick={() => setYear((y) => y + 1)}
className="rounded-lg border p-2 hover:bg-slate-100"
disabled={year >= currentYear}
>
<ChevronRight size={16} />
</button>
</div>
{/* Line chart */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Revenus et dépenses {year}
</h2>
{isLoading ? (
<div className="h-52 animate-pulse rounded-lg bg-slate-100" />
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
<Legend />
<Line
type="monotone"
dataKey="Revenus"
stroke="#10b981"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="Dépenses"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Monthly table */}
<div className="rounded-xl border bg-white shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-600">Mois</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Revenus</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Dépenses</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Solde net</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Transactions</th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 12 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 5 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 animate-pulse rounded bg-slate-100" />
</td>
))}
</tr>
))
: data?.months.map((m, i) => {
const hasData = m.transaction_count > 0;
return (
<tr
key={m.month}
className={`border-b last:border-0 transition-colors ${
hasData ? "cursor-pointer hover:bg-slate-50" : "opacity-50"
}`}
onClick={() => hasData && navigate(`/?month=${m.month}`)}
>
<td className="px-4 py-3 font-medium text-slate-800">
{MONTH_NAMES[i]}
</td>
<td className="px-4 py-3 text-right text-green-700">
{m.income_cents > 0 ? formatCurrency(m.income_cents) : "—"}
</td>
<td className="px-4 py-3 text-right text-red-600">
{m.expense_cents > 0 ? formatCurrency(m.expense_cents) : "—"}
</td>
<td
className={`px-4 py-3 text-right font-medium ${
m.balance_cents >= 0 ? "text-green-700" : "text-red-600"
}`}
>
{m.transaction_count > 0
? (m.balance_cents >= 0 ? "+" : "") + formatCurrency(m.balance_cents)
: "—"}
</td>
<td className="px-4 py-3 text-right text-slate-500">
{m.transaction_count > 0 ? m.transaction_count : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+24 -9
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, TrendingUp, TrendingDown, ChevronLeft, ChevronRight } from "lucide-react";
import { Plus, Pencil, Trash2, TrendingUp, TrendingDown, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { downloadExport } from "../api/client";
import {
useTransactions,
useDeleteTransaction,
@@ -82,15 +83,29 @@ export default function TransactionsPage() {
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold text-slate-800">Transactions</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90"
>
<Plus size={16} />
Nouvelle
</button>
<div className="flex gap-2">
<button
onClick={() => downloadExport(`/api/v1/export/csv?month=${month}`, `transactions_${month}.csv`).catch(() => addToast({ type: "error", message: "Erreur export CSV" }))}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
<Download size={14} /> CSV
</button>
<button
onClick={() => downloadExport(`/api/v1/export/pdf?month=${month}`, `transactions_${month}.pdf`).catch(() => addToast({ type: "error", message: "Erreur export PDF" }))}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
<Download size={14} /> PDF
</button>
<button
onClick={openCreate}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90"
>
<Plus size={16} />
Nouvelle
</button>
</div>
</div>
{/* Filters */}