Compare commits

..

3 Commits

Author SHA1 Message Date
Nox (OpenClaw) 434de9aa3e feat: production docker + documentation
- Backend Dockerfile: multi-stage build with venv, gunicorn+uvicorn workers, entrypoint runs alembic then gunicorn
- Frontend Dockerfile: multi-stage with npm ci, nginx:1.27-alpine runtime
- nginx.conf: gzip compression, security headers (X-Frame-Options, X-Content-Type-Options, etc.), static asset caching, correct API proxy preserving /api/ prefix
- docker-compose.yml: production config — db healthcheck, backend healthcheck, frontend depends_on backend healthy, no exposed backend port
- docker-compose.override.yml: dev hot-reload — uvicorn --reload with source mount, npm run dev on port 5173
- Rate limiting: slowapi middleware on /auth/login (10/min) and /auth/register (5/min)
- README.md: full documentation with architecture, quick start, API table, dev/prod instructions, tests
- .env.example: all variables documented with comments
- .gitignore: extended with *.pyc, *.cover, .ruff_cache, frontend/.vite, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:07:38 +00:00
Nox (OpenClaw) e3fac99045 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>
2026-03-17 16:48:26 +00:00
Nox (OpenClaw) 9f7378cb69 feat: frontend core — auth, layout, transactions, categories 2026-03-17 16:36:47 +00:00
45 changed files with 4374 additions and 68 deletions
+17 -8
View File
@@ -1,17 +1,26 @@
# ── PostgreSQL ──────────────────────────────────────────
# ── PostgreSQL ──────────────────────────────────────────────────────────────
POSTGRES_USER=budget
POSTGRES_PASSWORD=budget
POSTGRES_DB=budget_tracker
DB_PORT=5432
# ── Backend (FastAPI) ──────────────────────────────────
# ── Backend (FastAPI) ───────────────────────────────────────────────────────
# Full connection URL — built from the vars above by docker-compose
DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker
# IMPORTANT: generate a strong random key for production
# python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
DEBUG=false
BACKEND_PORT=8000
# ── Frontend ───────────────────────────────────────────
FRONTEND_PORT=3000
# Comma-separated list of allowed origins (JSON array syntax)
# Production: set to your actual frontend domain, e.g. ["https://budget.example.com"]
CORS_ORIGINS=["http://localhost"]
# ── Ports (override for local conflicts) ────────────────────────────────────
# Production: frontend is exposed on port 80 (FRONTEND_PORT)
FRONTEND_PORT=80
# Dev only:
DB_PORT=5432
BACKEND_PORT=8000
+19 -6
View File
@@ -1,32 +1,45 @@
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.egg-info/
dist/
.venv/
venv/
*.cover
.coverage
htmlcov/
.pytest_cache/
.ruff_cache/
*.egg
# Node
node_modules/
frontend/dist/
frontend/.vite/
*.tsbuildinfo
# Environment
# Environment — never commit secrets
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS
.DS_Store
Thumbs.db
desktop.ini
# Docker
pgdata/
# Testing
.coverage
htmlcov/
.pytest_cache/
# Build artifacts
dist/
build/
+139 -21
View File
@@ -1,37 +1,155 @@
# Budget Tracker
Application web de suivi de budget personnel.
A personal finance web application to track income, expenses, and budgets with analytics and CSV/PDF export.
## Stack technique
<!-- Screenshots placeholder -->
<!-- ![Dashboard](docs/screenshots/dashboard.png) -->
- **Backend** : FastAPI, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16
- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui
- **Graphiques** : Recharts
- **Export** : CSV, PDF (WeasyPrint)
---
## Démarrage rapide
## Stack
| Layer | Technology |
|-----------|-----------------------------------------------------|
| Backend | FastAPI 0.115, SQLAlchemy 2.0 async, Alembic |
| Database | PostgreSQL 16 |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, Radix UI |
| Charts | Recharts |
| Export | CSV (native), PDF (WeasyPrint) |
| Server | Gunicorn + Uvicorn workers, Nginx 1.27 |
---
## Quick Start
```bash
# Copier et configurer les variables d'environnement
# 1. Copy and configure environment
cp .env.example .env
# Lancer tous les services (dev avec hot-reload)
docker compose up
# 2. Start all services (production mode)
docker compose up -d
# Backend : http://localhost:8000
# Frontend : http://localhost:5173
# Health check : http://localhost:8000/health
# App is available at http://localhost
# API docs at http://localhost/api/v1/docs
```
## Développement
> The backend automatically runs `alembic upgrade head` on startup.
---
## Architecture
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Browser │──80──▶│ Nginx (frontend) │──/api/▶│ FastAPI │
└─────────────┘ │ serves SPA dist │ │ (gunicorn) │
└──────────────────┘ └──────┬───────┘
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
- **Frontend**: React SPA served by Nginx. All `/api/*` requests are proxied to the backend.
- **Backend**: FastAPI with async SQLAlchemy. Exposes REST API under `/api/v1`.
- **Database**: PostgreSQL with Alembic migrations applied at container start.
---
## API Endpoints
Interactive docs (Swagger UI): `http://localhost/api/v1/docs`
| Module | Prefix | Description |
|--------------|-------------------------|------------------------------------|
| Auth | `/api/v1/auth` | Register, login, refresh, logout |
| Transactions | `/api/v1/transactions` | CRUD + pagination + filters |
| Categories | `/api/v1/categories` | Income / expense categories |
| Budgets | `/api/v1/budgets` | Monthly budgets + rollover |
| Dashboard | `/api/v1/dashboard` | Monthly summary + charts data |
| History | `/api/v1/history` | Year-over-year analytics |
| Export | `/api/v1/export` | CSV and PDF export |
| Health | `/health` | Liveness probe |
---
## Development (hot-reload)
The `docker-compose.override.yml` is automatically merged in dev, enabling:
- Backend: `uvicorn --reload` with source code mounted
- Frontend: `npm run dev` (Vite HMR) on port 5173
```bash
# Linting backend
cd backend && ruff check .
# Start dev environment
cp .env.example .env
docker compose up
# Tests backend
cd backend && pytest
# Format frontend
cd frontend && npm run format
# Backend API: http://localhost:8000
# Frontend dev server: http://localhost:5173
# API docs: http://localhost:8000/api/v1/docs
```
---
## Production
```bash
# Use only the base compose file (no override)
docker compose -f docker-compose.yml up -d
# Generate a secure SECRET_KEY
python -c "import secrets; print(secrets.token_hex(32))"
# Set in .env:
# SECRET_KEY=<generated value>
# CORS_ORIGINS=["https://your-domain.com"]
```
---
## Tests
```bash
# Backend tests
cd backend
pip install -r requirements.txt
pytest
# With coverage
pytest --cov=app --cov-report=term-missing
# Frontend type check
cd frontend
npm run build # tsc -b + vite build
```
---
## Project Structure
```
budget-tracker/
├── backend/
│ ├── app/
│ │ ├── auth/ # JWT auth (register, login, refresh, logout)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── routers/ # FastAPI routers (transactions, budgets, …)
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # Business logic layer
│ │ ├── config.py # Settings (pydantic-settings)
│ │ ├── database.py # Async engine + session factory
│ │ └── main.py # App factory + middleware
│ ├── alembic/ # Database migrations
│ ├── tests/ # pytest test suite
│ ├── Dockerfile # Multi-stage build (venv → slim runtime)
│ └── entrypoint.sh # alembic upgrade head → gunicorn
└── frontend/
├── src/
│ ├── api/ # Axios client + API functions
│ ├── components/ # Shared UI components
│ ├── hooks/ # React Query hooks
│ ├── pages/ # Route-level page components
│ └── stores/ # Zustand auth store
├── Dockerfile # Multi-stage build (npm ci → nginx)
└── nginx.conf # Gzip, security headers, SPA fallback, API proxy
```
+32 -3
View File
@@ -1,13 +1,19 @@
FROM python:3.12-slim AS base
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM python:3.12-slim AS build
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps for WeasyPrint
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (build time)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
@@ -19,8 +25,31 @@ RUN apt-get update && \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (runtime)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libcairo2 \
libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["/entrypoint.sh"]
+11 -1
View File
@@ -1,13 +1,14 @@
import uuid
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.limiter import limiter
from app.auth.security import (
create_access_token,
create_refresh_token,
@@ -28,7 +29,9 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(
request: Request,
user_data: UserCreate,
session: AsyncSession = Depends(get_session),
) -> UserResponse:
@@ -54,7 +57,9 @@ async def register(
@router.post("/login", response_model=Token)
@limiter.limit("10/minute")
async def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
session: AsyncSession = Depends(get_session),
) -> Token:
@@ -127,6 +132,11 @@ async def refresh_token(
return TokenRefresh(access_token=new_access_token, token_type="bearer")
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)) -> UserResponse:
return UserResponse.model_validate(current_user)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
data: TokenRefreshRequest,
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+16
View File
@@ -3,11 +3,19 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.database import engine
from app.limiter import limiter
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
@@ -24,6 +32,10 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
@@ -40,6 +52,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,
)
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting server..."
exec gunicorn app.main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile - \
--error-logfile -
+2
View File
@@ -12,6 +12,8 @@ bcrypt==4.2.1
python-multipart==0.0.20
httpx==0.28.1
weasyprint==63.1
gunicorn==23.0.0
slowapi==0.1.9
# Test dependencies
aiosqlite==0.20.0
pytest==8.3.4
+10 -3
View File
@@ -1,13 +1,18 @@
services:
db:
ports:
- "${DB_PORT:-5432}:5432"
backend:
build:
context: ./backend
dockerfile: Dockerfile
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./backend:/app
ports:
- "${BACKEND_PORT:-8000}:8000"
environment:
SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production}
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]'
frontend:
image: node:20-alpine
@@ -20,6 +25,8 @@ services:
- "5173:5173"
environment:
NODE_ENV: development
depends_on:
- backend
volumes:
frontend_node_modules:
+16 -10
View File
@@ -6,15 +6,14 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-budget}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"]
interval: 5s
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
backend:
build:
@@ -24,13 +23,19 @@ services:
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]'
DEBUG: ${DEBUG:-false}
ports:
- "${BACKEND_PORT:-8000}:8000"
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]}
DEBUG: "false"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
frontend:
build:
@@ -38,9 +43,10 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:80"
- "${FRONTEND_PORT:-80}:80"
depends_on:
- backend
backend:
condition: service_healthy
volumes:
pgdata:
+7 -2
View File
@@ -1,15 +1,20 @@
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+41 -6
View File
@@ -3,13 +3,48 @@ server {
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Static assets — long-lived cache with immutable flag
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy → backend (preserve full /api/ prefix)
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
# SPA fallback — all unknown routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+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>
);
}
+304 -5
View File
@@ -1,8 +1,307 @@
import axios from "axios";
import axios, {
type InternalAxiosRequestConfig,
type AxiosResponse,
} from "axios";
import type {
AuthTokens,
User,
Transaction,
Category,
PaginatedResponse,
TransactionFilters,
CreateTransactionPayload,
UpdateTransactionPayload,
CreateCategoryPayload,
UpdateCategoryPayload,
DashboardData,
Budget,
CreateBudgetPayload,
UpdateBudgetPayload,
HistoryData,
} from "./types";
// ---------------------------------------------------------------------------
// Module-level token state (no circular dependency with auth store)
// ---------------------------------------------------------------------------
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
let _onAuthFailed: (() => void) | null = null;
export function setClientTokens(
access: string | null,
refresh: string | null,
): void {
_accessToken = access;
_refreshToken = refresh;
}
export function setOnAuthFailed(cb: () => void): void {
_onAuthFailed = cb;
}
// ---------------------------------------------------------------------------
// Axios instance
// ---------------------------------------------------------------------------
export const apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
// Request interceptor — attach Bearer token
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (_accessToken) {
config.headers.Authorization = `Bearer ${_accessToken}`;
}
return config;
});
// ---------------------------------------------------------------------------
// Response interceptor — silent token refresh on 401
// ---------------------------------------------------------------------------
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null): void {
failedQueue.forEach((p) => {
if (error) p.reject(error);
else if (token) p.resolve(token);
});
failedQueue = [];
}
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: unknown) => {
if (!axios.isAxiosError(error)) return Promise.reject(error);
const config = error.config;
const status = error.response?.status;
if (status !== 401 || !config) return Promise.reject(error);
// Don't retry auth endpoints
if (
config.url?.includes("/auth/refresh") ||
config.url?.includes("/auth/login")
) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
config.headers.Authorization = `Bearer ${token}`;
return apiClient(config);
});
}
isRefreshing = true;
try {
const rt = _refreshToken;
if (!rt) throw new Error("No refresh token");
const { data } = await axios.post<{ access_token: string }>(
"/api/v1/auth/refresh",
{ refresh_token: rt },
);
_accessToken = data.access_token;
processQueue(null, data.access_token);
config.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(config);
} catch (refreshError) {
processQueue(refreshError, null);
_accessToken = null;
_refreshToken = null;
_onAuthFailed?.();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export async function loginUser(
email: string,
password: string,
): Promise<AuthTokens> {
const params = new URLSearchParams();
params.append("username", email);
params.append("password", password);
const { data } = await apiClient.post<AuthTokens>("/auth/login", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
return data;
}
export async function registerUser(
email: string,
password: string,
full_name: string,
): Promise<User> {
const { data } = await apiClient.post<User>("/auth/register", {
email,
password,
full_name,
});
return data;
}
export async function getMe(): Promise<User> {
const { data } = await apiClient.get<User>("/auth/me");
return data;
}
export async function logoutUser(refreshToken: string): Promise<void> {
await apiClient.post("/auth/logout", { refresh_token: refreshToken });
}
// ---------------------------------------------------------------------------
// Transactions API
// ---------------------------------------------------------------------------
export async function getTransactions(
filters: TransactionFilters,
): Promise<PaginatedResponse<Transaction>> {
const { data } = await apiClient.get<PaginatedResponse<Transaction>>(
"/transactions",
{ params: filters },
);
return data;
}
export async function createTransaction(
payload: CreateTransactionPayload,
): Promise<Transaction> {
const { data } = await apiClient.post<Transaction>("/transactions", payload);
return data;
}
export async function updateTransaction(
id: string,
payload: UpdateTransactionPayload,
): Promise<Transaction> {
const { data } = await apiClient.put<Transaction>(
`/transactions/${id}`,
payload,
);
return data;
}
export async function deleteTransaction(id: string): Promise<void> {
await apiClient.delete(`/transactions/${id}`);
}
// ---------------------------------------------------------------------------
// Categories API
// ---------------------------------------------------------------------------
export async function getCategories(
type?: string,
): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>("/categories", {
params: type ? { type } : undefined,
});
return data;
}
export async function createCategory(
payload: CreateCategoryPayload,
): Promise<Category> {
const { data } = await apiClient.post<Category>("/categories", payload);
return data;
}
export async function updateCategory(
id: string,
payload: UpdateCategoryPayload,
): Promise<Category> {
const { data } = await apiClient.put<Category>(
`/categories/${id}`,
payload,
);
return data;
}
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);
}
+155
View File
@@ -0,0 +1,155 @@
export type TransactionType = "income" | "expense";
export type CategoryType = "income" | "expense" | "both";
export interface User {
id: string;
email: string;
full_name: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface CategoryBrief {
id: string;
name: string;
color: string | null;
}
export interface Transaction {
id: string;
amount_cents: number;
type: TransactionType;
description: string | null;
category: CategoryBrief;
transaction_date: string;
created_at: string;
}
export interface Category {
id: string;
name: string;
type: CategoryType;
color: string | null;
icon: string | null;
is_default: boolean;
created_at: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
}
export interface TransactionFilters {
month?: string;
category_id?: string;
type?: TransactionType;
page?: number;
per_page?: number;
}
export interface CreateTransactionPayload {
amount_cents: number;
type: TransactionType;
category_id: string;
description?: string;
transaction_date: string;
}
export interface UpdateTransactionPayload {
amount_cents?: number;
type?: TransactionType;
category_id?: string | null;
description?: string | null;
transaction_date?: string;
}
export interface CreateCategoryPayload {
name: string;
type: CategoryType;
color?: string;
icon?: string;
}
export interface UpdateCategoryPayload {
name?: string;
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[];
}
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useCreateCategory, useUpdateCategory } from "../hooks/useCategories";
import { useToast } from "../context/ToastContext";
import type { Category, CategoryType } from "../api/types";
interface Props {
isOpen: boolean;
onClose: () => void;
category?: Category;
}
interface FormState {
name: string;
type: CategoryType;
color: string;
icon: string;
}
const DEFAULT_FORM: FormState = {
name: "",
type: "expense",
color: "#6366f1",
icon: "",
};
export default function CategoryModal({ isOpen, onClose, category }: Props) {
const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory();
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const isEditing = !!category;
const isDefault = category?.is_default ?? false;
const isPending = createMutation.isPending || updateMutation.isPending;
useEffect(() => {
if (category) {
setForm({
name: category.name,
type: category.type,
color: category.color ?? "#6366f1",
icon: category.icon ?? "",
});
} else {
setForm(DEFAULT_FORM);
}
setErrors({});
}, [category, isOpen]);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
}
function validate(): boolean {
const errs: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) errs.name = "Nom requis";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
if (isEditing && category) {
updateMutation.mutate(
{
id: category.id,
payload: {
name: form.name.trim(),
color: form.color,
icon: form.icon || undefined,
},
},
{
onSuccess: () => {
addToast({ type: "success", message: "Catégorie modifiée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la modification" }),
},
);
} else {
createMutation.mutate(
{
name: form.name.trim(),
type: form.type,
color: form.color,
icon: form.icon || undefined,
},
{
onSuccess: () => {
addToast({ type: "success", message: "Catégorie créée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la création" }),
},
);
}
}
if (!isOpen) return null;
const CATEGORY_TYPES: { value: CategoryType; label: string }[] = [
{ value: "expense", label: "Dépense" },
{ value: "income", label: "Revenu" },
{ value: "both", label: "Les deux" },
];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-semibold text-slate-800">
{isEditing ? "Modifier la catégorie" : "Nouvelle catégorie"}
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-700"
aria-label="Fermer"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
{isDefault && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
Les catégories par défaut ne peuvent pas être modifiées en type.
</p>
)}
{/* Name */}
<div>
<label
htmlFor="cat-name"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Nom
</label>
<input
id="cat-name"
type="text"
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="Ex. : Alimentation"
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.name ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Type */}
<div>
<label
htmlFor="cat-type"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Type
</label>
<select
id="cat-type"
value={form.type}
onChange={(e) => set("type", e.target.value as CategoryType)}
disabled={isDefault || isEditing}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 disabled:bg-slate-50 disabled:text-slate-400"
>
{CATEGORY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* Color */}
<div>
<label
htmlFor="cat-color"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Couleur
</label>
<div className="flex items-center gap-3">
<input
id="cat-color"
type="color"
value={form.color}
onChange={(e) => set("color", e.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-slate-200 p-0.5"
/>
<span className="text-sm text-slate-500">{form.color}</span>
</div>
</div>
{/* Icon */}
<div>
<label
htmlFor="cat-icon"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Icône{" "}
<span className="font-normal text-slate-400">(optionnel)</span>
</label>
<input
id="cat-icon"
type="text"
value={form.icon}
onChange={(e) => set("icon", e.target.value)}
placeholder="Ex. : shopping-cart"
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isPending
? "Enregistrement…"
: isEditing
? "Modifier"
: "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { AlertTriangle } from "lucide-react";
interface Props {
isOpen: boolean;
message: string;
isLoading?: boolean;
onConfirm: () => void;
onClose: () => void;
}
export default function DeleteConfirmModal({
isOpen,
message,
isLoading,
onConfirm,
onClose,
}: Props) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100">
<AlertTriangle size={20} className="text-red-600" />
</div>
<h2 className="text-base font-semibold text-slate-800">
Confirmer la suppression
</h2>
</div>
<p className="mb-6 text-sm text-slate-600">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{isLoading ? "Suppression…" : "Supprimer"}
</button>
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
ArrowLeftRight,
Tag,
Wallet,
History,
Menu,
X,
LogOut,
ChevronRight,
} from "lucide-react";
import { useAuthStore } from "../stores/auth";
import { logoutUser } from "../api/client";
import { useToast } from "../context/ToastContext";
const NAV_ITEMS = [
{ to: "/", icon: LayoutDashboard, label: "Dashboard", end: true },
{ to: "/transactions", icon: ArrowLeftRight, label: "Transactions", end: false },
{ to: "/categories", icon: Tag, label: "Catégories", end: false },
{ to: "/budgets", icon: Wallet, label: "Budgets", end: false },
{ to: "/history", icon: History, label: "Historique", end: false },
];
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user, refreshToken, logout } = useAuthStore();
const navigate = useNavigate();
const { addToast } = useToast();
const handleLogout = async () => {
try {
if (refreshToken) await logoutUser(refreshToken);
} catch {
// best-effort logout
} finally {
logout();
navigate("/login");
addToast({ type: "info", message: "Déconnecté avec succès" });
}
};
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary text-white"
: "text-slate-300 hover:bg-slate-700 hover:text-white"
}`;
const SidebarContent = () => (
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center border-b border-slate-700 px-4">
<span className="text-lg font-bold text-white">Budget Tracker</span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{NAV_ITEMS.map(({ to, icon: Icon, label, end }) => (
<li key={to}>
<NavLink
to={to}
end={end}
className={navLinkClass}
onClick={() => setSidebarOpen(false)}
>
<Icon size={18} />
{label}
<ChevronRight size={14} className="ml-auto opacity-40" />
</NavLink>
</li>
))}
</ul>
</nav>
{/* User / Logout */}
<div className="border-t border-slate-700 p-4">
<div className="mb-2 truncate text-xs text-slate-400">
{user?.full_name ?? user?.email ?? "—"}
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-700 hover:text-white"
>
<LogOut size={16} />
Déconnexion
</button>
</div>
</div>
);
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 bg-slate-800 lg:flex lg:flex-col">
<SidebarContent />
</aside>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-60 transform bg-slate-800 transition-transform duration-200 lg:hidden ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
onClick={() => setSidebarOpen(false)}
className="absolute right-3 top-4 text-slate-400 hover:text-white"
aria-label="Fermer le menu"
>
<X size={20} />
</button>
<SidebarContent />
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Mobile header */}
<header className="flex h-14 items-center border-b bg-white px-4 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-slate-600 hover:text-slate-900"
aria-label="Ouvrir le menu"
>
<Menu size={22} />
</button>
<span className="ml-3 font-semibold text-slate-800">
Budget Tracker
</span>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuthStore } from "../stores/auth";
export default function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <Outlet />;
}
+302
View File
@@ -0,0 +1,302 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useCategories } from "../hooks/useCategories";
import {
useCreateTransaction,
useUpdateTransaction,
} from "../hooks/useTransactions";
import { eurosToCents, centsToEuros } from "../utils/format";
import { useToast } from "../context/ToastContext";
import type { Transaction, TransactionType } from "../api/types";
interface Props {
isOpen: boolean;
onClose: () => void;
transaction?: Transaction;
}
interface FormState {
amount: string;
type: TransactionType;
category_id: string;
description: string;
transaction_date: string;
}
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
const DEFAULT_FORM: FormState = {
amount: "",
type: "expense",
category_id: "",
description: "",
transaction_date: todayISO(),
};
export default function TransactionForm({ isOpen, onClose, transaction }: Props) {
const { data: categories = [] } = useCategories();
const createMutation = useCreateTransaction();
const updateMutation = useUpdateTransaction();
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const isEditing = !!transaction;
const isPending = createMutation.isPending || updateMutation.isPending;
// Populate form when editing
useEffect(() => {
if (transaction) {
setForm({
amount: String(centsToEuros(transaction.amount_cents)),
type: transaction.type,
category_id: transaction.category.id,
description: transaction.description ?? "",
transaction_date: transaction.transaction_date,
});
} else {
setForm(DEFAULT_FORM);
}
setErrors({});
}, [transaction, isOpen]);
const filteredCategories = categories.filter(
(c) => c.type === form.type || c.type === "both",
);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => {
const next = { ...prev, [key]: value };
// Reset category when type changes and current category is incompatible
if (key === "type") {
const cat = categories.find((c) => c.id === prev.category_id);
if (cat && cat.type !== "both" && cat.type !== value) {
next.category_id = "";
}
}
return next;
});
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
}
function validate(): boolean {
const errs: Partial<Record<keyof FormState, string>> = {};
const amountNum = parseFloat(form.amount);
if (!form.amount || isNaN(amountNum) || amountNum <= 0)
errs.amount = "Montant invalide (doit être > 0)";
if (!form.category_id) errs.category_id = "Catégorie requise";
if (!form.transaction_date) errs.transaction_date = "Date requise";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
amount_cents: eurosToCents(parseFloat(form.amount)),
type: form.type,
category_id: form.category_id,
description: form.description || undefined,
transaction_date: form.transaction_date,
};
if (isEditing && transaction) {
updateMutation.mutate(
{ id: transaction.id, payload },
{
onSuccess: () => {
addToast({ type: "success", message: "Transaction modifiée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la modification" }),
},
);
} else {
createMutation.mutate(payload, {
onSuccess: () => {
addToast({ type: "success", message: "Transaction ajoutée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de l'ajout" }),
});
}
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-semibold text-slate-800">
{isEditing ? "Modifier la transaction" : "Nouvelle transaction"}
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-700"
aria-label="Fermer"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
{/* Type toggle */}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">
Type
</label>
<div className="flex rounded-lg border border-slate-200 p-1">
{(["expense", "income"] as TransactionType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => set("type", t)}
className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
form.type === t
? t === "expense"
? "bg-red-100 text-red-700"
: "bg-green-100 text-green-700"
: "text-slate-500 hover:text-slate-700"
}`}
>
{t === "expense" ? "Dépense" : "Revenu"}
</button>
))}
</div>
</div>
{/* Amount */}
<div>
<label
htmlFor="tx-amount"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Montant ()
</label>
<input
id="tx-amount"
type="number"
step="0.01"
min="0.01"
value={form.amount}
onChange={(e) => set("amount", e.target.value)}
placeholder="0,00"
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.amount ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.amount && (
<p className="mt-1 text-xs text-red-500">{errors.amount}</p>
)}
</div>
{/* Date */}
<div>
<label
htmlFor="tx-date"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Date
</label>
<input
id="tx-date"
type="date"
value={form.transaction_date}
onChange={(e) => set("transaction_date", e.target.value)}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.transaction_date ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.transaction_date && (
<p className="mt-1 text-xs text-red-500">{errors.transaction_date}</p>
)}
</div>
{/* Category */}
<div>
<label
htmlFor="tx-category"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Catégorie
</label>
<select
id="tx-category"
value={form.category_id}
onChange={(e) => set("category_id", e.target.value)}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.category_id ? "border-red-400" : "border-slate-200"
}`}
>
<option value=""> Choisir une catégorie </option>
{filteredCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{errors.category_id && (
<p className="mt-1 text-xs text-red-500">{errors.category_id}</p>
)}
</div>
{/* Description */}
<div>
<label
htmlFor="tx-description"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Description{" "}
<span className="font-normal text-slate-400">(optionnel)</span>
</label>
<input
id="tx-description"
type="text"
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="Ex. : Courses Carrefour"
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isPending
? "Enregistrement…"
: isEditing
? "Modifier"
: "Ajouter"}
</button>
</div>
</form>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useReducer,
useEffect,
} from "react";
import { X } from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ToastType = "success" | "error" | "info";
interface Toast {
id: number;
type: ToastType;
message: string;
}
interface ToastContextValue {
addToast: (toast: Omit<Toast, "id">) => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
type Action =
| { type: "ADD"; toast: Toast }
| { type: "REMOVE"; id: number };
function reducer(state: Toast[], action: Action): Toast[] {
switch (action.type) {
case "ADD":
return [...state, action.toast];
case "REMOVE":
return state.filter((t) => t.id !== action.id);
}
}
// ---------------------------------------------------------------------------
// Provider + UI
// ---------------------------------------------------------------------------
let nextId = 0;
function ToastItem({
toast,
onRemove,
}: {
toast: Toast;
onRemove: (id: number) => void;
}) {
useEffect(() => {
const timer = setTimeout(() => onRemove(toast.id), 4000);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
const colors: Record<ToastType, string> = {
success: "bg-green-600",
error: "bg-red-600",
info: "bg-blue-600",
};
return (
<div
className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm text-white shadow-lg ${colors[toast.type]}`}
>
<span className="flex-1">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="shrink-0 opacity-80 hover:opacity-100"
aria-label="Fermer"
>
<X size={14} />
</button>
</div>
);
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, dispatch] = useReducer(reducer, []);
const removeToast = useCallback((id: number) => {
dispatch({ type: "REMOVE", id });
}, []);
const addToast = useCallback((toast: Omit<Toast, "id">) => {
dispatch({ type: "ADD", toast: { ...toast, id: ++nextId } });
}, []);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider>
);
}
+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"] }),
});
}
+43
View File
@@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCategory,
deleteCategory,
getCategories,
updateCategory,
} from "../api/client";
import type {
CreateCategoryPayload,
UpdateCategoryPayload,
} from "../api/types";
export function useCategories() {
return useQuery({
queryKey: ["categories"],
queryFn: () => getCategories(),
});
}
export function useCreateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateCategoryPayload) => createCategory(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
export function useUpdateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCategoryPayload }) =>
updateCategory(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
export function useDeleteCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteCategory(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
+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),
});
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTransaction,
deleteTransaction,
getTransactions,
updateTransaction,
} from "../api/client";
import type {
CreateTransactionPayload,
TransactionFilters,
UpdateTransactionPayload,
} from "../api/types";
export function useTransactions(filters: TransactionFilters) {
return useQuery({
queryKey: ["transactions", filters],
queryFn: () => getTransactions(filters),
});
}
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateTransactionPayload) =>
createTransaction(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useUpdateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: UpdateTransactionPayload;
}) => updateTransaction(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useDeleteTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteTransaction(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
+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>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { loginUser, getMe, setClientTokens } from "../api/client";
import { useAuthStore } from "../stores/auth";
interface FormState {
email: string;
password: string;
}
interface Errors {
email?: string;
password?: string;
global?: string;
}
export default function LoginPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [form, setForm] = useState<FormState>({ email: "", password: "" });
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
function validate(): boolean {
const errs: Errors = {};
if (!form.email) errs.email = "Email requis";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
errs.email = "Email invalide";
if (!form.password) errs.password = "Mot de passe requis";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
setErrors({});
try {
const tokens = await loginUser(form.email, form.password);
// Set tokens on the client so getMe() succeeds
setClientTokens(tokens.access_token, tokens.refresh_token);
const user = await getMe();
login(user, tokens.access_token, tokens.refresh_token);
navigate("/", { replace: true });
} catch (err) {
const msg =
err instanceof Error && err.message.includes("401")
? "Email ou mot de passe incorrect"
: "Erreur de connexion, veuillez réessayer";
setErrors({ global: msg });
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
<p className="mt-1 text-sm text-slate-500">
Connectez-vous à votre compte
</p>
</div>
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
{errors.global && (
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.global}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
value={form.email}
onChange={(e) =>
setForm((f) => ({ ...f, email: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.email ? "border-red-400" : "border-slate-200"
}`}
placeholder="vous@exemple.com"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.password ? "border-red-400" : "border-slate-200"
}`}
placeholder="••••••••"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? "Connexion…" : "Se connecter"}
</button>
</form>
</div>
<p className="mt-4 text-center text-sm text-slate-500">
Pas encore de compte ?{" "}
<Link to="/register" className="font-medium text-primary hover:underline">
S&apos;inscrire
</Link>
</p>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { registerUser, loginUser, getMe, setClientTokens } from "../api/client";
import { useAuthStore } from "../stores/auth";
interface FormState {
full_name: string;
email: string;
password: string;
}
interface Errors {
full_name?: string;
email?: string;
password?: string;
global?: string;
}
export default function RegisterPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [form, setForm] = useState<FormState>({
full_name: "",
email: "",
password: "",
});
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
function validate(): boolean {
const errs: Errors = {};
if (!form.full_name.trim() || form.full_name.trim().length < 2)
errs.full_name = "Nom complet requis (2 caractères minimum)";
if (!form.email) errs.email = "Email requis";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
errs.email = "Email invalide";
if (!form.password || form.password.length < 8)
errs.password = "Mot de passe requis (8 caractères minimum)";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
setErrors({});
try {
await registerUser(form.email, form.password, form.full_name.trim());
// Auto-login after registration
const tokens = await loginUser(form.email, form.password);
setClientTokens(tokens.access_token, tokens.refresh_token);
const user = await getMe();
login(user, tokens.access_token, tokens.refresh_token);
navigate("/", { replace: true });
} catch (err) {
const isConflict =
err instanceof Error && err.message.toLowerCase().includes("409");
setErrors({
global: isConflict
? "Cet email est déjà utilisé"
: "Erreur lors de l'inscription, veuillez réessayer",
});
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
<p className="mt-1 text-sm text-slate-500">Créez votre compte</p>
</div>
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
{errors.global && (
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.global}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="full_name"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Nom complet
</label>
<input
id="full_name"
type="text"
autoComplete="name"
value={form.full_name}
onChange={(e) =>
setForm((f) => ({ ...f, full_name: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.full_name ? "border-red-400" : "border-slate-200"
}`}
placeholder="Jean Dupont"
/>
{errors.full_name && (
<p className="mt-1 text-xs text-red-500">{errors.full_name}</p>
)}
</div>
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
value={form.email}
onChange={(e) =>
setForm((f) => ({ ...f, email: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.email ? "border-red-400" : "border-slate-200"
}`}
placeholder="vous@exemple.com"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.password ? "border-red-400" : "border-slate-200"
}`}
placeholder="8 caractères minimum"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? "Inscription…" : "Créer mon compte"}
</button>
</form>
</div>
<p className="mt-4 text-center text-sm text-slate-500">
Déjà un compte ?{" "}
<Link to="/login" className="font-medium text-primary hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, TrendingUp, TrendingDown, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { downloadExport } from "../api/client";
import {
useTransactions,
useDeleteTransaction,
} from "../hooks/useTransactions";
import { useCategories } from "../hooks/useCategories";
import TransactionForm from "../components/TransactionForm";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import { formatCurrency, formatDate, currentMonth } from "../utils/format";
import type { Transaction, TransactionType } from "../api/types";
const PER_PAGE = 20;
// Skeleton row
function SkeletonRow() {
return (
<tr>
{Array.from({ length: 6 }).map((_, i) => (
<td key={i} className="px-4 py-3">
<div className="h-4 animate-pulse rounded bg-slate-100" />
</td>
))}
</tr>
);
}
export default function TransactionsPage() {
const { addToast } = useToast();
const { data: categoriesData } = useCategories();
const [month, setMonth] = useState(currentMonth());
const [categoryFilter, setCategoryFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<TransactionType | "">("");
const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false);
const [editingTx, setEditingTx] = useState<Transaction | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Transaction | undefined>();
const filters = {
month: month || undefined,
category_id: categoryFilter || undefined,
type: typeFilter || undefined,
page,
per_page: PER_PAGE,
};
const { data, isLoading } = useTransactions(filters);
const deleteMutation = useDeleteTransaction();
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 0;
function openCreate() {
setEditingTx(undefined);
setFormOpen(true);
}
function openEdit(tx: Transaction) {
setEditingTx(tx);
setFormOpen(true);
}
function handleFilterChange() {
setPage(1);
}
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
addToast({ type: "success", message: "Transaction supprimée" });
setDeleteTarget(undefined);
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la suppression" }),
});
}
const categories = categoriesData ?? [];
return (
<div>
<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"
>
<Plus size={16} />
Nouvelle
</button>
</div>
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap gap-3">
<input
type="month"
value={month}
onChange={(e) => {
setMonth(e.target.value);
handleFilterChange();
}}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<select
value={categoryFilter}
onChange={(e) => {
setCategoryFilter(e.target.value);
handleFilterChange();
}}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Toutes les catégories</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<div className="flex rounded-lg border border-slate-200">
{(
[
{ value: "", label: "Tout" },
{ value: "income", label: "Revenus" },
{ value: "expense", label: "Dépenses" },
] as { value: TransactionType | ""; label: string }[]
).map(({ value, label }) => (
<button
key={value}
onClick={() => {
setTypeFilter(value);
handleFilterChange();
}}
className={`px-3 py-1.5 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
typeFilter === value
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="px-4 py-3 text-left font-medium text-slate-500">
Date
</th>
<th className="px-4 py-3 text-left font-medium text-slate-500">
Description
</th>
<th className="px-4 py-3 text-left font-medium text-slate-500">
Catégorie
</th>
<th className="px-4 py-3 text-right font-medium text-slate-500">
Montant
</th>
<th className="px-4 py-3 text-center font-medium text-slate-500">
Type
</th>
<th className="px-4 py-3 text-right font-medium text-slate-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
) : !data || data.items.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-12 text-center text-slate-400"
>
<p className="font-medium">Aucune transaction</p>
<p className="mt-1 text-xs">
Ajoutez votre première transaction avec le bouton ci-dessus.
</p>
</td>
</tr>
) : (
data.items.map((tx) => (
<tr
key={tx.id}
className="group transition-colors hover:bg-slate-50"
>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatDate(tx.transaction_date)}
</td>
<td className="px-4 py-3 text-slate-800">
{tx.description ?? (
<span className="text-slate-400 italic"></span>
)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
backgroundColor: tx.category.color
? `${tx.category.color}20`
: "#e2e8f0",
color: tx.category.color ?? "#64748b",
}}
>
{tx.category.name}
</span>
</td>
<td
className={`whitespace-nowrap px-4 py-3 text-right font-medium tabular-nums ${
tx.type === "income"
? "text-green-600"
: "text-slate-800"
}`}
>
{tx.type === "income" ? "+" : "-"}
{formatCurrency(tx.amount_cents)}
</td>
<td className="px-4 py-3 text-center">
{tx.type === "income" ? (
<TrendingUp
size={16}
className="mx-auto text-green-500"
/>
) : (
<TrendingDown
size={16}
className="mx-auto text-red-400"
/>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => openEdit(tx)}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(tx)}
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
aria-label="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-100 px-4 py-3">
<span className="text-xs text-slate-500">
{data.total} transaction{data.total > 1 ? "s" : ""} au total
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
>
<ChevronLeft size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`min-w-[2rem] rounded px-2 py-1 text-xs font-medium ${
p === page
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
>
{p}
</button>
),
)}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
{/* Modals */}
<TransactionForm
isOpen={formOpen}
onClose={() => setFormOpen(false)}
transaction={editingTx}
/>
<DeleteConfirmModal
isOpen={!!deleteTarget}
message={
deleteTarget
? `Supprimer la transaction "${deleteTarget.description ?? formatCurrency(deleteTarget.amount_cents)}" ?`
: ""
}
isLoading={deleteMutation.isPending}
onConfirm={handleDelete}
onClose={() => setDeleteTarget(undefined)}
/>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { setClientTokens, setOnAuthFailed } from "../api/client";
import type { User } from "../api/types";
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
}
interface AuthActions {
login: (user: User, accessToken: string, refreshToken: string) => void;
updateTokens: (accessToken: string) => void;
logout: () => void;
registerOnAuthFailed: (cb: () => void) => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
login(user, accessToken, refreshToken) {
setClientTokens(accessToken, refreshToken);
set({ user, accessToken, refreshToken, isAuthenticated: true });
},
updateTokens(accessToken) {
setClientTokens(accessToken, get().refreshToken);
set({ accessToken });
},
logout() {
setClientTokens(null, null);
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
});
},
registerOnAuthFailed(cb) {
setOnAuthFailed(cb);
},
}),
{
name: "budget-tracker-auth",
onRehydrateStorage: () => (state) => {
if (state?.accessToken) {
setClientTokens(state.accessToken, state.refreshToken);
}
},
},
),
);
+25
View File
@@ -0,0 +1,25 @@
export function centsToEuros(cents: number): number {
return cents / 100;
}
export function eurosToCents(euros: number): number {
return Math.round(euros * 100);
}
export function formatCurrency(cents: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}
export function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat("fr-FR").format(new Date(dateStr + "T00:00:00"));
}
export function currentMonth(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}