Compare commits
3 Commits
21339d771d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 434de9aa3e | |||
| e3fac99045 | |||
| 9f7378cb69 |
+17
-8
@@ -1,17 +1,26 @@
|
|||||||
# ── PostgreSQL ──────────────────────────────────────────
|
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||||
POSTGRES_USER=budget
|
POSTGRES_USER=budget
|
||||||
POSTGRES_PASSWORD=budget
|
POSTGRES_PASSWORD=budget
|
||||||
POSTGRES_DB=budget_tracker
|
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
|
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
|
SECRET_KEY=change-me-in-production
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
||||||
DEBUG=false
|
|
||||||
BACKEND_PORT=8000
|
|
||||||
|
|
||||||
# ── Frontend ───────────────────────────────────────────
|
# Comma-separated list of allowed origins (JSON array syntax)
|
||||||
FRONTEND_PORT=3000
|
# 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
@@ -1,32 +1,45 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
*.cover
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Environment
|
# Environment — never commit secrets
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# Testing
|
# Build artifacts
|
||||||
.coverage
|
dist/
|
||||||
htmlcov/
|
build/
|
||||||
.pytest_cache/
|
|
||||||
|
|||||||
@@ -1,37 +1,155 @@
|
|||||||
# Budget Tracker
|
# 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 -->
|
||||||
|
<!--  -->
|
||||||
|
|
||||||
- **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
|
```bash
|
||||||
# Copier et configurer les variables d'environnement
|
# 1. Copy and configure environment
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Lancer tous les services (dev avec hot-reload)
|
# 2. Start all services (production mode)
|
||||||
docker compose up
|
docker compose up -d
|
||||||
|
|
||||||
# Backend : http://localhost:8000
|
# App is available at http://localhost
|
||||||
# Frontend : http://localhost:5173
|
# API docs at http://localhost/api/v1/docs
|
||||||
# Health check : http://localhost:8000/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Linting backend
|
# Start dev environment
|
||||||
cd backend && ruff check .
|
cp .env.example .env
|
||||||
|
docker compose up
|
||||||
|
|
||||||
# Tests backend
|
# Backend API: http://localhost:8000
|
||||||
cd backend && pytest
|
# Frontend dev server: http://localhost:5173
|
||||||
|
# API docs: http://localhost:8000/api/v1/docs
|
||||||
# Format frontend
|
```
|
||||||
cd frontend && npm run format
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
@@ -1,13 +1,19 @@
|
|||||||
FROM python:3.12-slim AS base
|
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
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 && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
libpango-1.0-0 \
|
libpango-1.0-0 \
|
||||||
libpangocairo-1.0-0 \
|
libpangocairo-1.0-0 \
|
||||||
libgdk-pixbuf2.0-0 \
|
libgdk-pixbuf2.0-0 \
|
||||||
@@ -19,8 +25,31 @@ RUN apt-get update && \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 . .
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth.dependencies import get_current_user
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.limiter import limiter
|
||||||
from app.auth.security import (
|
from app.auth.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_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)
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def register(
|
async def register(
|
||||||
|
request: Request,
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
@@ -54,7 +57,9 @@ async def register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
async def login(
|
async def login(
|
||||||
|
request: Request,
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Token:
|
) -> Token:
|
||||||
@@ -127,6 +132,11 @@ async def refresh_token(
|
|||||||
return TokenRefresh(access_token=new_access_token, token_type="bearer")
|
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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
data: TokenRefreshRequest,
|
data: TokenRefreshRequest,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -3,11 +3,19 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.auth.router import router as auth_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
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.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
|
from app.routers.transactions import router as transactions_router
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +32,10 @@ def create_app() -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
@@ -40,6 +52,10 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(auth_router, prefix=api_prefix)
|
app.include_router(auth_router, prefix=api_prefix)
|
||||||
app.include_router(transactions_router, prefix=api_prefix)
|
app.include_router(transactions_router, prefix=api_prefix)
|
||||||
app.include_router(categories_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
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
|
||||||
|
from app.services import budget_service
|
||||||
|
from app.utils import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/budgets", tags=["budgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def _current_month() -> str:
|
||||||
|
now = utcnow()
|
||||||
|
return f"{now.year}-{now.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BudgetResponse])
|
||||||
|
async def list_budgets(
|
||||||
|
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list[BudgetResponse]:
|
||||||
|
if month is None:
|
||||||
|
month = _current_month()
|
||||||
|
return await budget_service.list_budgets(session, current_user.id, month)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BudgetResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_budget(
|
||||||
|
data: BudgetCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> BudgetResponse:
|
||||||
|
return await budget_service.create_budget(session, current_user.id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{budget_id}", response_model=BudgetResponse)
|
||||||
|
async def update_budget(
|
||||||
|
budget_id: uuid.UUID,
|
||||||
|
data: BudgetUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> BudgetResponse:
|
||||||
|
return await budget_service.update_budget(session, current_user.id, budget_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_budget(
|
||||||
|
budget_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await budget_service.delete_budget(session, current_user.id, budget_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rollover", response_model=list[BudgetResponse], status_code=status.HTTP_201_CREATED)
|
||||||
|
async def rollover_budgets(
|
||||||
|
month: str = Query(..., description="Target month YYYY-MM to copy budgets into"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list[BudgetResponse]:
|
||||||
|
"""Copy budgets from month-1 into the given month (skips existing ones)."""
|
||||||
|
return await budget_service.rollover_budgets(session, current_user.id, month)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.dashboard import DashboardResponse
|
||||||
|
from app.services import dashboard_service
|
||||||
|
from app.utils import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _current_month() -> str:
|
||||||
|
now = utcnow()
|
||||||
|
return f"{now.year}-{now.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardResponse)
|
||||||
|
async def get_dashboard(
|
||||||
|
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> DashboardResponse:
|
||||||
|
if month is None:
|
||||||
|
month = _current_month()
|
||||||
|
return await dashboard_service.get_dashboard(session, current_user.id, month)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.models.user import User
|
||||||
|
from app.utils import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/export", tags=["export"])
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(month: str) -> tuple[date, date]:
|
||||||
|
year, mon = map(int, month.split("-"))
|
||||||
|
start = date(year, mon, 1)
|
||||||
|
if mon == 12:
|
||||||
|
end = date(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = date(year, mon + 1, 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_transactions(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id,
|
||||||
|
month: str,
|
||||||
|
) -> list[Transaction]:
|
||||||
|
start, end = _month_bounds(month)
|
||||||
|
result = await session.execute(
|
||||||
|
select(Transaction)
|
||||||
|
.options(selectinload(Transaction.category))
|
||||||
|
.where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
.order_by(Transaction.transaction_date, Transaction.created_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/csv")
|
||||||
|
async def export_csv(
|
||||||
|
month: str = Query(default=None, description="Month YYYY-MM"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
if month is None:
|
||||||
|
now = utcnow()
|
||||||
|
month = f"{now.year}-{now.month:02d}"
|
||||||
|
|
||||||
|
transactions = await _fetch_transactions(session, current_user.id, month)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=";")
|
||||||
|
writer.writerow(["Date", "Type", "Catégorie", "Description", "Montant (€)"])
|
||||||
|
for tx in transactions:
|
||||||
|
amount_eur = tx.amount_cents / 100
|
||||||
|
if tx.type.value == "expense":
|
||||||
|
amount_eur = -amount_eur
|
||||||
|
writer.writerow([
|
||||||
|
tx.transaction_date.isoformat(),
|
||||||
|
tx.type.value,
|
||||||
|
tx.category.name,
|
||||||
|
tx.description or "",
|
||||||
|
f"{amount_eur:.2f}",
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
filename = f"transactions_{month}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pdf")
|
||||||
|
async def export_pdf(
|
||||||
|
month: str = Query(default=None, description="Month YYYY-MM"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
if month is None:
|
||||||
|
now = utcnow()
|
||||||
|
month = f"{now.year}-{now.month:02d}"
|
||||||
|
|
||||||
|
transactions = await _fetch_transactions(session, current_user.id, month)
|
||||||
|
|
||||||
|
total_income = sum(t.amount_cents for t in transactions if t.type.value == "income")
|
||||||
|
total_expense = sum(t.amount_cents for t in transactions if t.type.value == "expense")
|
||||||
|
|
||||||
|
rows_html = ""
|
||||||
|
for tx in transactions:
|
||||||
|
amount_eur = tx.amount_cents / 100
|
||||||
|
color = "#16a34a" if tx.type.value == "income" else "#dc2626"
|
||||||
|
sign = "+" if tx.type.value == "income" else "-"
|
||||||
|
rows_html += f"""
|
||||||
|
<tr>
|
||||||
|
<td>{tx.transaction_date.isoformat()}</td>
|
||||||
|
<td>{tx.category.name}</td>
|
||||||
|
<td>{tx.description or "—"}</td>
|
||||||
|
<td style="color:{color};text-align:right;font-weight:600">
|
||||||
|
{sign}{amount_eur:.2f} €
|
||||||
|
</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; font-size: 12px; color: #1e293b; margin: 40px; }}
|
||||||
|
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||||
|
.subtitle {{ color: #64748b; margin-bottom: 24px; }}
|
||||||
|
.kpi {{ display: flex; gap: 40px; margin-bottom: 24px; }}
|
||||||
|
.kpi-card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||||
|
padding: 12px 20px; }}
|
||||||
|
.kpi-label {{ font-size: 11px; color: #64748b; }}
|
||||||
|
.kpi-value {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
|
th {{ background: #1e293b; color: white; padding: 8px 12px; text-align: left; font-size: 11px; }}
|
||||||
|
td {{ padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }}
|
||||||
|
tr:nth-child(even) td {{ background: #f8fafc; }}
|
||||||
|
.footer {{ margin-top: 24px; font-size: 10px; color: #94a3b8; text-align: right; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Rapport de transactions</h1>
|
||||||
|
<div class="subtitle">Période : {month} | {current_user.full_name or current_user.email}</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Revenus</div>
|
||||||
|
<div class="kpi-value" style="color:#16a34a">+{total_income/100:.2f} €</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Dépenses</div>
|
||||||
|
<div class="kpi-value" style="color:#dc2626">-{total_expense/100:.2f} €</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">Solde net</div>
|
||||||
|
<div class="kpi-value">{(total_income - total_expense)/100:+.2f} €</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th><th>Catégorie</th><th>Description</th><th style="text-align:right">Montant</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows_html}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer">Généré le {utcnow().strftime('%d/%m/%Y')} — Budget Tracker</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
import weasyprint # noqa: PLC0415
|
||||||
|
|
||||||
|
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
|
||||||
|
filename = f"transactions_{month}.pdf"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([pdf_bytes]),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.transaction import Transaction, TransactionType
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.history import HistoryResponse, MonthSummary
|
||||||
|
from app.utils import utcnow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/history", tags=["history"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=HistoryResponse)
|
||||||
|
async def get_history(
|
||||||
|
year: int = Query(default=None, description="Year (defaults to current year)"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> HistoryResponse:
|
||||||
|
if year is None:
|
||||||
|
year = utcnow().year
|
||||||
|
|
||||||
|
months: list[MonthSummary] = []
|
||||||
|
for mon in range(1, 13):
|
||||||
|
start = date(year, mon, 1)
|
||||||
|
if mon == 12:
|
||||||
|
end = date(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = date(year, mon + 1, 1)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("income"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("expense"),
|
||||||
|
func.count(Transaction.id).label("count"),
|
||||||
|
).where(
|
||||||
|
Transaction.user_id == current_user.id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.one()
|
||||||
|
months.append(
|
||||||
|
MonthSummary(
|
||||||
|
month=f"{year}-{mon:02d}",
|
||||||
|
income_cents=row.income,
|
||||||
|
expense_cents=row.expense,
|
||||||
|
balance_cents=row.income - row.expense,
|
||||||
|
transaction_count=row.count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HistoryResponse(year=year, months=months)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetCreate(BaseModel):
|
||||||
|
category_id: uuid.UUID
|
||||||
|
month: str # YYYY-MM
|
||||||
|
limit_cents: int
|
||||||
|
|
||||||
|
@field_validator("limit_cents")
|
||||||
|
@classmethod
|
||||||
|
def limit_positive(cls, v: int) -> int:
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("limit_cents must be positive")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("month")
|
||||||
|
@classmethod
|
||||||
|
def month_format(cls, v: str) -> str:
|
||||||
|
parts = v.split("-")
|
||||||
|
if len(parts) != 2 or not all(p.isdigit() for p in parts):
|
||||||
|
raise ValueError("month must be in YYYY-MM format")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetUpdate(BaseModel):
|
||||||
|
limit_cents: int
|
||||||
|
|
||||||
|
@field_validator("limit_cents")
|
||||||
|
@classmethod
|
||||||
|
def limit_positive(cls, v: int) -> int:
|
||||||
|
if v <= 0:
|
||||||
|
raise ValueError("limit_cents must be positive")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
category_color: str | None
|
||||||
|
month: str
|
||||||
|
limit_cents: int
|
||||||
|
spent_cents: int
|
||||||
|
remaining_cents: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryExpense(BaseModel):
|
||||||
|
category_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
color: str | None
|
||||||
|
amount_cents: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyTrend(BaseModel):
|
||||||
|
month: str # YYYY-MM
|
||||||
|
income_cents: int
|
||||||
|
expense_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetAlert(BaseModel):
|
||||||
|
budget_id: uuid.UUID
|
||||||
|
category_name: str
|
||||||
|
limit_cents: int
|
||||||
|
spent_cents: int
|
||||||
|
percentage: float
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardResponse(BaseModel):
|
||||||
|
month: str
|
||||||
|
balance_cents: int # all-time cumulative balance
|
||||||
|
total_income_cents: int # month income
|
||||||
|
total_expense_cents: int # month expenses
|
||||||
|
net_cents: int # month net
|
||||||
|
by_category: list[CategoryExpense]
|
||||||
|
monthly_trend: list[MonthlyTrend]
|
||||||
|
budget_alerts: list[BudgetAlert]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonthSummary(BaseModel):
|
||||||
|
month: str # YYYY-MM
|
||||||
|
income_cents: int
|
||||||
|
expense_cents: int
|
||||||
|
balance_cents: int # month net
|
||||||
|
transaction_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
months: list[MonthSummary]
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.transaction import Transaction, TransactionType
|
||||||
|
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
|
||||||
|
from app.utils import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(month: str) -> tuple[date, date]:
|
||||||
|
year, mon = map(int, month.split("-"))
|
||||||
|
start = date(year, mon, 1)
|
||||||
|
if mon == 12:
|
||||||
|
end = date(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = date(year, mon + 1, 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _prev_month(month: str) -> str:
|
||||||
|
year, mon = map(int, month.split("-"))
|
||||||
|
if mon == 1:
|
||||||
|
return f"{year - 1}-12"
|
||||||
|
return f"{year}-{mon - 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _spent_cents(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
category_id: uuid.UUID,
|
||||||
|
month: str,
|
||||||
|
) -> int:
|
||||||
|
start, end = _month_bounds(month)
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.type == TransactionType.expense,
|
||||||
|
Transaction.category_id == category_id,
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_response(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
budget: Budget,
|
||||||
|
) -> BudgetResponse:
|
||||||
|
spent = await _spent_cents(session, user_id, budget.category_id, budget.month)
|
||||||
|
return BudgetResponse(
|
||||||
|
id=budget.id,
|
||||||
|
category_id=budget.category_id,
|
||||||
|
category_name=budget.category.name,
|
||||||
|
category_color=budget.category.color,
|
||||||
|
month=budget.month,
|
||||||
|
limit_cents=budget.limit_cents,
|
||||||
|
spent_cents=spent,
|
||||||
|
remaining_cents=max(0, budget.limit_cents - spent),
|
||||||
|
created_at=budget.created_at,
|
||||||
|
updated_at=budget.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_budgets(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
month: str,
|
||||||
|
) -> list[BudgetResponse]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.user_id == user_id, Budget.month == month)
|
||||||
|
.order_by(Budget.created_at)
|
||||||
|
)
|
||||||
|
budgets = result.scalars().all()
|
||||||
|
return [await _to_response(session, user_id, b) for b in budgets]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_budget(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
data: BudgetCreate,
|
||||||
|
) -> BudgetResponse:
|
||||||
|
# Check for duplicate
|
||||||
|
existing = await session.execute(
|
||||||
|
select(Budget).where(
|
||||||
|
Budget.user_id == user_id,
|
||||||
|
Budget.category_id == data.category_id,
|
||||||
|
Budget.month == data.month,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Budget already exists for this category and month",
|
||||||
|
)
|
||||||
|
|
||||||
|
budget = Budget(
|
||||||
|
user_id=user_id,
|
||||||
|
category_id=data.category_id,
|
||||||
|
month=data.month,
|
||||||
|
limit_cents=data.limit_cents,
|
||||||
|
)
|
||||||
|
session.add(budget)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.id == budget.id)
|
||||||
|
)
|
||||||
|
budget = result.scalar_one()
|
||||||
|
return await _to_response(session, user_id, budget)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_budget(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
budget_id: uuid.UUID,
|
||||||
|
data: BudgetUpdate,
|
||||||
|
) -> BudgetResponse:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||||
|
)
|
||||||
|
budget = result.scalar_one_or_none()
|
||||||
|
if budget is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||||
|
|
||||||
|
budget.limit_cents = data.limit_cents
|
||||||
|
budget.updated_at = utcnow()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.id == budget_id)
|
||||||
|
)
|
||||||
|
budget = result.scalar_one()
|
||||||
|
return await _to_response(session, user_id, budget)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_budget(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
budget_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||||
|
)
|
||||||
|
budget = result.scalar_one_or_none()
|
||||||
|
if budget is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||||
|
await session.delete(budget)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def rollover_budgets(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
target_month: str,
|
||||||
|
) -> list[BudgetResponse]:
|
||||||
|
"""Copy budgets from previous month to target_month (skip existing)."""
|
||||||
|
source_month = _prev_month(target_month)
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.user_id == user_id, Budget.month == source_month)
|
||||||
|
)
|
||||||
|
source_budgets = result.scalars().all()
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for src in source_budgets:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(Budget).where(
|
||||||
|
Budget.user_id == user_id,
|
||||||
|
Budget.category_id == src.category_id,
|
||||||
|
Budget.month == target_month,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
continue
|
||||||
|
new_budget = Budget(
|
||||||
|
user_id=user_id,
|
||||||
|
category_id=src.category_id,
|
||||||
|
month=target_month,
|
||||||
|
limit_cents=src.limit_cents,
|
||||||
|
)
|
||||||
|
session.add(new_budget)
|
||||||
|
created.append(new_budget)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for b in created:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(Budget.id == b.id)
|
||||||
|
)
|
||||||
|
b = result.scalar_one()
|
||||||
|
responses.append(await _to_response(session, user_id, b))
|
||||||
|
|
||||||
|
return responses
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.transaction import Transaction, TransactionType
|
||||||
|
from app.schemas.dashboard import (
|
||||||
|
BudgetAlert,
|
||||||
|
CategoryExpense,
|
||||||
|
DashboardResponse,
|
||||||
|
MonthlyTrend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(month: str) -> tuple[date, date]:
|
||||||
|
year, mon = map(int, month.split("-"))
|
||||||
|
start = date(year, mon, 1)
|
||||||
|
if mon == 12:
|
||||||
|
end = date(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = date(year, mon + 1, 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _prev_month(month: str) -> str:
|
||||||
|
year, mon = map(int, month.split("-"))
|
||||||
|
if mon == 1:
|
||||||
|
return f"{year - 1}-12"
|
||||||
|
return f"{year}-{mon - 1:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dashboard(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
month: str,
|
||||||
|
) -> DashboardResponse:
|
||||||
|
start, end = _month_bounds(month)
|
||||||
|
|
||||||
|
# --- All-time balance (cumulative since origin) ---
|
||||||
|
balance_result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||||
|
else_=-Transaction.amount_cents,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
).where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
balance_cents: int = balance_result.scalar_one()
|
||||||
|
|
||||||
|
# --- Month income & expenses ---
|
||||||
|
month_result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("income"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("expense"),
|
||||||
|
).where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
month_row = month_result.one()
|
||||||
|
total_income_cents: int = month_row.income
|
||||||
|
total_expense_cents: int = month_row.expense
|
||||||
|
|
||||||
|
# --- Expenses by category for the month ---
|
||||||
|
cat_result = await session.execute(
|
||||||
|
select(
|
||||||
|
Transaction.category_id,
|
||||||
|
func.sum(Transaction.amount_cents).label("total"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.type == TransactionType.expense,
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
.group_by(Transaction.category_id)
|
||||||
|
)
|
||||||
|
cat_rows = cat_result.all()
|
||||||
|
|
||||||
|
# Fetch category names
|
||||||
|
category_ids = [row.category_id for row in cat_rows]
|
||||||
|
by_category: list[CategoryExpense] = []
|
||||||
|
if category_ids:
|
||||||
|
cats_result = await session.execute(
|
||||||
|
select(Category).where(Category.id.in_(category_ids))
|
||||||
|
)
|
||||||
|
cats_map = {c.id: c for c in cats_result.scalars().all()}
|
||||||
|
for row in cat_rows:
|
||||||
|
cat = cats_map.get(row.category_id)
|
||||||
|
by_category.append(
|
||||||
|
CategoryExpense(
|
||||||
|
category_id=row.category_id,
|
||||||
|
category_name=cat.name if cat else "?",
|
||||||
|
color=cat.color if cat else None,
|
||||||
|
amount_cents=row.total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Monthly trend: last 6 months ---
|
||||||
|
monthly_trend: list[MonthlyTrend] = []
|
||||||
|
cur = month
|
||||||
|
months_to_fetch = []
|
||||||
|
for _ in range(6):
|
||||||
|
months_to_fetch.append(cur)
|
||||||
|
cur = _prev_month(cur)
|
||||||
|
months_to_fetch.reverse()
|
||||||
|
|
||||||
|
for m in months_to_fetch:
|
||||||
|
m_start, m_end = _month_bounds(m)
|
||||||
|
trend_result = await session.execute(
|
||||||
|
select(
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("income"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
func.case(
|
||||||
|
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("expense"),
|
||||||
|
).where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.transaction_date >= m_start,
|
||||||
|
Transaction.transaction_date < m_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
trend_row = trend_result.one()
|
||||||
|
monthly_trend.append(
|
||||||
|
MonthlyTrend(
|
||||||
|
month=m,
|
||||||
|
income_cents=trend_row.income,
|
||||||
|
expense_cents=trend_row.expense,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Budget alerts (>= 80% spent) ---
|
||||||
|
budgets_result = await session.execute(
|
||||||
|
select(Budget)
|
||||||
|
.options(selectinload(Budget.category))
|
||||||
|
.where(
|
||||||
|
Budget.user_id == user_id,
|
||||||
|
Budget.month == month,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
budgets = budgets_result.scalars().all()
|
||||||
|
|
||||||
|
budget_alerts: list[BudgetAlert] = []
|
||||||
|
for budget in budgets:
|
||||||
|
spent_result = await session.execute(
|
||||||
|
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
|
||||||
|
Transaction.user_id == user_id,
|
||||||
|
Transaction.deleted_at.is_(None),
|
||||||
|
Transaction.type == TransactionType.expense,
|
||||||
|
Transaction.category_id == budget.category_id,
|
||||||
|
Transaction.transaction_date >= start,
|
||||||
|
Transaction.transaction_date < end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
spent_cents: int = spent_result.scalar_one()
|
||||||
|
percentage = (spent_cents / budget.limit_cents * 100) if budget.limit_cents > 0 else 0
|
||||||
|
if percentage >= 80:
|
||||||
|
budget_alerts.append(
|
||||||
|
BudgetAlert(
|
||||||
|
budget_id=budget.id,
|
||||||
|
category_name=budget.category.name,
|
||||||
|
limit_cents=budget.limit_cents,
|
||||||
|
spent_cents=spent_cents,
|
||||||
|
percentage=round(percentage, 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
month=month,
|
||||||
|
balance_cents=balance_cents,
|
||||||
|
total_income_cents=total_income_cents,
|
||||||
|
total_expense_cents=total_expense_cents,
|
||||||
|
net_cents=total_income_cents - total_expense_cents,
|
||||||
|
by_category=by_category,
|
||||||
|
monthly_trend=monthly_trend,
|
||||||
|
budget_alerts=budget_alerts,
|
||||||
|
)
|
||||||
@@ -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 -
|
||||||
@@ -12,6 +12,8 @@ bcrypt==4.2.1
|
|||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
weasyprint==63.1
|
weasyprint==63.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
slowapi==0.1.9
|
||||||
# Test dependencies
|
# Test dependencies
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production}
|
||||||
DEBUG: "true"
|
DEBUG: "true"
|
||||||
|
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
@@ -20,6 +25,8 @@ services:
|
|||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
|
|||||||
+16
-10
@@ -6,15 +6,14 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-budget}
|
POSTGRES_USER: ${POSTGRES_USER:-budget}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
|
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
|
||||||
ports:
|
|
||||||
- "${DB_PORT:-5432}:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -24,13 +23,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]'
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||||
DEBUG: ${DEBUG:-false}
|
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||||
ports:
|
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]}
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
DEBUG: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -38,9 +43,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-80}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
+7
-2
@@ -1,15 +1,20 @@
|
|||||||
|
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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 --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+41
-6
@@ -3,13 +3,48 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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
@@ -1,10 +1,34 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import HomePage from "./pages/HomePage";
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<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>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+304
-5
@@ -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({
|
export const apiClient = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api/v1",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createBudget,
|
||||||
|
deleteBudget,
|
||||||
|
getBudgets,
|
||||||
|
rolloverBudgets,
|
||||||
|
updateBudget,
|
||||||
|
} from "../api/client";
|
||||||
|
import type { CreateBudgetPayload, UpdateBudgetPayload } from "../api/types";
|
||||||
|
|
||||||
|
export function useBudgets(month?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["budgets", month],
|
||||||
|
queryFn: () => getBudgets(month),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateBudget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateBudgetPayload) => createBudget(payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateBudget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: UpdateBudgetPayload }) =>
|
||||||
|
updateBudget(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteBudget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => deleteBudget(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRolloverBudgets() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (month: string) => rolloverBudgets(month),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getDashboard } from "../api/client";
|
||||||
|
|
||||||
|
export function useDashboard(month?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", month],
|
||||||
|
queryFn: () => getDashboard(month),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getHistory } from "../api/client";
|
||||||
|
|
||||||
|
export function useHistory(year?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["history", year],
|
||||||
|
queryFn: () => getHistory(year),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, CopyPlus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useBudgets,
|
||||||
|
useCreateBudget,
|
||||||
|
useUpdateBudget,
|
||||||
|
useDeleteBudget,
|
||||||
|
useRolloverBudgets,
|
||||||
|
} from "../hooks/useBudgets";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import DeleteConfirmModal from "../components/DeleteConfirmModal";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { formatCurrency, currentMonth } from "../utils/format";
|
||||||
|
import type { Budget } from "../api/types";
|
||||||
|
|
||||||
|
function prevMonth(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
if (m === 1) return `${y - 1}-12`;
|
||||||
|
return `${y}-${String(m - 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
if (m === 12) return `${y + 1}-01`;
|
||||||
|
return `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
|
||||||
|
new Date(y, m - 1, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ spent, limit }: { spent: number; limit: number }) {
|
||||||
|
const pct = limit > 0 ? Math.min((spent / limit) * 100, 100) : 0;
|
||||||
|
const color =
|
||||||
|
pct >= 100 ? "bg-red-500" : pct >= 70 ? "bg-orange-400" : "bg-green-500";
|
||||||
|
return (
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BudgetFormProps {
|
||||||
|
month: string;
|
||||||
|
editing?: Budget;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetForm({ month, editing, onClose }: BudgetFormProps) {
|
||||||
|
const { data: categoriesData } = useCategories();
|
||||||
|
const createBudget = useCreateBudget();
|
||||||
|
const updateBudget = useUpdateBudget();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const expenseCategories = categoriesData?.filter(
|
||||||
|
(c) => c.type === "expense" || c.type === "both"
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const [categoryId, setCategoryId] = useState(editing?.category_id ?? "");
|
||||||
|
const [limitEuros, setLimitEuros] = useState(
|
||||||
|
editing ? String(editing.limit_cents / 100) : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const limit_cents = Math.round(Number(limitEuros) * 100);
|
||||||
|
if (!limit_cents || limit_cents <= 0) {
|
||||||
|
addToast({ type: "error", message: "Limite invalide" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateBudget.mutateAsync({ id: editing.id, payload: { limit_cents } });
|
||||||
|
addToast({ type: "success", message: "Budget modifié" });
|
||||||
|
} else {
|
||||||
|
if (!categoryId) {
|
||||||
|
addToast({ type: "error", message: "Sélectionnez une catégorie" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createBudget.mutateAsync({ category_id: categoryId, month, limit_cents });
|
||||||
|
addToast({ type: "success", message: "Budget créé" });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
addToast({ type: "error", message: "Erreur lors de la sauvegarde" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
{editing ? "Modifier le budget" : "Nouveau budget"}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!editing && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||||
|
Catégorie
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{expenseCategories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-slate-700">
|
||||||
|
Limite (€)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
value={limitEuros}
|
||||||
|
onChange={(e) => setLimitEuros(e.target.value)}
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
placeholder="ex: 500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
{editing ? "Enregistrer" : "Créer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetsPage() {
|
||||||
|
const [month, setMonth] = useState(currentMonth());
|
||||||
|
const { data: budgets, isLoading } = useBudgets(month);
|
||||||
|
const deleteBudget = useDeleteBudget();
|
||||||
|
const rollover = useRolloverBudgets();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingBudget, setEditingBudget] = useState<Budget | undefined>();
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Budget | undefined>();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteBudget.mutateAsync(deleteTarget.id);
|
||||||
|
addToast({ type: "success", message: "Budget supprimé" });
|
||||||
|
} catch {
|
||||||
|
addToast({ type: "error", message: "Erreur lors de la suppression" });
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollover = async () => {
|
||||||
|
try {
|
||||||
|
const created = await rollover.mutateAsync(month);
|
||||||
|
if (created.length === 0) {
|
||||||
|
addToast({ type: "info", message: "Aucun budget à reconduire (déjà existants)" });
|
||||||
|
} else {
|
||||||
|
addToast({ type: "success", message: `${created.length} budget(s) reconduit(s)` });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addToast({ type: "error", message: "Erreur lors de la reconduction" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(prevMonth(month))}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-800 capitalize">
|
||||||
|
{formatMonthLabel(month)}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(nextMonth(month))}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRollover}
|
||||||
|
disabled={rollover.isPending}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CopyPlus size={15} />
|
||||||
|
Reconduire M-1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingBudget(undefined); setFormOpen(true); }}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Nouveau budget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-24 animate-pulse rounded-xl bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !budgets || budgets.length === 0 ? (
|
||||||
|
<div className="rounded-xl border bg-white p-10 text-center text-slate-400">
|
||||||
|
Aucun budget défini pour ce mois.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{budgets.map((budget) => {
|
||||||
|
const pct = budget.limit_cents > 0
|
||||||
|
? Math.round((budget.spent_cents / budget.limit_cents) * 100)
|
||||||
|
: 0;
|
||||||
|
const over = budget.spent_cents > budget.limit_cents;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={budget.id}
|
||||||
|
className="rounded-xl border bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{budget.category_color && (
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ background: budget.category_color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-slate-800">
|
||||||
|
{budget.category_name}
|
||||||
|
</span>
|
||||||
|
{over && (
|
||||||
|
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||||
|
Dépassé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingBudget(budget); setFormOpen(true); }}
|
||||||
|
className="rounded p-1 text-slate-400 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(budget)}
|
||||||
|
className="rounded p-1 text-slate-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgressBar spent={budget.spent_cents} limit={budget.limit_cents} />
|
||||||
|
<div className="mt-1.5 flex justify-between text-xs text-slate-500">
|
||||||
|
<span>{formatCurrency(budget.spent_cents)} dépensés</span>
|
||||||
|
<span className={over ? "font-semibold text-red-600" : ""}>
|
||||||
|
{pct}% — limite {formatCurrency(budget.limit_cents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form modal */}
|
||||||
|
{formOpen && (
|
||||||
|
<BudgetForm
|
||||||
|
month={month}
|
||||||
|
editing={editingBudget}
|
||||||
|
onClose={() => { setFormOpen(false); setEditingBudget(undefined); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
message={`Supprimer le budget "${deleteTarget.category_name}" ?`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteTarget(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useCategories, useDeleteCategory } from "../hooks/useCategories";
|
||||||
|
import CategoryModal from "../components/CategoryModal";
|
||||||
|
import DeleteConfirmModal from "../components/DeleteConfirmModal";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import type { Category } from "../api/types";
|
||||||
|
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const { data: categories, isLoading } = useCategories();
|
||||||
|
const deleteMutation = useDeleteCategory();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingCat, setEditingCat] = useState<Category | undefined>();
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Category | undefined>();
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingCat(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteMutation.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
addToast({ type: "success", message: "Catégorie supprimée" });
|
||||||
|
setDeleteTarget(undefined);
|
||||||
|
},
|
||||||
|
onError: () => addToast({ type: "error", message: "Erreur lors de la suppression" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = categories?.filter((c) => c.type === "expense" || c.type === "both") ?? [];
|
||||||
|
const income = categories?.filter((c) => c.type === "income") ?? [];
|
||||||
|
|
||||||
|
function CategoryRow({ cat }: { cat: Category }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-white px-4 py-3 shadow-sm">
|
||||||
|
{cat.color && (
|
||||||
|
<span className="h-4 w-4 rounded-full shrink-0" style={{ background: cat.color }} />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-sm font-medium text-slate-800">{cat.name}</span>
|
||||||
|
{cat.is_default && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">
|
||||||
|
Défaut
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCat(cat); setModalOpen(true); }}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(cat)}
|
||||||
|
className="p-1 text-slate-400 hover:text-red-600"
|
||||||
|
disabled={cat.is_default}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold text-slate-800">Catégories</h1>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Nouvelle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 animate-pulse rounded-lg bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Dépenses
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{expense.map((c) => <CategoryRow key={c.id} cat={c} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Revenus
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{income.map((c) => <CategoryRow key={c.id} cat={c} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CategoryModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
category={editingCat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
message={`Supprimer la catégorie "${deleteTarget.name}" ?`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteTarget(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Wallet,
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
import { downloadExport } from "../api/client";
|
||||||
|
import { formatCurrency, currentMonth } from "../utils/format";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
|
||||||
|
const PIE_COLORS = [
|
||||||
|
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#3b82f6",
|
||||||
|
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#84cc16",
|
||||||
|
];
|
||||||
|
|
||||||
|
function prevMonth(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
if (m === 1) return `${y - 1}-12`;
|
||||||
|
return `${y}-${String(m - 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
if (m === 12) return `${y + 1}-01`;
|
||||||
|
return `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
|
||||||
|
new Date(y, m - 1, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortMonth(month: string): string {
|
||||||
|
const [y, m] = month.split("-").map(Number);
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { month: "short" }).format(new Date(y, m - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
signed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, icon, color, signed }: KpiCardProps) {
|
||||||
|
const formatted = signed
|
||||||
|
? (value >= 0 ? "+" : "") + formatCurrency(value)
|
||||||
|
: formatCurrency(value);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
<span className={`rounded-full p-2 ${color}`}>{icon}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-2 text-2xl font-bold ${signed && value < 0 ? "text-red-600" : signed && value > 0 ? "text-green-600" : "text-slate-900"}`}>
|
||||||
|
{formatted}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [month, setMonth] = useState(currentMonth());
|
||||||
|
const { data, isLoading } = useDashboard(month);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleExportCsv = async () => {
|
||||||
|
try {
|
||||||
|
await downloadExport(`/api/v1/export/csv?month=${month}`, `transactions_${month}.csv`);
|
||||||
|
} catch {
|
||||||
|
addToast({ type: "error", message: "Erreur lors de l'export CSV" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = async () => {
|
||||||
|
try {
|
||||||
|
await downloadExport(`/api/v1/export/pdf?month=${month}`, `transactions_${month}.pdf`);
|
||||||
|
} catch {
|
||||||
|
addToast({ type: "error", message: "Erreur lors de l'export PDF" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendData = data?.monthly_trend.map((t) => ({
|
||||||
|
name: formatShortMonth(t.month),
|
||||||
|
Revenus: t.income_cents / 100,
|
||||||
|
Dépenses: t.expense_cents / 100,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const pieData = data?.by_category.map((c) => ({
|
||||||
|
name: c.category_name,
|
||||||
|
value: c.amount_cents / 100,
|
||||||
|
color: c.color ?? undefined,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(prevMonth(month))}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-800 capitalize">
|
||||||
|
{formatMonthLabel(month)}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(nextMonth(month))}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
disabled={month >= currentMonth()}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Download size={14} /> CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Download size={14} /> PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget alerts */}
|
||||||
|
{data?.budget_alerts && data.budget_alerts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.budget_alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.budget_id}
|
||||||
|
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm ${
|
||||||
|
alert.percentage >= 100
|
||||||
|
? "border-red-200 bg-red-50 text-red-800"
|
||||||
|
: "border-orange-200 bg-orange-50 text-orange-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} className="shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>{alert.category_name}</strong> :{" "}
|
||||||
|
{formatCurrency(alert.spent_cents)} dépensés sur{" "}
|
||||||
|
{formatCurrency(alert.limit_cents)} ({alert.percentage}%)
|
||||||
|
{alert.percentage >= 100 && " — Budget dépassé !"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/budgets")}
|
||||||
|
className="ml-auto shrink-0 font-medium underline"
|
||||||
|
>
|
||||||
|
Gérer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-28 animate-pulse rounded-xl bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="Solde courant"
|
||||||
|
value={data?.balance_cents ?? 0}
|
||||||
|
icon={<Wallet size={18} className="text-indigo-600" />}
|
||||||
|
color="bg-indigo-50"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Revenus du mois"
|
||||||
|
value={data?.total_income_cents ?? 0}
|
||||||
|
icon={<TrendingUp size={18} className="text-green-600" />}
|
||||||
|
color="bg-green-50"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Dépenses du mois"
|
||||||
|
value={data?.total_expense_cents ?? 0}
|
||||||
|
icon={<TrendingDown size={18} className="text-red-600" />}
|
||||||
|
color="bg-red-50"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Solde net du mois"
|
||||||
|
value={data?.net_cents ?? 0}
|
||||||
|
icon={<TrendingUp size={18} className="text-blue-600" />}
|
||||||
|
color="bg-blue-50"
|
||||||
|
signed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Bar chart — monthly trend */}
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">
|
||||||
|
Tendance 6 derniers mois
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={trendData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}€`} />
|
||||||
|
<Tooltip formatter={(v: number) => `${v.toFixed(2)} €`} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="Revenus" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Dépenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pie chart — expenses by category */}
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">
|
||||||
|
Dépenses par catégorie
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
|
||||||
|
) : pieData.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-slate-400">
|
||||||
|
Aucune dépense ce mois
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} ${(percent * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={entry.color ?? PIE_COLORS[index % PIE_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v: number) => `${v.toFixed(2)} €`} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { useHistory } from "../hooks/useHistory";
|
||||||
|
import { formatCurrency } from "../utils/format";
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"Jan", "Fév", "Mar", "Avr", "Mai", "Jun",
|
||||||
|
"Jul", "Aoû", "Sep", "Oct", "Nov", "Déc",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [year, setYear] = useState(currentYear);
|
||||||
|
const { data, isLoading } = useHistory(year);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const chartData = data?.months.map((m, i) => ({
|
||||||
|
name: MONTH_NAMES[i],
|
||||||
|
Revenus: m.income_cents / 100,
|
||||||
|
Dépenses: m.expense_cents / 100,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setYear((y) => y - 1)}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-800">{year}</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setYear((y) => y + 1)}
|
||||||
|
className="rounded-lg border p-2 hover:bg-slate-100"
|
||||||
|
disabled={year >= currentYear}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line chart */}
|
||||||
|
<div className="rounded-xl border bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">
|
||||||
|
Revenus et dépenses {year}
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-52 animate-pulse rounded-lg bg-slate-100" />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}€`} />
|
||||||
|
<Tooltip formatter={(v: number) => `${v.toFixed(2)} €`} />
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Revenus"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Dépenses"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly table */}
|
||||||
|
<div className="rounded-xl border bg-white shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-slate-50">
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-600">Mois</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-600">Revenus</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-600">Dépenses</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-600">Solde net</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-600">Transactions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b">
|
||||||
|
{Array.from({ length: 5 }).map((__, j) => (
|
||||||
|
<td key={j} className="px-4 py-3">
|
||||||
|
<div className="h-4 animate-pulse rounded bg-slate-100" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: data?.months.map((m, i) => {
|
||||||
|
const hasData = m.transaction_count > 0;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={m.month}
|
||||||
|
className={`border-b last:border-0 transition-colors ${
|
||||||
|
hasData ? "cursor-pointer hover:bg-slate-50" : "opacity-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => hasData && navigate(`/?month=${m.month}`)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-800">
|
||||||
|
{MONTH_NAMES[i]}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-green-700">
|
||||||
|
{m.income_cents > 0 ? formatCurrency(m.income_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-red-600">
|
||||||
|
{m.expense_cents > 0 ? formatCurrency(m.expense_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-4 py-3 text-right font-medium ${
|
||||||
|
m.balance_cents >= 0 ? "text-green-700" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.transaction_count > 0
|
||||||
|
? (m.balance_cents >= 0 ? "+" : "") + formatCurrency(m.balance_cents)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-slate-500">
|
||||||
|
{m.transaction_count > 0 ? m.transaction_count : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'inscrire
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user