e3fac99045
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>
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
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} | {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}"'},
|
|
)
|