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