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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nox (OpenClaw)
2026-03-17 16:48:26 +00:00
parent 9f7378cb69
commit e3fac99045
21 changed files with 2026 additions and 12 deletions
+67
View File
@@ -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)
+27
View File
@@ -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)
+175
View File
@@ -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"""
<tr>
<td>{tx.transaction_date.isoformat()}</td>
<td>{tx.category.name}</td>
<td>{tx.description or ""}</td>
<td style="color:{color};text-align:right;font-weight:600">
{sign}{amount_eur:.2f}
</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"/>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12px; color: #1e293b; margin: 40px; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
.subtitle {{ color: #64748b; margin-bottom: 24px; }}
.kpi {{ display: flex; gap: 40px; margin-bottom: 24px; }}
.kpi-card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 12px 20px; }}
.kpi-label {{ font-size: 11px; color: #64748b; }}
.kpi-value {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
table {{ width: 100%; border-collapse: collapse; }}
th {{ background: #1e293b; color: white; padding: 8px 12px; text-align: left; font-size: 11px; }}
td {{ padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }}
tr:nth-child(even) td {{ background: #f8fafc; }}
.footer {{ margin-top: 24px; font-size: 10px; color: #94a3b8; text-align: right; }}
</style>
</head>
<body>
<h1>Rapport de transactions</h1>
<div class="subtitle">Période : {month} &nbsp;|&nbsp; {current_user.full_name or current_user.email}</div>
<div class="kpi">
<div class="kpi-card">
<div class="kpi-label">Revenus</div>
<div class="kpi-value" style="color:#16a34a">+{total_income/100:.2f} €</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Dépenses</div>
<div class="kpi-value" style="color:#dc2626">-{total_expense/100:.2f} €</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Solde net</div>
<div class="kpi-value">{(total_income - total_expense)/100:+.2f} €</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Catégorie</th><th>Description</th><th style="text-align:right">Montant</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
<div class="footer">Généré le {utcnow().strftime('%d/%m/%Y')} — Budget Tracker</div>
</body>
</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}"'},
)
+73
View File
@@ -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)