From e3fac99045d471525c1cdab4bd44da5880099d58 Mon Sep 17 00:00:00 2001 From: "Nox (OpenClaw)" Date: Tue, 17 Mar 2026 16:48:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20advanced=20features=20=E2=80=94=20dashb?= =?UTF-8?q?oard,=20budgets,=20history,=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/main.py | 8 + backend/app/routers/budgets.py | 67 +++++ backend/app/routers/dashboard.py | 27 ++ backend/app/routers/export.py | 175 ++++++++++++ backend/app/routers/history.py | 73 +++++ backend/app/schemas/budget.py | 51 ++++ backend/app/schemas/dashboard.py | 37 +++ backend/app/schemas/history.py | 14 + backend/app/services/budget_service.py | 213 +++++++++++++++ backend/app/services/dashboard_service.py | 222 +++++++++++++++ frontend/src/App.tsx | 30 +- frontend/src/api/client.ts | 79 ++++++ frontend/src/api/types.ts | 71 +++++ frontend/src/hooks/useBudgets.ts | 49 ++++ frontend/src/hooks/useDashboard.ts | 9 + frontend/src/hooks/useHistory.ts | 9 + frontend/src/pages/BudgetsPage.tsx | 316 ++++++++++++++++++++++ frontend/src/pages/CategoriesPage.tsx | 121 +++++++++ frontend/src/pages/DashboardPage.tsx | 286 ++++++++++++++++++++ frontend/src/pages/HistoryPage.tsx | 148 ++++++++++ frontend/src/pages/TransactionsPage.tsx | 33 ++- 21 files changed, 2026 insertions(+), 12 deletions(-) create mode 100644 backend/app/routers/budgets.py create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/routers/export.py create mode 100644 backend/app/routers/history.py create mode 100644 backend/app/schemas/budget.py create mode 100644 backend/app/schemas/dashboard.py create mode 100644 backend/app/schemas/history.py create mode 100644 backend/app/services/budget_service.py create mode 100644 backend/app/services/dashboard_service.py create mode 100644 frontend/src/hooks/useBudgets.ts create mode 100644 frontend/src/hooks/useDashboard.ts create mode 100644 frontend/src/hooks/useHistory.ts create mode 100644 frontend/src/pages/BudgetsPage.tsx create mode 100644 frontend/src/pages/CategoriesPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/HistoryPage.tsx 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} €
+
+
+ + + + + + + + {rows_html} + +
DateCatégorieDescriptionMontant
+ + +""" + + 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"} +

+
+ {!editing && ( +
+ + +
+ )} +
+ + 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 + /> +
+
+ + +
+
+
+
+ ); +} + +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 ( +
+
+

{label}

+ {icon} +
+

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 */} +
+ + + + + + + + + + + + {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}`)} + > + + + + + + + ); + })} + +
MoisRevenusDépensesSolde netTransactions
+
+
+ {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 */}