diff --git a/backend/app/main.py b/backend/app/main.py
index 1ece1f0..cdaa569 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -7,7 +7,11 @@ from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.database import engine
+from app.routers.budgets import router as budgets_router
from app.routers.categories import router as categories_router
+from app.routers.dashboard import router as dashboard_router
+from app.routers.export import router as export_router
+from app.routers.history import router as history_router
from app.routers.transactions import router as transactions_router
@@ -40,6 +44,10 @@ def create_app() -> FastAPI:
app.include_router(auth_router, prefix=api_prefix)
app.include_router(transactions_router, prefix=api_prefix)
app.include_router(categories_router, prefix=api_prefix)
+ app.include_router(dashboard_router, prefix=api_prefix)
+ app.include_router(budgets_router, prefix=api_prefix)
+ app.include_router(history_router, prefix=api_prefix)
+ app.include_router(export_router, prefix=api_prefix)
return app
diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py
new file mode 100644
index 0000000..d97fc5a
--- /dev/null
+++ b/backend/app/routers/budgets.py
@@ -0,0 +1,67 @@
+import uuid
+
+from fastapi import APIRouter, Depends, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.dependencies import get_current_user
+from app.database import get_session
+from app.models.user import User
+from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
+from app.services import budget_service
+from app.utils import utcnow
+
+router = APIRouter(prefix="/budgets", tags=["budgets"])
+
+
+def _current_month() -> str:
+ now = utcnow()
+ return f"{now.year}-{now.month:02d}"
+
+
+@router.get("", response_model=list[BudgetResponse])
+async def list_budgets(
+ month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> list[BudgetResponse]:
+ if month is None:
+ month = _current_month()
+ return await budget_service.list_budgets(session, current_user.id, month)
+
+
+@router.post("", response_model=BudgetResponse, status_code=status.HTTP_201_CREATED)
+async def create_budget(
+ data: BudgetCreate,
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> BudgetResponse:
+ return await budget_service.create_budget(session, current_user.id, data)
+
+
+@router.put("/{budget_id}", response_model=BudgetResponse)
+async def update_budget(
+ budget_id: uuid.UUID,
+ data: BudgetUpdate,
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> BudgetResponse:
+ return await budget_service.update_budget(session, current_user.id, budget_id, data)
+
+
+@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_budget(
+ budget_id: uuid.UUID,
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> None:
+ await budget_service.delete_budget(session, current_user.id, budget_id)
+
+
+@router.post("/rollover", response_model=list[BudgetResponse], status_code=status.HTTP_201_CREATED)
+async def rollover_budgets(
+ month: str = Query(..., description="Target month YYYY-MM to copy budgets into"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> list[BudgetResponse]:
+ """Copy budgets from month-1 into the given month (skips existing ones)."""
+ return await budget_service.rollover_budgets(session, current_user.id, month)
diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
new file mode 100644
index 0000000..d512d2c
--- /dev/null
+++ b/backend/app/routers/dashboard.py
@@ -0,0 +1,27 @@
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.dependencies import get_current_user
+from app.database import get_session
+from app.models.user import User
+from app.schemas.dashboard import DashboardResponse
+from app.services import dashboard_service
+from app.utils import utcnow
+
+router = APIRouter(prefix="/dashboard", tags=["dashboard"])
+
+
+def _current_month() -> str:
+ now = utcnow()
+ return f"{now.year}-{now.month:02d}"
+
+
+@router.get("", response_model=DashboardResponse)
+async def get_dashboard(
+ month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> DashboardResponse:
+ if month is None:
+ month = _current_month()
+ return await dashboard_service.get_dashboard(session, current_user.id, month)
diff --git a/backend/app/routers/export.py b/backend/app/routers/export.py
new file mode 100644
index 0000000..acff184
--- /dev/null
+++ b/backend/app/routers/export.py
@@ -0,0 +1,175 @@
+import csv
+import io
+from datetime import date
+
+from fastapi import APIRouter, Depends, Query
+from fastapi.responses import StreamingResponse
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.auth.dependencies import get_current_user
+from app.database import get_session
+from app.models.transaction import Transaction
+from app.models.user import User
+from app.utils import utcnow
+
+router = APIRouter(prefix="/export", tags=["export"])
+
+
+def _month_bounds(month: str) -> tuple[date, date]:
+ year, mon = map(int, month.split("-"))
+ start = date(year, mon, 1)
+ if mon == 12:
+ end = date(year + 1, 1, 1)
+ else:
+ end = date(year, mon + 1, 1)
+ return start, end
+
+
+async def _fetch_transactions(
+ session: AsyncSession,
+ user_id,
+ month: str,
+) -> list[Transaction]:
+ start, end = _month_bounds(month)
+ result = await session.execute(
+ select(Transaction)
+ .options(selectinload(Transaction.category))
+ .where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ .order_by(Transaction.transaction_date, Transaction.created_at)
+ )
+ return list(result.scalars().all())
+
+
+@router.get("/csv")
+async def export_csv(
+ month: str = Query(default=None, description="Month YYYY-MM"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> StreamingResponse:
+ if month is None:
+ now = utcnow()
+ month = f"{now.year}-{now.month:02d}"
+
+ transactions = await _fetch_transactions(session, current_user.id, month)
+
+ output = io.StringIO()
+ writer = csv.writer(output, delimiter=";")
+ writer.writerow(["Date", "Type", "Catégorie", "Description", "Montant (€)"])
+ for tx in transactions:
+ amount_eur = tx.amount_cents / 100
+ if tx.type.value == "expense":
+ amount_eur = -amount_eur
+ writer.writerow([
+ tx.transaction_date.isoformat(),
+ tx.type.value,
+ tx.category.name,
+ tx.description or "",
+ f"{amount_eur:.2f}",
+ ])
+
+ output.seek(0)
+ filename = f"transactions_{month}.csv"
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+@router.get("/pdf")
+async def export_pdf(
+ month: str = Query(default=None, description="Month YYYY-MM"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> StreamingResponse:
+ if month is None:
+ now = utcnow()
+ month = f"{now.year}-{now.month:02d}"
+
+ transactions = await _fetch_transactions(session, current_user.id, month)
+
+ total_income = sum(t.amount_cents for t in transactions if t.type.value == "income")
+ total_expense = sum(t.amount_cents for t in transactions if t.type.value == "expense")
+
+ rows_html = ""
+ for tx in transactions:
+ amount_eur = tx.amount_cents / 100
+ color = "#16a34a" if tx.type.value == "income" else "#dc2626"
+ sign = "+" if tx.type.value == "income" else "-"
+ rows_html += f"""
+
+ | {tx.transaction_date.isoformat()} |
+ {tx.category.name} |
+ {tx.description or "—"} |
+
+ {sign}{amount_eur:.2f} €
+ |
+
"""
+
+ html = f"""
+
+
+
+
+
+
+ Rapport de transactions
+ Période : {month} | {current_user.full_name or current_user.email}
+
+
+
Revenus
+
+{total_income/100:.2f} €
+
+
+
Dépenses
+
-{total_expense/100:.2f} €
+
+
+
Solde net
+
{(total_income - total_expense)/100:+.2f} €
+
+
+
+
+
+ | Date | Catégorie | Description | Montant |
+
+
+
+ {rows_html}
+
+
+
+
+"""
+
+ import weasyprint # noqa: PLC0415
+
+ pdf_bytes = weasyprint.HTML(string=html).write_pdf()
+ filename = f"transactions_{month}.pdf"
+ return StreamingResponse(
+ iter([pdf_bytes]),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py
new file mode 100644
index 0000000..d1b732f
--- /dev/null
+++ b/backend/app/routers/history.py
@@ -0,0 +1,73 @@
+from datetime import date
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.dependencies import get_current_user
+from app.database import get_session
+from app.models.transaction import Transaction, TransactionType
+from app.models.user import User
+from app.schemas.history import HistoryResponse, MonthSummary
+from app.utils import utcnow
+
+router = APIRouter(prefix="/history", tags=["history"])
+
+
+@router.get("", response_model=HistoryResponse)
+async def get_history(
+ year: int = Query(default=None, description="Year (defaults to current year)"),
+ session: AsyncSession = Depends(get_session),
+ current_user: User = Depends(get_current_user),
+) -> HistoryResponse:
+ if year is None:
+ year = utcnow().year
+
+ months: list[MonthSummary] = []
+ for mon in range(1, 13):
+ start = date(year, mon, 1)
+ if mon == 12:
+ end = date(year + 1, 1, 1)
+ else:
+ end = date(year, mon + 1, 1)
+
+ result = await session.execute(
+ select(
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.income, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("income"),
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.expense, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("expense"),
+ func.count(Transaction.id).label("count"),
+ ).where(
+ Transaction.user_id == current_user.id,
+ Transaction.deleted_at.is_(None),
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ )
+ row = result.one()
+ months.append(
+ MonthSummary(
+ month=f"{year}-{mon:02d}",
+ income_cents=row.income,
+ expense_cents=row.expense,
+ balance_cents=row.income - row.expense,
+ transaction_count=row.count,
+ )
+ )
+
+ return HistoryResponse(year=year, months=months)
diff --git a/backend/app/schemas/budget.py b/backend/app/schemas/budget.py
new file mode 100644
index 0000000..71b7cdc
--- /dev/null
+++ b/backend/app/schemas/budget.py
@@ -0,0 +1,51 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, field_validator
+
+
+class BudgetCreate(BaseModel):
+ category_id: uuid.UUID
+ month: str # YYYY-MM
+ limit_cents: int
+
+ @field_validator("limit_cents")
+ @classmethod
+ def limit_positive(cls, v: int) -> int:
+ if v <= 0:
+ raise ValueError("limit_cents must be positive")
+ return v
+
+ @field_validator("month")
+ @classmethod
+ def month_format(cls, v: str) -> str:
+ parts = v.split("-")
+ if len(parts) != 2 or not all(p.isdigit() for p in parts):
+ raise ValueError("month must be in YYYY-MM format")
+ return v
+
+
+class BudgetUpdate(BaseModel):
+ limit_cents: int
+
+ @field_validator("limit_cents")
+ @classmethod
+ def limit_positive(cls, v: int) -> int:
+ if v <= 0:
+ raise ValueError("limit_cents must be positive")
+ return v
+
+
+class BudgetResponse(BaseModel):
+ id: uuid.UUID
+ category_id: uuid.UUID
+ category_name: str
+ category_color: str | None
+ month: str
+ limit_cents: int
+ spent_cents: int
+ remaining_cents: int
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py
new file mode 100644
index 0000000..da8ea46
--- /dev/null
+++ b/backend/app/schemas/dashboard.py
@@ -0,0 +1,37 @@
+import uuid
+
+from pydantic import BaseModel
+
+
+class CategoryExpense(BaseModel):
+ category_id: uuid.UUID
+ category_name: str
+ color: str | None
+ amount_cents: int
+
+ model_config = {"from_attributes": True}
+
+
+class MonthlyTrend(BaseModel):
+ month: str # YYYY-MM
+ income_cents: int
+ expense_cents: int
+
+
+class BudgetAlert(BaseModel):
+ budget_id: uuid.UUID
+ category_name: str
+ limit_cents: int
+ spent_cents: int
+ percentage: float
+
+
+class DashboardResponse(BaseModel):
+ month: str
+ balance_cents: int # all-time cumulative balance
+ total_income_cents: int # month income
+ total_expense_cents: int # month expenses
+ net_cents: int # month net
+ by_category: list[CategoryExpense]
+ monthly_trend: list[MonthlyTrend]
+ budget_alerts: list[BudgetAlert]
diff --git a/backend/app/schemas/history.py b/backend/app/schemas/history.py
new file mode 100644
index 0000000..566cbfc
--- /dev/null
+++ b/backend/app/schemas/history.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel
+
+
+class MonthSummary(BaseModel):
+ month: str # YYYY-MM
+ income_cents: int
+ expense_cents: int
+ balance_cents: int # month net
+ transaction_count: int
+
+
+class HistoryResponse(BaseModel):
+ year: int
+ months: list[MonthSummary]
diff --git a/backend/app/services/budget_service.py b/backend/app/services/budget_service.py
new file mode 100644
index 0000000..e2f2c73
--- /dev/null
+++ b/backend/app/services/budget_service.py
@@ -0,0 +1,213 @@
+import uuid
+from datetime import date
+
+from fastapi import HTTPException, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models.budget import Budget
+from app.models.transaction import Transaction, TransactionType
+from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
+from app.utils import utcnow
+
+
+def _month_bounds(month: str) -> tuple[date, date]:
+ year, mon = map(int, month.split("-"))
+ start = date(year, mon, 1)
+ if mon == 12:
+ end = date(year + 1, 1, 1)
+ else:
+ end = date(year, mon + 1, 1)
+ return start, end
+
+
+def _prev_month(month: str) -> str:
+ year, mon = map(int, month.split("-"))
+ if mon == 1:
+ return f"{year - 1}-12"
+ return f"{year}-{mon - 1:02d}"
+
+
+async def _spent_cents(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ category_id: uuid.UUID,
+ month: str,
+) -> int:
+ start, end = _month_bounds(month)
+ result = await session.execute(
+ select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.type == TransactionType.expense,
+ Transaction.category_id == category_id,
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ )
+ return result.scalar_one()
+
+
+async def _to_response(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ budget: Budget,
+) -> BudgetResponse:
+ spent = await _spent_cents(session, user_id, budget.category_id, budget.month)
+ return BudgetResponse(
+ id=budget.id,
+ category_id=budget.category_id,
+ category_name=budget.category.name,
+ category_color=budget.category.color,
+ month=budget.month,
+ limit_cents=budget.limit_cents,
+ spent_cents=spent,
+ remaining_cents=max(0, budget.limit_cents - spent),
+ created_at=budget.created_at,
+ updated_at=budget.updated_at,
+ )
+
+
+async def list_budgets(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ month: str,
+) -> list[BudgetResponse]:
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.user_id == user_id, Budget.month == month)
+ .order_by(Budget.created_at)
+ )
+ budgets = result.scalars().all()
+ return [await _to_response(session, user_id, b) for b in budgets]
+
+
+async def create_budget(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ data: BudgetCreate,
+) -> BudgetResponse:
+ # Check for duplicate
+ existing = await session.execute(
+ select(Budget).where(
+ Budget.user_id == user_id,
+ Budget.category_id == data.category_id,
+ Budget.month == data.month,
+ )
+ )
+ if existing.scalar_one_or_none() is not None:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Budget already exists for this category and month",
+ )
+
+ budget = Budget(
+ user_id=user_id,
+ category_id=data.category_id,
+ month=data.month,
+ limit_cents=data.limit_cents,
+ )
+ session.add(budget)
+ await session.commit()
+
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.id == budget.id)
+ )
+ budget = result.scalar_one()
+ return await _to_response(session, user_id, budget)
+
+
+async def update_budget(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ budget_id: uuid.UUID,
+ data: BudgetUpdate,
+) -> BudgetResponse:
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.id == budget_id, Budget.user_id == user_id)
+ )
+ budget = result.scalar_one_or_none()
+ if budget is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
+
+ budget.limit_cents = data.limit_cents
+ budget.updated_at = utcnow()
+ await session.commit()
+
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.id == budget_id)
+ )
+ budget = result.scalar_one()
+ return await _to_response(session, user_id, budget)
+
+
+async def delete_budget(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ budget_id: uuid.UUID,
+) -> None:
+ result = await session.execute(
+ select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
+ )
+ budget = result.scalar_one_or_none()
+ if budget is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
+ await session.delete(budget)
+ await session.commit()
+
+
+async def rollover_budgets(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ target_month: str,
+) -> list[BudgetResponse]:
+ """Copy budgets from previous month to target_month (skip existing)."""
+ source_month = _prev_month(target_month)
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.user_id == user_id, Budget.month == source_month)
+ )
+ source_budgets = result.scalars().all()
+
+ created = []
+ for src in source_budgets:
+ existing = await session.execute(
+ select(Budget).where(
+ Budget.user_id == user_id,
+ Budget.category_id == src.category_id,
+ Budget.month == target_month,
+ )
+ )
+ if existing.scalar_one_or_none() is not None:
+ continue
+ new_budget = Budget(
+ user_id=user_id,
+ category_id=src.category_id,
+ month=target_month,
+ limit_cents=src.limit_cents,
+ )
+ session.add(new_budget)
+ created.append(new_budget)
+
+ await session.commit()
+
+ responses = []
+ for b in created:
+ result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(Budget.id == b.id)
+ )
+ b = result.scalar_one()
+ responses.append(await _to_response(session, user_id, b))
+
+ return responses
diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py
new file mode 100644
index 0000000..a36900c
--- /dev/null
+++ b/backend/app/services/dashboard_service.py
@@ -0,0 +1,222 @@
+import uuid
+from datetime import date
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models.budget import Budget
+from app.models.category import Category
+from app.models.transaction import Transaction, TransactionType
+from app.schemas.dashboard import (
+ BudgetAlert,
+ CategoryExpense,
+ DashboardResponse,
+ MonthlyTrend,
+)
+
+
+def _month_bounds(month: str) -> tuple[date, date]:
+ year, mon = map(int, month.split("-"))
+ start = date(year, mon, 1)
+ if mon == 12:
+ end = date(year + 1, 1, 1)
+ else:
+ end = date(year, mon + 1, 1)
+ return start, end
+
+
+def _prev_month(month: str) -> str:
+ year, mon = map(int, month.split("-"))
+ if mon == 1:
+ return f"{year - 1}-12"
+ return f"{year}-{mon - 1:02d}"
+
+
+async def get_dashboard(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ month: str,
+) -> DashboardResponse:
+ start, end = _month_bounds(month)
+
+ # --- All-time balance (cumulative since origin) ---
+ balance_result = await session.execute(
+ select(
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.income, Transaction.amount_cents),
+ else_=-Transaction.amount_cents,
+ )
+ ),
+ 0,
+ )
+ ).where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ )
+ )
+ balance_cents: int = balance_result.scalar_one()
+
+ # --- Month income & expenses ---
+ month_result = await session.execute(
+ select(
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.income, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("income"),
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.expense, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("expense"),
+ ).where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ )
+ month_row = month_result.one()
+ total_income_cents: int = month_row.income
+ total_expense_cents: int = month_row.expense
+
+ # --- Expenses by category for the month ---
+ cat_result = await session.execute(
+ select(
+ Transaction.category_id,
+ func.sum(Transaction.amount_cents).label("total"),
+ )
+ .where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.type == TransactionType.expense,
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ .group_by(Transaction.category_id)
+ )
+ cat_rows = cat_result.all()
+
+ # Fetch category names
+ category_ids = [row.category_id for row in cat_rows]
+ by_category: list[CategoryExpense] = []
+ if category_ids:
+ cats_result = await session.execute(
+ select(Category).where(Category.id.in_(category_ids))
+ )
+ cats_map = {c.id: c for c in cats_result.scalars().all()}
+ for row in cat_rows:
+ cat = cats_map.get(row.category_id)
+ by_category.append(
+ CategoryExpense(
+ category_id=row.category_id,
+ category_name=cat.name if cat else "?",
+ color=cat.color if cat else None,
+ amount_cents=row.total,
+ )
+ )
+
+ # --- Monthly trend: last 6 months ---
+ monthly_trend: list[MonthlyTrend] = []
+ cur = month
+ months_to_fetch = []
+ for _ in range(6):
+ months_to_fetch.append(cur)
+ cur = _prev_month(cur)
+ months_to_fetch.reverse()
+
+ for m in months_to_fetch:
+ m_start, m_end = _month_bounds(m)
+ trend_result = await session.execute(
+ select(
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.income, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("income"),
+ func.coalesce(
+ func.sum(
+ func.case(
+ (Transaction.type == TransactionType.expense, Transaction.amount_cents),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("expense"),
+ ).where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.transaction_date >= m_start,
+ Transaction.transaction_date < m_end,
+ )
+ )
+ trend_row = trend_result.one()
+ monthly_trend.append(
+ MonthlyTrend(
+ month=m,
+ income_cents=trend_row.income,
+ expense_cents=trend_row.expense,
+ )
+ )
+
+ # --- Budget alerts (>= 80% spent) ---
+ budgets_result = await session.execute(
+ select(Budget)
+ .options(selectinload(Budget.category))
+ .where(
+ Budget.user_id == user_id,
+ Budget.month == month,
+ )
+ )
+ budgets = budgets_result.scalars().all()
+
+ budget_alerts: list[BudgetAlert] = []
+ for budget in budgets:
+ spent_result = await session.execute(
+ select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
+ Transaction.user_id == user_id,
+ Transaction.deleted_at.is_(None),
+ Transaction.type == TransactionType.expense,
+ Transaction.category_id == budget.category_id,
+ Transaction.transaction_date >= start,
+ Transaction.transaction_date < end,
+ )
+ )
+ spent_cents: int = spent_result.scalar_one()
+ percentage = (spent_cents / budget.limit_cents * 100) if budget.limit_cents > 0 else 0
+ if percentage >= 80:
+ budget_alerts.append(
+ BudgetAlert(
+ budget_id=budget.id,
+ category_name=budget.category.name,
+ limit_cents=budget.limit_cents,
+ spent_cents=spent_cents,
+ percentage=round(percentage, 1),
+ )
+ )
+
+ return DashboardResponse(
+ month=month,
+ balance_cents=balance_cents,
+ total_income_cents=total_income_cents,
+ total_expense_cents=total_expense_cents,
+ net_cents=total_income_cents - total_expense_cents,
+ by_category=by_category,
+ monthly_trend=monthly_trend,
+ budget_alerts=budget_alerts,
+ )
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a87086b..b45974a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
- } />
+ {/* Public routes */}
+ } />
+ } />
+
+ {/* Protected routes with layout */}
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ {/* Catch-all */}
+ } />
);
}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 807d204..66bf823 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -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 {
await apiClient.delete(`/categories/${id}`);
}
+
+// ---------------------------------------------------------------------------
+// Dashboard API
+// ---------------------------------------------------------------------------
+export async function getDashboard(month?: string): Promise {
+ const { data } = await apiClient.get("/dashboard", {
+ params: month ? { month } : undefined,
+ });
+ return data;
+}
+
+// ---------------------------------------------------------------------------
+// Budgets API
+// ---------------------------------------------------------------------------
+export async function getBudgets(month?: string): Promise {
+ const { data } = await apiClient.get("/budgets", {
+ params: month ? { month } : undefined,
+ });
+ return data;
+}
+
+export async function createBudget(payload: CreateBudgetPayload): Promise {
+ const { data } = await apiClient.post("/budgets", payload);
+ return data;
+}
+
+export async function updateBudget(id: string, payload: UpdateBudgetPayload): Promise {
+ const { data } = await apiClient.put(`/budgets/${id}`, payload);
+ return data;
+}
+
+export async function deleteBudget(id: string): Promise {
+ await apiClient.delete(`/budgets/${id}`);
+}
+
+export async function rolloverBudgets(month: string): Promise {
+ const { data } = await apiClient.post("/budgets/rollover", null, {
+ params: { month },
+ });
+ return data;
+}
+
+// ---------------------------------------------------------------------------
+// History API
+// ---------------------------------------------------------------------------
+export async function getHistory(year?: number): Promise {
+ const { data } = await apiClient.get("/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 {
+ const { data } = await apiClient.get(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);
+}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index cb715b7..7f97cdc 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -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[];
+}
diff --git a/frontend/src/hooks/useBudgets.ts b/frontend/src/hooks/useBudgets.ts
new file mode 100644
index 0000000..5342044
--- /dev/null
+++ b/frontend/src/hooks/useBudgets.ts
@@ -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"] }),
+ });
+}
diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts
new file mode 100644
index 0000000..621afef
--- /dev/null
+++ b/frontend/src/hooks/useDashboard.ts
@@ -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),
+ });
+}
diff --git a/frontend/src/hooks/useHistory.ts b/frontend/src/hooks/useHistory.ts
new file mode 100644
index 0000000..f4c0d98
--- /dev/null
+++ b/frontend/src/hooks/useHistory.ts
@@ -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),
+ });
+}
diff --git a/frontend/src/pages/BudgetsPage.tsx b/frontend/src/pages/BudgetsPage.tsx
new file mode 100644
index 0000000..35bc93c
--- /dev/null
+++ b/frontend/src/pages/BudgetsPage.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+
+
+ {editing ? "Modifier le budget" : "Nouveau budget"}
+
+
+
+
+ );
+}
+
+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();
+ const [deleteTarget, setDeleteTarget] = useState();
+
+ 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 (
+
+ {/* Header */}
+
+
+
+
+ {formatMonthLabel(month)}
+
+
+
+
+
+
+
+
+
+ {/* Budget list */}
+ {isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : !budgets || budgets.length === 0 ? (
+
+ Aucun budget défini pour ce mois.
+
+ ) : (
+
+ {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 (
+
+
+
+ {budget.category_color && (
+
+ )}
+
+ {budget.category_name}
+
+ {over && (
+
+ Dépassé
+
+ )}
+
+
+
+
+
+
+
+
+ {formatCurrency(budget.spent_cents)} dépensés
+
+ {pct}% — limite {formatCurrency(budget.limit_cents)}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Form modal */}
+ {formOpen && (
+
{ setFormOpen(false); setEditingBudget(undefined); }}
+ />
+ )}
+
+ {/* Delete confirm */}
+ {deleteTarget && (
+ setDeleteTarget(undefined)}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/CategoriesPage.tsx b/frontend/src/pages/CategoriesPage.tsx
new file mode 100644
index 0000000..b8778cc
--- /dev/null
+++ b/frontend/src/pages/CategoriesPage.tsx
@@ -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();
+ const [deleteTarget, setDeleteTarget] = useState();
+
+ 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 (
+
+ {cat.color && (
+
+ )}
+
{cat.name}
+ {cat.is_default && (
+
+ Défaut
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
Catégories
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+ <>
+
+
+ Dépenses
+
+
+ {expense.map((c) => )}
+
+
+
+
+ Revenus
+
+
+ {income.map((c) => )}
+
+
+ >
+ )}
+
+
setModalOpen(false)}
+ category={editingCat}
+ />
+
+ {deleteTarget && (
+ setDeleteTarget(undefined)}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..8b5d6df
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -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 (
+
+
+
0 ? "text-green-600" : "text-slate-900"}`}>
+ {formatted}
+
+
+ );
+}
+
+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 (
+
+ {/* Header */}
+
+
+
+
+ {formatMonthLabel(month)}
+
+
+
+
+
+
+
+
+
+ {/* Budget alerts */}
+ {data?.budget_alerts && data.budget_alerts.length > 0 && (
+
+ {data.budget_alerts.map((alert) => (
+
= 100
+ ? "border-red-200 bg-red-50 text-red-800"
+ : "border-orange-200 bg-orange-50 text-orange-800"
+ }`}
+ >
+
+
+ {alert.category_name} :{" "}
+ {formatCurrency(alert.spent_cents)} dépensés sur{" "}
+ {formatCurrency(alert.limit_cents)} ({alert.percentage}%)
+ {alert.percentage >= 100 && " — Budget dépassé !"}
+
+
+
+ ))}
+
+ )}
+
+ {/* KPI Cards */}
+ {isLoading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ }
+ color="bg-indigo-50"
+ />
+ }
+ color="bg-green-50"
+ />
+ }
+ color="bg-red-50"
+ />
+ }
+ color="bg-blue-50"
+ signed
+ />
+
+ )}
+
+ {/* Charts */}
+
+ {/* Bar chart — monthly trend */}
+
+
+ Tendance 6 derniers mois
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+ `${v}€`} />
+ `${v.toFixed(2)} €`} />
+
+
+
+
+
+ )}
+
+
+ {/* Pie chart — expenses by category */}
+
+
+ Dépenses par catégorie
+
+ {isLoading ? (
+
+ ) : pieData.length === 0 ? (
+
+ Aucune dépense ce mois
+
+ ) : (
+
+
+
+ `${name} ${(percent * 100).toFixed(0)}%`
+ }
+ labelLine={false}
+ >
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+ `${v.toFixed(2)} €`} />
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx
new file mode 100644
index 0000000..2036dcc
--- /dev/null
+++ b/frontend/src/pages/HistoryPage.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
{year}
+
+
+
+ {/* Line chart */}
+
+
+ Revenus et dépenses {year}
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+ `${v}€`} />
+ `${v.toFixed(2)} €`} />
+
+
+
+
+
+ )}
+
+
+ {/* Monthly table */}
+
+
+
+
+ | Mois |
+ Revenus |
+ Dépenses |
+ Solde net |
+ Transactions |
+
+
+
+ {isLoading
+ ? Array.from({ length: 12 }).map((_, i) => (
+
+ {Array.from({ length: 5 }).map((__, j) => (
+ |
+
+ |
+ ))}
+
+ ))
+ : data?.months.map((m, i) => {
+ const hasData = m.transaction_count > 0;
+ return (
+ hasData && navigate(`/?month=${m.month}`)}
+ >
+ |
+ {MONTH_NAMES[i]}
+ |
+
+ {m.income_cents > 0 ? formatCurrency(m.income_cents) : "—"}
+ |
+
+ {m.expense_cents > 0 ? formatCurrency(m.expense_cents) : "—"}
+ |
+ = 0 ? "text-green-700" : "text-red-600"
+ }`}
+ >
+ {m.transaction_count > 0
+ ? (m.balance_cents >= 0 ? "+" : "") + formatCurrency(m.balance_cents)
+ : "—"}
+ |
+
+ {m.transaction_count > 0 ? m.transaction_count : "—"}
+ |
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx
index 1768619..70ee37e 100644
--- a/frontend/src/pages/TransactionsPage.tsx
+++ b/frontend/src/pages/TransactionsPage.tsx
@@ -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 (
-
+
Transactions
-
+
+
+
+
+
{/* Filters */}