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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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} | {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}"'},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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"] }),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user