Set up initial frontend with Vite and integrated Docker for full-stack build
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Dépendances
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
backend/node_modules/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Base de données locale
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Variables d'environnement
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE / éditeurs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code (settings locaux)
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Budget Commun
|
||||||
|
|
||||||
|
Application web PWA pour gérer un budget partagé en couple (ou en famille).
|
||||||
|
Hébergeable sur un NAS ou tout serveur local. Installable sur iPhone/Android depuis Safari/Chrome.
|
||||||
|
|
||||||
|
**Fonctionnalités :**
|
||||||
|
- Budget commun + budgets personnels par utilisateur
|
||||||
|
- Dépenses récurrentes (mensuel, hebdo, annuel, ponctuel, paiement en x fois)
|
||||||
|
- Pointage mensuel des dépenses
|
||||||
|
- Simulateur de projets d'épargne
|
||||||
|
- Gestion des épargnes avec suivi du solde
|
||||||
|
- Thème sombre / clair / système
|
||||||
|
- Pull-to-refresh sur mobile
|
||||||
|
- PWA installable (fonctionne hors-ligne en lecture)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
- **Frontend** : React + Vite (SPA, PWA)
|
||||||
|
- **Backend** : Node.js / Express
|
||||||
|
- **Base de données** : PostgreSQL 16
|
||||||
|
- **Authentification** : JWT (30 jours)
|
||||||
|
- **Déploiement** : Docker / Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
budget-commun/
|
||||||
|
├── docker-compose.yml ← déploiement standard
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile ← build multi-stage (frontend + backend)
|
||||||
|
│ ├── server.js ← API Express + initialisation BDD
|
||||||
|
│ └── package.json
|
||||||
|
└── frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/ ← Dashboard, Dépenses, Pointage, Simulateur, Épargnes…
|
||||||
|
│ ├── contexts/ ← Auth, Budget, Theme
|
||||||
|
│ ├── hooks/
|
||||||
|
│ └── api/
|
||||||
|
├── public/
|
||||||
|
│ ├── sw.js ← Service Worker (network-first)
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ └── favicon.svg
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le Dockerfile est **multi-stage** : il build le frontend Vite, puis l'embarque dans l'image Node.js qui sert à la fois l'API et les fichiers statiques sur le port **3000**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage rapide (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est accessible sur `http://localhost:3456`.
|
||||||
|
|
||||||
|
**Identifiants par défaut :**
|
||||||
|
| Utilisateur | Mot de passe |
|
||||||
|
|-------------|--------------|
|
||||||
|
| user1 | password1 |
|
||||||
|
| user2 | password2 |
|
||||||
|
|
||||||
|
> **Sécurité** : changez les mots de passe depuis **Mon compte → Changer le mot de passe** après la première connexion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Les variables d'environnement peuvent être définies dans un fichier `.env` (voir `.gitignore`) :
|
||||||
|
|
||||||
|
| Variable | Défaut | Description |
|
||||||
|
|----------------|--------------------------------------------------|------------------------------------|
|
||||||
|
| `DATABASE_URL` | `postgres://budget:budget@db:5432/budget` | Connexion PostgreSQL |
|
||||||
|
| `JWT_SECRET` | `budget-commun-secret-change-me` | Secret de signature JWT |
|
||||||
|
| `ADMIN_USER` | `user1` | Nom d'utilisateur administrateur |
|
||||||
|
|
||||||
|
Pour la production, définissez au minimum `JWT_SECRET` avec une valeur aléatoire longue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement sur TrueNAS SCALE (Custom App)
|
||||||
|
|
||||||
|
TrueNAS ne supporte pas `docker compose` directement. Utilisez **Custom App** via `midclt` :
|
||||||
|
|
||||||
|
### 1. Charger l'image sur le NAS
|
||||||
|
|
||||||
|
Depuis votre machine de build :
|
||||||
|
```bash
|
||||||
|
# Build pour l'architecture amd64 (TrueNAS)
|
||||||
|
docker buildx build --platform linux/amd64 -t budget-commun:latest -f backend/Dockerfile .
|
||||||
|
|
||||||
|
# Transférer sur le NAS
|
||||||
|
docker save budget-commun:latest | ssh admin@<IP-NAS> "sudo docker load"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Créer l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh admin@<IP-NAS>
|
||||||
|
|
||||||
|
sudo midclt call app.create '{
|
||||||
|
"name": "budget-commun",
|
||||||
|
"custom_app": true,
|
||||||
|
"custom_compose_config": {
|
||||||
|
"services": {
|
||||||
|
"db": {
|
||||||
|
"image": "postgres:16-alpine",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"environment": {
|
||||||
|
"POSTGRES_DB": "budget",
|
||||||
|
"POSTGRES_USER": "budget",
|
||||||
|
"POSTGRES_PASSWORD": "budget"
|
||||||
|
},
|
||||||
|
"volumes": ["pgdata:/var/lib/postgresql/data"]
|
||||||
|
},
|
||||||
|
"budget-commun": {
|
||||||
|
"image": "budget-commun:latest",
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
"ports": ["23000:3000"],
|
||||||
|
"depends_on": ["db"],
|
||||||
|
"environment": {
|
||||||
|
"DATABASE_URL": "postgres://budget:budget@db:5432/budget",
|
||||||
|
"JWT_SECRET": "changez-cette-valeur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"volumes": { "pgdata": {} }
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Mises à jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild + transfert
|
||||||
|
docker buildx build --platform linux/amd64 -t budget-commun:latest -f backend/Dockerfile .
|
||||||
|
docker save budget-commun:latest | ssh admin@<IP-NAS> "sudo docker load"
|
||||||
|
|
||||||
|
# Redéployer
|
||||||
|
ssh admin@<IP-NAS> "sudo midclt call app.redeploy budget-commun"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation sur mobile (PWA)
|
||||||
|
|
||||||
|
**iPhone (Safari) :**
|
||||||
|
1. Ouvrir l'URL de l'application dans Safari
|
||||||
|
2. Appuyer sur **Partager** → **Sur l'écran d'accueil**
|
||||||
|
3. Nommer l'app et confirmer
|
||||||
|
|
||||||
|
**Android (Chrome) :**
|
||||||
|
1. Ouvrir l'URL dans Chrome
|
||||||
|
2. Menu → **Ajouter à l'écran d'accueil**
|
||||||
|
|
||||||
|
> Les deux appareils doivent être sur le même réseau Wi-Fi que le serveur.
|
||||||
|
> Pour un accès depuis l'extérieur, configurez un reverse proxy (Nginx, Traefik) ou un VPN (WireGuard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Développement local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && npm install
|
||||||
|
DATABASE_URL=postgres://budget:budget@localhost:5432/budget node server.js
|
||||||
|
|
||||||
|
# Frontend (dans un autre terminal)
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le frontend Vite proxifie `/api` vers `http://localhost:3000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sauvegarde
|
||||||
|
|
||||||
|
Les données sont dans le volume Docker `pgdata`. Pour sauvegarder :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec <container-postgres> pg_dump -U budget budget > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour restaurer :
|
||||||
|
```bash
|
||||||
|
docker exec -i <container-postgres> psql -U budget budget < backup.sql
|
||||||
|
```
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Build context : racine du projet (budget-commun/)
|
||||||
|
|
||||||
|
# ── Stage 1 : Build React ─────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend .
|
||||||
|
RUN npx vite build --outDir /dist --emptyOutDir
|
||||||
|
|
||||||
|
# ── Stage 2 : Backend Node.js ──────────────────────────────────
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/package.json .
|
||||||
|
RUN npm install --production
|
||||||
|
COPY backend .
|
||||||
|
COPY --from=frontend-builder /dist ./public
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
Generated
+1127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "budget-commun-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.13.3",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"bcryptjs": "^2.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Budget Commun",
|
||||||
|
"short_name": "Budget",
|
||||||
|
"description": "Gestion du budget commun du couple",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f0e0c",
|
||||||
|
"theme_color": "#0f0e0c",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const CACHE = "budget-commun-v2";
|
||||||
|
const ASSETS = ["/", "/index.html", "/manifest.json"];
|
||||||
|
|
||||||
|
self.addEventListener("install", e => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", e => {
|
||||||
|
e.waitUntil(caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||||
|
));
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", e => {
|
||||||
|
// API calls: network only
|
||||||
|
if (e.request.url.includes("/api/")) return;
|
||||||
|
// Static: cache first
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then(cached => cached || fetch(e.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { Pool } = require("pg");
|
||||||
|
const cors = require("cors");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "budget-commun-secret-change-me";
|
||||||
|
const JWT_EXPIRY = "30d";
|
||||||
|
const ADMIN_USER = process.env.ADMIN_USER || "user1";
|
||||||
|
|
||||||
|
// ── Database ──────────────────────────────────────────────────
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || "postgres://budget:budget@localhost:5432/budget",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForDb(retries = 15, delay = 2000) {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1");
|
||||||
|
console.log("PostgreSQL connecté.");
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
console.log(`Attente de la base de données... (${i + 1}/${retries})`);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Impossible de se connecter à PostgreSQL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDb() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
nom TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('commun','perso')),
|
||||||
|
owner_user_id INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL DEFAULT '📦',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
cat TEXT NOT NULL DEFAULT 'autre',
|
||||||
|
freq TEXT NOT NULL DEFAULT 'mensuel',
|
||||||
|
month TEXT,
|
||||||
|
budget_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pointage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
expense_id INTEGER NOT NULL,
|
||||||
|
month TEXT NOT NULL,
|
||||||
|
pointed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pointed_at TEXT,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
budget_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(expense_id, month)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS savings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
budget_id INTEGER NOT NULL REFERENCES budgets(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL DEFAULT '💰',
|
||||||
|
monthly_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
balance REAL NOT NULL DEFAULT 0,
|
||||||
|
target REAL,
|
||||||
|
closed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
expense_id INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS savings_transactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
saving_id INTEGER NOT NULL REFERENCES savings(id) ON DELETE CASCADE,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS installments INTEGER;
|
||||||
|
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS installments_paid INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed users
|
||||||
|
const { rows: [{ c: userCount }] } = await pool.query("SELECT COUNT(*) AS c FROM users");
|
||||||
|
if (parseInt(userCount) === 0) {
|
||||||
|
await pool.query("INSERT INTO users (username,password) VALUES ($1,$2)", ["user1", bcrypt.hashSync("password1", 10)]);
|
||||||
|
await pool.query("INSERT INTO users (username,password) VALUES ($1,$2)", ["user2", bcrypt.hashSync("password2", 10)]);
|
||||||
|
console.log("Utilisateurs créés : user1 / user2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed budgets
|
||||||
|
const { rows: [{ c: budgetCount }] } = await pool.query("SELECT COUNT(*) AS c FROM budgets");
|
||||||
|
if (parseInt(budgetCount) === 0) {
|
||||||
|
const { rows: [u1] } = await pool.query("SELECT id FROM users WHERE username='user1'");
|
||||||
|
const { rows: [u2] } = await pool.query("SELECT id FROM users WHERE username='user2'");
|
||||||
|
await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (1,'Commun','commun',NULL) ON CONFLICT DO NOTHING");
|
||||||
|
await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (2,'Perso A','perso',$1) ON CONFLICT DO NOTHING", [u1.id]);
|
||||||
|
await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (3,'Perso B','perso',$1) ON CONFLICT DO NOTHING", [u2.id]);
|
||||||
|
await pool.query("SELECT setval('budgets_id_seq', 3)");
|
||||||
|
console.log("Budgets créés : Commun, Perso A, Perso B");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed categories
|
||||||
|
const { rows: [{ c: catCount }] } = await pool.query("SELECT COUNT(*) AS c FROM categories");
|
||||||
|
if (parseInt(catCount) === 0) {
|
||||||
|
const defaults = [
|
||||||
|
{ slug:"logement", label:"Logement", emoji:"🏠", sort:1 },
|
||||||
|
{ slug:"alimentation", label:"Courses", emoji:"🛒", sort:2 },
|
||||||
|
{ slug:"transport", label:"Transport", emoji:"🚗", sort:3 },
|
||||||
|
{ slug:"abonnements", label:"Abonnements", emoji:"📱", sort:4 },
|
||||||
|
{ slug:"sante", label:"Santé", emoji:"💊", sort:5 },
|
||||||
|
{ slug:"loisirs", label:"Loisirs", emoji:"🎉", sort:6 },
|
||||||
|
{ slug:"enfants", label:"Enfants", emoji:"👶", sort:7 },
|
||||||
|
{ slug:"autre", label:"Autre", emoji:"📦", sort:8 },
|
||||||
|
];
|
||||||
|
for (const c of defaults) {
|
||||||
|
await pool.query("INSERT INTO categories (slug,label,emoji,active,sort) VALUES ($1,$2,$3,1,$4)", [c.slug, c.label, c.emoji, c.sort]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO categories (slug,label,emoji,active,sort) VALUES ('epargne','Épargne','💰',1,9) ON CONFLICT DO NOTHING"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed settings
|
||||||
|
const defs = { nameA:"", nameB:"", incomeA:"", incomeB:"", sliderVal:"63", sliderLocked:"false", setupDone:"false" };
|
||||||
|
for (const [k, v] of Object.entries(defs)) {
|
||||||
|
await pool.query("INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO NOTHING", [k, v]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware auth ───────────────────────────────────────────
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const h = req.headers.authorization;
|
||||||
|
if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Non authentifié" });
|
||||||
|
try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); }
|
||||||
|
catch { res.status(401).json({ error: "Token invalide ou expiré" }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
requireAuth(req, res, () => {
|
||||||
|
if (req.user.username !== ADMIN_USER) return res.status(403).json({ error: "Accès réservé à l'administrateur" });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth routes ───────────────────────────────────────────────
|
||||||
|
app.post("/api/auth/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "Champs manquants" });
|
||||||
|
const { rows } = await pool.query("SELECT * FROM users WHERE username=$1", [username]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user || !bcrypt.compareSync(password, user.password))
|
||||||
|
return res.status(401).json({ error: "Identifiants incorrects" });
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
||||||
|
res.json({ token, username: user.username, isAdmin: user.username === ADMIN_USER });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/auth/me", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT username FROM users WHERE id=$1 AND username=$2",
|
||||||
|
[req.user.id, req.user.username]
|
||||||
|
);
|
||||||
|
if (!rows[0]) return res.status(401).json({ error: "Session expirée" });
|
||||||
|
res.json({ username: rows[0].username, isAdmin: rows[0].username === ADMIN_USER });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/change-password", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Champs manquants" });
|
||||||
|
if (newPassword.length < 6) return res.status(400).json({ error: "6 caractères minimum" });
|
||||||
|
const { rows } = await pool.query("SELECT * FROM users WHERE id=$1", [req.user.id]);
|
||||||
|
if (!bcrypt.compareSync(currentPassword, rows[0].password))
|
||||||
|
return res.status(401).json({ error: "Mot de passe actuel incorrect" });
|
||||||
|
await pool.query("UPDATE users SET password=$1 WHERE id=$2", [bcrypt.hashSync(newPassword, 10), req.user.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Settings ──────────────────────────────────────────────────
|
||||||
|
app.get("/api/settings", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query("SELECT key,value FROM settings");
|
||||||
|
const obj = {};
|
||||||
|
rows.forEach(r => obj[r.key] = r.value);
|
||||||
|
res.json(obj);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/settings", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
for (const [k, v] of Object.entries(req.body)) {
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2",
|
||||||
|
[k, String(v)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Categories ────────────────────────────────────────────────
|
||||||
|
app.get("/api/categories", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query("SELECT * FROM categories ORDER BY sort ASC");
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/categories", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { label, emoji } = req.body;
|
||||||
|
if (!label) return res.status(400).json({ error: "label requis" });
|
||||||
|
const slug = label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"") + "-" + Date.now();
|
||||||
|
const { rows: [{ m }] } = await pool.query("SELECT MAX(sort) AS m FROM categories");
|
||||||
|
const maxSort = m || 0;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"INSERT INTO categories (slug,label,emoji,active,sort) VALUES ($1,$2,$3,1,$4) RETURNING *",
|
||||||
|
[slug, label.trim(), emoji || "📦", maxSort + 1]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/categories/:id", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { label, emoji, active } = req.body;
|
||||||
|
const { rows } = await pool.query("SELECT * FROM categories WHERE id=$1", [req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: "Catégorie introuvable" });
|
||||||
|
const cat = rows[0];
|
||||||
|
const { rows: updated } = await pool.query(
|
||||||
|
"UPDATE categories SET label=$1,emoji=$2,active=$3 WHERE id=$4 RETURNING *",
|
||||||
|
[label ?? cat.label, emoji ?? cat.emoji, active !== undefined ? (active ? 1 : 0) : cat.active, req.params.id]
|
||||||
|
);
|
||||||
|
res.json(updated[0]);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/categories/:id", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query("SELECT * FROM categories WHERE id=$1", [req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: "Catégorie introuvable" });
|
||||||
|
await pool.query("UPDATE expenses SET cat='autre' WHERE cat=$1", [rows[0].slug]);
|
||||||
|
await pool.query("DELETE FROM categories WHERE id=$1", [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Expenses ──────────────────────────────────────────────────
|
||||||
|
app.get("/api/expenses", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const budgetId = parseInt(req.query.budget_id || req.headers["x-budget-id"]) || 1;
|
||||||
|
const { rows } = await pool.query("SELECT * FROM expenses WHERE budget_id=$1 ORDER BY created_at ASC", [budgetId]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/expenses", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, amount, cat, freq, month, installments } = req.body;
|
||||||
|
if (!name || !amount) return res.status(400).json({ error: "name and amount required" });
|
||||||
|
const finalFreq = month ? "ponctuel" : (freq || "mensuel");
|
||||||
|
const budgetId = parseInt(req.headers["x-budget-id"]) || 1;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"INSERT INTO expenses (name,amount,cat,freq,month,budget_id,installments) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *",
|
||||||
|
[name, amount, cat || "autre", finalFreq, month || null, budgetId, installments || null]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/expenses/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, amount, cat, freq, month, installments, installments_paid } = req.body;
|
||||||
|
if (!name || !amount) return res.status(400).json({ error: "name and amount required" });
|
||||||
|
const { rows } = await pool.query("SELECT * FROM expenses WHERE id=$1", [req.params.id]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: "Dépense introuvable" });
|
||||||
|
const existing = rows[0];
|
||||||
|
const finalFreq = month ? "ponctuel" : (freq || existing.freq);
|
||||||
|
const { rows: updated } = await pool.query(
|
||||||
|
"UPDATE expenses SET name=$1,amount=$2,cat=$3,freq=$4,month=$5,installments=$6,installments_paid=$7 WHERE id=$8 RETURNING *",
|
||||||
|
[name, amount, cat || existing.cat, finalFreq, month || null,
|
||||||
|
installments ?? existing.installments,
|
||||||
|
installments_paid !== undefined ? installments_paid : existing.installments_paid,
|
||||||
|
req.params.id]
|
||||||
|
);
|
||||||
|
res.json(updated[0]);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/expenses/:id/pay-installment", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows: [exp] } = await pool.query("SELECT * FROM expenses WHERE id=$1", [req.params.id]);
|
||||||
|
if (!exp) return res.status(404).json({ error: "Dépense introuvable" });
|
||||||
|
if (!exp.installments || exp.installments_paid >= exp.installments)
|
||||||
|
return res.status(400).json({ error: "Toutes les mensualités sont déjà payées" });
|
||||||
|
const { rows: [updated] } = await pool.query(
|
||||||
|
"UPDATE expenses SET installments_paid = installments_paid + 1 WHERE id=$1 RETURNING *",
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/expenses/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query("DELETE FROM expenses WHERE id=$1", [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Savings ───────────────────────────────────────────────────
|
||||||
|
app.get("/api/savings", requireAuth, async (req, res) => {
|
||||||
|
const budgetId = req.query.budget_id || req.headers["x-budget-id"] || 1;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT * FROM savings WHERE budget_id=$1 ORDER BY closed, created_at DESC",
|
||||||
|
[budgetId]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/savings", requireAuth, async (req, res) => {
|
||||||
|
const budgetId = req.headers["x-budget-id"] || 1;
|
||||||
|
const { name, emoji = "💰", monthly_amount = 0, target } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: "Nom requis" });
|
||||||
|
|
||||||
|
// Create linked expense if monthly_amount > 0
|
||||||
|
let expenseId = null;
|
||||||
|
if (parseFloat(monthly_amount) > 0) {
|
||||||
|
const { rows: [exp] } = await pool.query(
|
||||||
|
"INSERT INTO expenses (name,amount,cat,freq,budget_id) VALUES ($1,$2,'epargne','mensuel',$3) RETURNING *",
|
||||||
|
[emoji + " " + name, parseFloat(monthly_amount), budgetId]
|
||||||
|
);
|
||||||
|
expenseId = exp.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: [saving] } = await pool.query(
|
||||||
|
"INSERT INTO savings (budget_id,name,emoji,monthly_amount,balance,target,expense_id) VALUES ($1,$2,$3,$4,0,$5,$6) RETURNING *",
|
||||||
|
[budgetId, name, emoji, parseFloat(monthly_amount) || 0, target ? parseFloat(target) : null, expenseId]
|
||||||
|
);
|
||||||
|
res.json(saving);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/savings/:id", requireAuth, async (req, res) => {
|
||||||
|
const { name, emoji, monthly_amount, target } = req.body;
|
||||||
|
const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]);
|
||||||
|
if (!saving) return res.status(404).json({ error: "Introuvable" });
|
||||||
|
|
||||||
|
// Update linked expense amount if changed
|
||||||
|
if (saving.expense_id && monthly_amount !== undefined) {
|
||||||
|
if (parseFloat(monthly_amount) > 0) {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE expenses SET amount=$1, name=$2 WHERE id=$3",
|
||||||
|
[parseFloat(monthly_amount), (emoji || saving.emoji) + " " + (name || saving.name), saving.expense_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]);
|
||||||
|
await pool.query("UPDATE savings SET expense_id=NULL WHERE id=$1", [saving.id]);
|
||||||
|
}
|
||||||
|
} else if (!saving.expense_id && monthly_amount && parseFloat(monthly_amount) > 0) {
|
||||||
|
// Create expense if now has monthly amount
|
||||||
|
const { rows: [exp] } = await pool.query(
|
||||||
|
"INSERT INTO expenses (name,amount,cat,freq,budget_id) VALUES ($1,$2,'epargne','mensuel',$3) RETURNING *",
|
||||||
|
[(emoji || saving.emoji) + " " + (name || saving.name), parseFloat(monthly_amount), saving.budget_id]
|
||||||
|
);
|
||||||
|
await pool.query("UPDATE savings SET expense_id=$1 WHERE id=$2", [exp.id, saving.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: [updated] } = await pool.query(
|
||||||
|
`UPDATE savings SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
emoji = COALESCE($2, emoji),
|
||||||
|
monthly_amount = COALESCE($3, monthly_amount),
|
||||||
|
target = $4
|
||||||
|
WHERE id=$5 RETURNING *`,
|
||||||
|
[name, emoji, monthly_amount !== undefined ? parseFloat(monthly_amount) : null, target ? parseFloat(target) : null, req.params.id]
|
||||||
|
);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/savings/:id/transaction", requireAuth, async (req, res) => {
|
||||||
|
const { amount, note = "" } = req.body;
|
||||||
|
if (!amount || isNaN(parseFloat(amount))) return res.status(400).json({ error: "Montant invalide" });
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO savings_transactions (saving_id,amount,note) VALUES ($1,$2,$3)",
|
||||||
|
[req.params.id, parseFloat(amount), note]
|
||||||
|
);
|
||||||
|
const { rows: [saving] } = await pool.query(
|
||||||
|
"UPDATE savings SET balance = balance + $1 WHERE id=$2 RETURNING *",
|
||||||
|
[parseFloat(amount), req.params.id]
|
||||||
|
);
|
||||||
|
res.json(saving);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/savings/:id/transactions", requireAuth, async (req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT * FROM savings_transactions WHERE saving_id=$1 ORDER BY created_at DESC LIMIT 20",
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/savings/:id/close", requireAuth, async (req, res) => {
|
||||||
|
const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]);
|
||||||
|
if (!saving) return res.status(404).json({ error: "Introuvable" });
|
||||||
|
|
||||||
|
// Delete linked expense
|
||||||
|
if (saving.expense_id) {
|
||||||
|
await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]);
|
||||||
|
}
|
||||||
|
const { rows: [updated] } = await pool.query(
|
||||||
|
"UPDATE savings SET closed=TRUE, expense_id=NULL WHERE id=$1 RETURNING *",
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/savings/:id", requireAuth, async (req, res) => {
|
||||||
|
const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]);
|
||||||
|
if (!saving) return res.status(404).json({ error: "Introuvable" });
|
||||||
|
if (saving.expense_id) {
|
||||||
|
await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]);
|
||||||
|
}
|
||||||
|
await pool.query("DELETE FROM savings WHERE id=$1", [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin ─────────────────────────────────────────────────────
|
||||||
|
app.get("/api/admin/users", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query("SELECT id,username FROM users");
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pointage ──────────────────────────────────────────────────
|
||||||
|
const POINTAGE_START_DAY = 26;
|
||||||
|
|
||||||
|
function currentMonth() {
|
||||||
|
const d = new Date();
|
||||||
|
if (d.getDate() < POINTAGE_START_DAY) {
|
||||||
|
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1);
|
||||||
|
return prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth(m) {
|
||||||
|
const [y, mo] = m.split('-').map(Number);
|
||||||
|
const d = new Date(y, mo - 2, 1);
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePointageForMonth(month, budgetId = 1) {
|
||||||
|
const { rows: recurring } = await pool.query(
|
||||||
|
"SELECT * FROM expenses WHERE freq != 'ponctuel' AND budget_id=$1",
|
||||||
|
[budgetId]
|
||||||
|
);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
for (const e of recurring) {
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO pointage (expense_id,month,pointed,amount,name,budget_id) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (expense_id,month) DO NOTHING",
|
||||||
|
[e.id, month, e.amount, e.name, budgetId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/pointage", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const month = req.query.month || currentMonth();
|
||||||
|
const budgetId = parseInt(req.headers["x-budget-id"]) || 1;
|
||||||
|
|
||||||
|
await generatePointageForMonth(month, budgetId);
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT p.*,e.cat,e.freq FROM pointage p
|
||||||
|
LEFT JOIN expenses e ON p.expense_id=e.id
|
||||||
|
WHERE p.month=$1 AND p.budget_id=$2 ORDER BY p.pointed ASC,p.name ASC
|
||||||
|
`, [month, budgetId]);
|
||||||
|
|
||||||
|
const prev = prevMonth(month);
|
||||||
|
await generatePointageForMonth(prev, budgetId);
|
||||||
|
const { rows: unpointedPrev } = await pool.query(`
|
||||||
|
SELECT p.*,e.cat,e.freq FROM pointage p
|
||||||
|
LEFT JOIN expenses e ON p.expense_id=e.id
|
||||||
|
WHERE p.month=$1 AND p.pointed=0 AND p.budget_id=$2 ORDER BY p.name ASC
|
||||||
|
`, [prev, budgetId]);
|
||||||
|
|
||||||
|
res.json({ month, rows, unpointedPrev, prevMonth: prev });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/pointage/:id", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { pointed } = req.body;
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE pointage SET pointed=$1,pointed_at=$2 WHERE id=$3",
|
||||||
|
[pointed ? 1 : 0, pointed ? new Date().toISOString() : null, req.params.id]
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query("SELECT * FROM pointage WHERE id=$1", [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/pointage/generate", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const month = req.body.month || currentMonth();
|
||||||
|
const budgetId = parseInt(req.headers["x-budget-id"]) || 1;
|
||||||
|
await generatePointageForMonth(month, budgetId);
|
||||||
|
res.json({ ok: true, month });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Serve frontend ────────────────────────────────────────────
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
app.get("*", (req, res) => res.sendFile(path.join(__dirname, "public", "index.html")));
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────
|
||||||
|
async function start() {
|
||||||
|
await waitForDb();
|
||||||
|
await initDb();
|
||||||
|
app.listen(3000, () => console.log("Budget Commun running on :3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch(err => {
|
||||||
|
console.error("Erreur au démarrage :", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: budget-commun-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: budget
|
||||||
|
POSTGRES_USER: budget
|
||||||
|
POSTGRES_PASSWORD: budget
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
budget-commun:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
container_name: budget-commun
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3456:3000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgres://budget:budget@db:5432/budget
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Budget" />
|
||||||
|
<meta name="theme-color" content="#0f0e0c" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Mono:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Budget Commun</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1677
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "budget-commun-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#1a1814"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" font-family="serif" font-size="18" font-weight="700" fill="#e8b86d">B</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
const CACHE = 'budget-commun-v3'
|
||||||
|
const ASSETS = ['/', '/index.html', '/manifest.json']
|
||||||
|
|
||||||
|
self.addEventListener('install', e => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)))
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', e => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.clients.claim()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', e => {
|
||||||
|
// API : réseau uniquement, jamais de cache
|
||||||
|
if (e.request.url.includes('/api/')) return
|
||||||
|
|
||||||
|
// Assets statiques : network first → cache en fallback offline
|
||||||
|
e.respondWith(
|
||||||
|
fetch(e.request)
|
||||||
|
.then(response => {
|
||||||
|
const clone = response.clone()
|
||||||
|
caches.open(CACHE).then(c => c.put(e.request, clone))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(e.request))
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
|
import { BudgetProvider, useBudget } from './contexts/BudgetContext'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { usePullToRefresh } from './hooks/usePullToRefresh'
|
||||||
|
import { api } from './api/client'
|
||||||
|
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import SetupPage from './pages/SetupPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import ExpensesPage from './pages/ExpensesPage'
|
||||||
|
import PointagePage from './pages/PointagePage'
|
||||||
|
import SimulateurPage from './pages/SimulateurPage'
|
||||||
|
import AdminPage from './pages/AdminPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import EpargnesPage from './pages/EpargnesPage'
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────
|
||||||
|
function Toast({ msg, type }) {
|
||||||
|
return (
|
||||||
|
<div className={`toast show t-${type}`}>{msg}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget switcher ───────────────────────────────────────────
|
||||||
|
function BudgetSwitcher() {
|
||||||
|
const { activeBudgetId, accessibleBudgets, switchBudget } = useBudget()
|
||||||
|
if (accessibleBudgets.length <= 1) return null
|
||||||
|
return (
|
||||||
|
<div className="budget-switcher">
|
||||||
|
{accessibleBudgets.map(b => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
className={`budget-switch-btn${b.id === activeBudgetId ? ' active' : ''}`}
|
||||||
|
onClick={() => switchBudget(b.id)}
|
||||||
|
>
|
||||||
|
{b.emoji} {b.nom}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main shell (requires auth) ────────────────────────────────
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
nameA: '', nameB: '',
|
||||||
|
incomeA: '', incomeB: '',
|
||||||
|
sliderVal: 63, sliderLocked: false,
|
||||||
|
setupDone: 'false',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'budget', label: 'Budget', dot: 'var(--aa)' },
|
||||||
|
{ id: 'depenses', label: 'Dépenses', dot: 'var(--ab)' },
|
||||||
|
{ id: 'pointage', label: 'Pointage', dot: 'var(--aa)' },
|
||||||
|
{ id: 'simu', label: 'Simulateur', dot: '#f9a875' },
|
||||||
|
{ id: 'epargnes', label: 'Épargnes', dot: 'var(--ab)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function AppShell() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const { activeBudget, activeBudgetId } = useBudget()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState('budget')
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false)
|
||||||
|
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
||||||
|
const [expenses, setExpenses] = useState([])
|
||||||
|
const [commonExpenses, setCommonExpenses] = useState([])
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const [toastTimer, setToastTimer] = useState(null)
|
||||||
|
|
||||||
|
function showToast(msg, type = 'ok') {
|
||||||
|
setToast({ msg, type })
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
const t = setTimeout(() => setToast(null), 2600)
|
||||||
|
setToastTimer(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [s, e, c, ce] = await Promise.all([
|
||||||
|
api.get('/settings'),
|
||||||
|
api.get('/expenses'),
|
||||||
|
api.get('/categories'),
|
||||||
|
api.get('/expenses?budget_id=1'),
|
||||||
|
])
|
||||||
|
setSettings({
|
||||||
|
...DEFAULT_SETTINGS, ...s,
|
||||||
|
sliderVal: parseInt(s.sliderVal) || 63,
|
||||||
|
sliderLocked: s.sliderLocked === 'true',
|
||||||
|
})
|
||||||
|
setExpenses(e)
|
||||||
|
setCategories(c)
|
||||||
|
setCommonExpenses(ce)
|
||||||
|
} catch {
|
||||||
|
showToast('Impossible de joindre le serveur', 'err')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (user) loadAll() }, [user, activeBudgetId, loadAll])
|
||||||
|
|
||||||
|
const { pullY, refreshing, triggered } = usePullToRefresh(loadAll)
|
||||||
|
|
||||||
|
const visibleTabs = TABS
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.setupDone !== 'true') {
|
||||||
|
return <SetupPage onComplete={loadAll} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
{/* Pull-to-refresh indicator */}
|
||||||
|
{(pullY > 0 || refreshing) && (
|
||||||
|
<div className="ptr-indicator" style={{ '--ptr-y': `${pullY}px` }}>
|
||||||
|
<div className={`ptr-spinner${triggered || refreshing ? ' spin' : ''}`}>↻</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wrap">
|
||||||
|
<header>
|
||||||
|
<div className="eyebrow">Espace familial</div>
|
||||||
|
<h1>Budget <span>{activeBudget.nom === 'Commun' ? 'Commun' : activeBudget.nom}</span></h1>
|
||||||
|
<div className="account-wrap">
|
||||||
|
<button
|
||||||
|
className={`account-btn${(tab === 'compte' || tab === 'admin') ? ' active' : ''}`}
|
||||||
|
onClick={() => setAccountOpen(o => !o)}
|
||||||
|
title="Compte"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="8" r="4"/>
|
||||||
|
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{accountOpen && (
|
||||||
|
<>
|
||||||
|
<div className="account-backdrop" onClick={() => setAccountOpen(false)} />
|
||||||
|
<div className="account-dropdown">
|
||||||
|
<button onClick={() => { setTab('compte'); setAccountOpen(false) }}>
|
||||||
|
Compte
|
||||||
|
</button>
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<button onClick={() => { setTab('admin'); setAccountOpen(false) }}>
|
||||||
|
Admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="dropdown-logout" onClick={() => { setAccountOpen(false); logout() }}>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Budget switcher (multi-profil — étape 4) */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<BudgetSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab nav */}
|
||||||
|
<nav className="tabs-nav">
|
||||||
|
{visibleTabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`tab-btn${tab === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
<span className="tdot" style={{ background: t.dot }} />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{tab === 'budget' && (
|
||||||
|
<DashboardPage
|
||||||
|
expenses={expenses}
|
||||||
|
commonExpenses={commonExpenses}
|
||||||
|
categories={categories}
|
||||||
|
settings={settings}
|
||||||
|
onSettingsChange={setSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'depenses' && (
|
||||||
|
<ExpensesPage
|
||||||
|
expenses={expenses}
|
||||||
|
categories={categories}
|
||||||
|
onExpensesChange={setExpenses}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'pointage' && (
|
||||||
|
<PointagePage categories={categories} />
|
||||||
|
)}
|
||||||
|
{tab === 'simu' && (
|
||||||
|
<SimulateurPage
|
||||||
|
settings={settings}
|
||||||
|
expenses={expenses}
|
||||||
|
onExpensesChange={setExpenses}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'epargnes' && <EpargnesPage onToast={showToast} />}
|
||||||
|
{tab === 'admin' && user?.isAdmin && (
|
||||||
|
<AdminPage
|
||||||
|
categories={categories}
|
||||||
|
onCategoriesChange={setCategories}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'compte' && (
|
||||||
|
<SettingsPage onToast={showToast} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="wrap">Données hébergées sur votre réseau · Budget Commun</footer>
|
||||||
|
|
||||||
|
{toast && <Toast msg={toast.msg} type={toast.type} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root ──────────────────────────────────────────────────────
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<BudgetProvider>
|
||||||
|
<AppInner />
|
||||||
|
</BudgetProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return <LoginPage />
|
||||||
|
return <AppShell />
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Centralized API client
|
||||||
|
// Injects Authorization + X-Budget-Id headers on every request
|
||||||
|
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('budget-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBudgetId() {
|
||||||
|
return localStorage.getItem('budget-active-id') || '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(method, path, body) {
|
||||||
|
const token = getToken()
|
||||||
|
const headers = { 'Content-Type': 'application/json' }
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Budget-Id sera utilisé à l'étape 2 (middleware backend)
|
||||||
|
headers['X-Budget-Id'] = getActiveBudgetId()
|
||||||
|
|
||||||
|
const opts = { method, headers }
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||||
|
|
||||||
|
const res = await fetch(BASE + path, opts)
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Token expiré : vider la session
|
||||||
|
localStorage.removeItem('budget-token')
|
||||||
|
window.dispatchEvent(new Event('auth:expired'))
|
||||||
|
throw new Error('Session expirée')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error || 'Erreur serveur')
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (path) => apiFetch('GET', path),
|
||||||
|
post: (path, body) => apiFetch('POST', path, body),
|
||||||
|
put: (path, body) => apiFetch('PUT', path, body),
|
||||||
|
delete: (path) => apiFetch('DELETE', path),
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null) // { username, isAdmin }
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('budget-token')
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Écoute les expirations de token détectées par le client API
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => logout()
|
||||||
|
window.addEventListener('auth:expired', handler)
|
||||||
|
return () => window.removeEventListener('auth:expired', handler)
|
||||||
|
}, [logout])
|
||||||
|
|
||||||
|
// Vérification du token au démarrage
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('budget-token')
|
||||||
|
if (!token) { setLoading(false); return }
|
||||||
|
|
||||||
|
api.get('/auth/me')
|
||||||
|
.then(me => setUser(me))
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('budget-token')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username, password) => {
|
||||||
|
const data = await api.post('/auth/login', { username, password })
|
||||||
|
localStorage.setItem('budget-token', data.token)
|
||||||
|
setUser({ username: data.username, isAdmin: data.isAdmin })
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changePassword = useCallback((currentPassword, newPassword) =>
|
||||||
|
api.post('/auth/change-password', { currentPassword, newPassword })
|
||||||
|
, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, changePassword }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const BudgetContext = createContext(null)
|
||||||
|
|
||||||
|
export function BudgetProvider({ children }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const [activeBudgetId, setActiveBudgetId] = useState(() => {
|
||||||
|
return Number.parseInt(localStorage.getItem('budget-active-id') || '1', 10)
|
||||||
|
})
|
||||||
|
const [names, setNames] = useState({ nameA: '', nameB: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
api.get('/settings').then(s => {
|
||||||
|
if (s.nameA || s.nameB) setNames({ nameA: s.nameA || '', nameB: s.nameB || '' })
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [user?.username])
|
||||||
|
|
||||||
|
const BUDGETS = [
|
||||||
|
{ id: 1, nom: 'Commun', type: 'commun', emoji: '👥' },
|
||||||
|
{ id: 2, nom: names.nameA || 'user1 (perso)', type: 'perso', emoji: '👤', owner: 'user1' },
|
||||||
|
{ id: 3, nom: names.nameB || 'user2 (perso)', type: 'perso', emoji: '👤', owner: 'user2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Recalcule les budgets accessibles selon l'utilisateur connecté
|
||||||
|
const accessibleBudgets = user
|
||||||
|
? BUDGETS.filter(b => b.type === 'commun' || b.owner === user.username)
|
||||||
|
: [BUDGETS[0]]
|
||||||
|
|
||||||
|
const activeBudget = BUDGETS.find(b => b.id === activeBudgetId) || BUDGETS[0]
|
||||||
|
|
||||||
|
// Si le budget actif n'est plus accessible (changement d'user), reset vers Commun
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !accessibleBudgets.find(b => b.id === activeBudgetId)) {
|
||||||
|
switchBudget(1)
|
||||||
|
}
|
||||||
|
}, [user?.username])
|
||||||
|
|
||||||
|
function switchBudget(id) {
|
||||||
|
localStorage.setItem('budget-active-id', String(id))
|
||||||
|
setActiveBudgetId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BudgetContext.Provider value={{
|
||||||
|
activeBudget,
|
||||||
|
activeBudgetId,
|
||||||
|
accessibleBudgets,
|
||||||
|
switchBudget,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</BudgetContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBudget() {
|
||||||
|
return useContext(BudgetContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const ThemeContext = createContext(null)
|
||||||
|
|
||||||
|
function getSystemTheme() {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(pref) {
|
||||||
|
const resolved = pref === 'system' ? getSystemTheme() : pref
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [theme, setThemeState] = useState(
|
||||||
|
() => localStorage.getItem('theme') || 'system'
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme)
|
||||||
|
if (theme === 'system') {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const handler = () => applyTheme('system')
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
function setTheme(t) {
|
||||||
|
localStorage.setItem('theme', t)
|
||||||
|
setThemeState(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
const THRESHOLD = 72
|
||||||
|
|
||||||
|
export function usePullToRefresh(onRefresh) {
|
||||||
|
const [pullY, setPullY] = useState(0)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const startY = useRef(0)
|
||||||
|
const active = useRef(false)
|
||||||
|
const onRefreshRef = useRef(onRefresh)
|
||||||
|
useEffect(() => { onRefreshRef.current = onRefresh }, [onRefresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onTouchStart(e) {
|
||||||
|
if (window.scrollY === 0) {
|
||||||
|
startY.current = e.touches[0].clientY
|
||||||
|
active.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e) {
|
||||||
|
if (!active.current) return
|
||||||
|
const dy = e.touches[0].clientY - startY.current
|
||||||
|
if (dy > 0) setPullY(Math.min(dy * 0.5, THRESHOLD + 16))
|
||||||
|
else { active.current = false; setPullY(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTouchEnd() {
|
||||||
|
if (!active.current) return
|
||||||
|
active.current = false
|
||||||
|
if (pullY >= THRESHOLD) {
|
||||||
|
setRefreshing(true)
|
||||||
|
setPullY(0)
|
||||||
|
// Invalider le cache SW avant de recharger les données
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration()
|
||||||
|
if (reg) await reg.update()
|
||||||
|
}
|
||||||
|
await onRefreshRef.current()
|
||||||
|
setRefreshing(false)
|
||||||
|
} else {
|
||||||
|
setPullY(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||||
|
window.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||||
|
window.addEventListener('touchend', onTouchEnd)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('touchstart', onTouchStart)
|
||||||
|
window.removeEventListener('touchmove', onTouchMove)
|
||||||
|
window.removeEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
}, [pullY])
|
||||||
|
|
||||||
|
return { pullY, refreshing, triggered: pullY >= THRESHOLD }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const EMOJI_LIST = [
|
||||||
|
'🏠','🛒','🚗','📱','💊','🎉','👶','📦','✈️','🍕','🎬','📚',
|
||||||
|
'🏋️','🐶','🌿','⚡','🚿','💡','🎮','🛡️','🎵','🍺','🏖','🏔',
|
||||||
|
'💼','🎓','🐱','🌊','🍜','⛽','🔧','🏥',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminPage({ categories, onCategoriesChange, onToast }) {
|
||||||
|
const [newEmoji, setNewEmoji] = useState('📦')
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [editEmojis, setEditEmojis] = useState({}) // { catId: showPicker }
|
||||||
|
const [pending, setPending] = useState({}) // { catId: { label, emoji } }
|
||||||
|
|
||||||
|
async function addCategory() {
|
||||||
|
if (!newLabel.trim()) { onToast('Nom requis', 'err'); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const created = await api.post('/categories', { label: newLabel.trim(), emoji: newEmoji })
|
||||||
|
onCategoriesChange([...categories, created])
|
||||||
|
setNewLabel('')
|
||||||
|
setNewEmoji('📦')
|
||||||
|
onToast('Catégorie ajoutée ✓')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(cat) {
|
||||||
|
try {
|
||||||
|
const updated = await api.put('/categories/' + cat.id, { active: cat.active ? 0 : 1 })
|
||||||
|
onCategoriesChange(categories.map(c => c.id === cat.id ? updated : c))
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabel(cat) {
|
||||||
|
const p = pending[cat.id]
|
||||||
|
if (!p) return
|
||||||
|
try {
|
||||||
|
const updated = await api.put('/categories/' + cat.id, {
|
||||||
|
label: p.label ?? cat.label,
|
||||||
|
emoji: p.emoji ?? cat.emoji,
|
||||||
|
})
|
||||||
|
onCategoriesChange(categories.map(c => c.id === cat.id ? updated : c))
|
||||||
|
setPending(prev => { const n = { ...prev }; delete n[cat.id]; return n })
|
||||||
|
onToast('Catégorie mise à jour ✓')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(cat) {
|
||||||
|
try {
|
||||||
|
await api.delete('/categories/' + cat.id)
|
||||||
|
onCategoriesChange(categories.filter(c => c.id !== cat.id))
|
||||||
|
onToast('Catégorie supprimée')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPendingField(catId, field, value) {
|
||||||
|
setPending(prev => ({
|
||||||
|
...prev,
|
||||||
|
[catId]: { ...prev[catId], [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card admin-card full">
|
||||||
|
<div className="card-lbl">Administration</div>
|
||||||
|
<div className="card-name" style={{ color: '#a78bfa' }}>⚙ Gestion des catégories</div>
|
||||||
|
|
||||||
|
{/* Existing categories */}
|
||||||
|
<div className="admin-section">
|
||||||
|
<div className="admin-section-title">Catégories existantes</div>
|
||||||
|
<div className="cat-admin-list">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const p = pending[cat.id] || {}
|
||||||
|
const showPicker = editEmojis[cat.id]
|
||||||
|
return (
|
||||||
|
<div key={cat.id}>
|
||||||
|
<div className={`cat-admin-item${cat.active ? '' : ' inactive'}`}>
|
||||||
|
<button
|
||||||
|
className="cat-emoji-btn"
|
||||||
|
onClick={() => setEditEmojis(prev => ({ ...prev, [cat.id]: !prev[cat.id] }))}
|
||||||
|
>
|
||||||
|
{p.emoji ?? cat.emoji}
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>#{cat.sort}</span>
|
||||||
|
<input
|
||||||
|
className="cat-label-inp"
|
||||||
|
value={p.label ?? cat.label}
|
||||||
|
onChange={e => setPendingField(cat.id, 'label', e.target.value)}
|
||||||
|
onBlur={() => pending[cat.id] && saveLabel(cat)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && saveLabel(cat)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`toggle-btn${cat.active ? ' active' : ''}`}
|
||||||
|
onClick={() => toggleActive(cat)}
|
||||||
|
>
|
||||||
|
{cat.active ? 'Actif' : 'Inactif'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon" onClick={() => deleteCategory(cat)} title="Supprimer">✕</button>
|
||||||
|
</div>
|
||||||
|
{showPicker && (
|
||||||
|
<div className="emoji-picker">
|
||||||
|
{EMOJI_LIST.map(em => (
|
||||||
|
<span
|
||||||
|
key={em}
|
||||||
|
className="emoji-opt"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingField(cat.id, 'emoji', em)
|
||||||
|
setEditEmojis(prev => ({ ...prev, [cat.id]: false }))
|
||||||
|
// Auto-save emoji
|
||||||
|
setTimeout(() => saveLabel({ ...cat, ...pending[cat.id], emoji: em }), 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{em}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add category */}
|
||||||
|
<div className="admin-section" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="admin-section-title">Ajouter une catégorie</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 8, alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Emoji</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newEmoji}
|
||||||
|
onChange={e => setNewEmoji(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface2)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8, color: 'var(--text)', fontSize: 20,
|
||||||
|
padding: 9, width: 48, textAlign: 'center', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Nom de la catégorie</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ex: Vacances"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={e => setNewLabel(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addCategory()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-add" onClick={addCategory} disabled={saving}>
|
||||||
|
{saving ? '…' : '+ Ajouter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useBudget } from '../contexts/BudgetContext'
|
||||||
|
|
||||||
|
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
|
||||||
|
const fmtPct = n => n.toFixed(1) + '%'
|
||||||
|
|
||||||
|
const FREQS = {
|
||||||
|
mensuel: { label: 'mensuel', mult: 1 },
|
||||||
|
hebdo: { label: 'hebdo ×4', mult: 4.33 },
|
||||||
|
annuel: { label: 'annuel ÷12', mult: 1 / 12 },
|
||||||
|
ponctuel: { label: 'ponctuel', mult: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function mly(expense) {
|
||||||
|
return expense.freq === 'ponctuel' ? 0 : expense.amount * (FREQS[expense.freq]?.mult ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Couleurs par catégorie ────────────────────────────────────
|
||||||
|
const CAT_COLORS = {
|
||||||
|
logement: '#e8b86d',
|
||||||
|
alimentation: '#7eb8a4',
|
||||||
|
transport: '#f9a875',
|
||||||
|
abonnements: '#a78bfa',
|
||||||
|
sante: '#60a5fa',
|
||||||
|
loisirs: '#f472b6',
|
||||||
|
enfants: '#34d399',
|
||||||
|
autre: '#6b7280',
|
||||||
|
}
|
||||||
|
function catColor(slug) { return CAT_COLORS[slug] || '#9ca3af' }
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ id: 'mensuel', label: 'Mensuel', mult: 1 },
|
||||||
|
{ id: 'hebdo', label: 'Hebdo', mult: 1 / 4.33 },
|
||||||
|
{ id: 'annuel', label: 'Annuel', mult: 12 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Donut SVG ─────────────────────────────────────────────────
|
||||||
|
function DonutChart({ slices, size = 150 }) {
|
||||||
|
const total = slices.reduce((s, d) => s + d.value, 0)
|
||||||
|
const cx = size / 2, cy = size / 2
|
||||||
|
const outerR = size / 2 - 4
|
||||||
|
const innerR = outerR * 0.58
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} style={{ flexShrink: 0 }}>
|
||||||
|
<circle cx={cx} cy={cy} r={(outerR + innerR) / 2} fill="none"
|
||||||
|
stroke="var(--border)" strokeWidth={outerR - innerR} />
|
||||||
|
<text x={cx} y={cy} textAnchor="middle" dy=".35em"
|
||||||
|
fill="var(--muted)" fontSize="10" fontFamily="DM Mono,monospace">vide</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let angle = -Math.PI / 2
|
||||||
|
const paths = slices.map(d => {
|
||||||
|
const sweep = (d.value / total) * 2 * Math.PI
|
||||||
|
if (sweep < 0.005) return null
|
||||||
|
const start = angle, end = angle + sweep
|
||||||
|
angle = end
|
||||||
|
const large = sweep > Math.PI ? 1 : 0
|
||||||
|
const x1o = cx + outerR * Math.cos(start), y1o = cy + outerR * Math.sin(start)
|
||||||
|
const x2o = cx + outerR * Math.cos(end), y2o = cy + outerR * Math.sin(end)
|
||||||
|
const x1i = cx + innerR * Math.cos(end), y1i = cy + innerR * Math.sin(end)
|
||||||
|
const x2i = cx + innerR * Math.cos(start), y2i = cy + innerR * Math.sin(start)
|
||||||
|
return (
|
||||||
|
<path key={d.slug}
|
||||||
|
d={`M${x1o} ${y1o} A${outerR} ${outerR} 0 ${large} 1 ${x2o} ${y2o} L${x1i} ${y1i} A${innerR} ${innerR} 0 ${large} 0 ${x2i} ${y2i}Z`}
|
||||||
|
fill={d.color} />
|
||||||
|
)
|
||||||
|
}).filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} style={{ flexShrink: 0 }}>
|
||||||
|
{paths}
|
||||||
|
<text x={cx} y={cy - 8} textAnchor="middle"
|
||||||
|
fill="var(--muted)" fontSize="9" fontFamily="DM Mono,monospace">TOTAL</text>
|
||||||
|
<text x={cx} y={cy + 8} textAnchor="middle"
|
||||||
|
fill="var(--text)" fontSize="13" fontFamily="DM Mono,monospace" fontWeight="600">
|
||||||
|
{fmt(total)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graphique synthèse ────────────────────────────────────────
|
||||||
|
function BudgetCharts({ recurExpenses, categories, color }) {
|
||||||
|
const [period, setPeriod] = useState('mensuel')
|
||||||
|
|
||||||
|
const catMap = {}
|
||||||
|
categories.forEach(c => { catMap[c.slug] = c })
|
||||||
|
|
||||||
|
// Regrouper par catégorie
|
||||||
|
const bycat = {}
|
||||||
|
recurExpenses.forEach(e => {
|
||||||
|
const m = mly(e)
|
||||||
|
if (m > 0) bycat[e.cat] = (bycat[e.cat] || 0) + m
|
||||||
|
})
|
||||||
|
|
||||||
|
const slices = Object.entries(bycat)
|
||||||
|
.map(([slug, value]) => ({
|
||||||
|
slug, value,
|
||||||
|
color: catColor(slug),
|
||||||
|
label: catMap[slug]?.label || slug,
|
||||||
|
emoji: catMap[slug]?.emoji || '📦',
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
|
||||||
|
const total = slices.reduce((s, d) => s + d.value, 0)
|
||||||
|
const mult = PERIODS.find(p => p.id === period)?.mult || 1
|
||||||
|
const max = slices[0]?.value || 1
|
||||||
|
|
||||||
|
if (total === 0) return (
|
||||||
|
<div className="card full" style={{ borderTop: `3px solid ${color || 'var(--border)'}` }}>
|
||||||
|
<div className="card-lbl">Synthèse par catégorie</div>
|
||||||
|
<div style={{ color: 'var(--muted)', fontSize: 12, padding: '12px 0' }}>
|
||||||
|
Ajoutez des dépenses récurrentes pour voir la synthèse.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card full" style={{ borderTop: `3px solid ${color || 'var(--aa)'}` }}>
|
||||||
|
<div className="card-lbl">Synthèse par catégorie</div>
|
||||||
|
|
||||||
|
{/* Sélecteur période */}
|
||||||
|
<div className="period-tabs">
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button key={p.id}
|
||||||
|
className={`period-tab${period === p.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setPeriod(p.id)}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-layout">
|
||||||
|
{/* Donut */}
|
||||||
|
<div className="donut-wrap">
|
||||||
|
<DonutChart slices={slices} size={150} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Légende + barres */}
|
||||||
|
<div className="chart-legend">
|
||||||
|
{slices.map(s => (
|
||||||
|
<div key={s.slug} className="legend-row">
|
||||||
|
<span className="legend-emoji">{s.emoji}</span>
|
||||||
|
<span className="legend-label">{s.label}</span>
|
||||||
|
<span className="legend-val">{fmt(s.value * mult)}</span>
|
||||||
|
<div className="legend-bar-track">
|
||||||
|
<div className="legend-bar-fill"
|
||||||
|
style={{ width: `${s.value / max * 100}%`, background: s.color }} />
|
||||||
|
</div>
|
||||||
|
<span className="legend-pct">{fmtPct(s.value / total * 100)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Totaux période */}
|
||||||
|
<div className="chart-totals">
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<div key={p.id} className={`chart-total-item${period === p.id ? ' active' : ''}`}>
|
||||||
|
<span className="ct-label">{p.label}</span>
|
||||||
|
<span className="ct-val">{fmt(total * p.mult)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard principal ───────────────────────────────────────
|
||||||
|
export default function DashboardPage({ expenses, commonExpenses = [], categories = [], settings }) {
|
||||||
|
const { activeBudget } = useBudget()
|
||||||
|
|
||||||
|
const ia = parseFloat(settings.incomeA) || 0
|
||||||
|
const ib = parseFloat(settings.incomeB) || 0
|
||||||
|
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 63
|
||||||
|
|
||||||
|
// Calculs basés sur le budget commun (contributions et restes)
|
||||||
|
const commonRecur = commonExpenses.filter(e => e.freq !== 'ponctuel')
|
||||||
|
const recur = expenses.filter(e => e.freq !== 'ponctuel')
|
||||||
|
const total = commonRecur.reduce((s, e) => s + mly(e), 0)
|
||||||
|
const cA = total * pct / 100
|
||||||
|
const cB = total - cA
|
||||||
|
const rA = ia - cA
|
||||||
|
const rB = ib - cB
|
||||||
|
|
||||||
|
const alert = rA < 0 || rB < 0
|
||||||
|
? { type: 'warn', msg: `⚠ Budget dépasse le revenu de : ${[rA < 0 && settings.nameA, rB < 0 && settings.nameB].filter(Boolean).join(', ')}.` }
|
||||||
|
: total > 0 && Math.abs(rA / (ia || 1) * 100 - rB / (ib || 1) * 100) < 5
|
||||||
|
? { type: 'ok-al', msg: `✓ Répartition équitable (${fmtPct(rA / (ia || 1) * 100)} vs ${fmtPct(rB / (ib || 1) * 100)} restant).` }
|
||||||
|
: null
|
||||||
|
|
||||||
|
// ── Vue personnelle ──────────────────────────────────────────
|
||||||
|
if (activeBudget.type === 'perso') {
|
||||||
|
const isA = activeBudget.owner === 'user1'
|
||||||
|
const name = isA ? settings.nameA : settings.nameB
|
||||||
|
const color = isA ? 'var(--aa)' : 'var(--ab)'
|
||||||
|
const income = isA ? ia : ib
|
||||||
|
const contrib = isA ? cA : cB
|
||||||
|
const montant = isA ? rA : rB
|
||||||
|
|
||||||
|
const persoRecur = expenses.filter(e => e.freq !== 'ponctuel')
|
||||||
|
const persoTotal = persoRecur.reduce((s, e) => s + mly(e), 0)
|
||||||
|
const solde = montant - persoTotal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="grid">
|
||||||
|
{/* Budget perso */}
|
||||||
|
<div className="card full">
|
||||||
|
<div className="card-lbl">Budget perso — {name}</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Dépenses récurrentes / mois</span>
|
||||||
|
<span className="stat-val">{fmt(persoTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par an</span>
|
||||||
|
<span className="stat-val">{fmt(persoTotal * 12)}</span>
|
||||||
|
</div>
|
||||||
|
{persoRecur.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 8px' }}>
|
||||||
|
Ajoutez des dépenses dans l'onglet Dépenses (budget perso actif).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rep-grid" style={{ marginTop: 16 }}>
|
||||||
|
<div className="rep-card">
|
||||||
|
<div className="rep-head">Montant défini</div>
|
||||||
|
<div className="rep-amt" style={{ color }}>{fmt(montant)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="rep-card">
|
||||||
|
<div className="rep-head">Dépenses perso</div>
|
||||||
|
<div className="rep-amt">{fmt(persoTotal)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, padding: '10px 14px', borderRadius: 8, background: 'var(--surface2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Reste disponible</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 18, color: solde < 0 ? 'var(--danger)' : color }}>
|
||||||
|
{fmt(solde)} / mois
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{solde < 0 && (
|
||||||
|
<div className="alert warn" style={{ marginTop: 10 }}>
|
||||||
|
⚠ Les dépenses perso dépassent le montant défini de {fmt(Math.abs(solde))}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contribution budget commun */}
|
||||||
|
<div className="card cc">
|
||||||
|
<div className="card-lbl">Contribution budget commun</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par mois</span>
|
||||||
|
<span className="stat-val">{fmt(contrib)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par an</span>
|
||||||
|
<span className="stat-val">{fmt(contrib * 12)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Répartition</span>
|
||||||
|
<span className="stat-val">{isA ? pct : 100 - pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Montant défini */}
|
||||||
|
<div className="card" style={{ borderTop: `3px solid ${color}` }}>
|
||||||
|
<div className="card-lbl">Montant défini</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8 }}>
|
||||||
|
Reste du budget commun disponible pour le perso
|
||||||
|
</div>
|
||||||
|
<div className="budget-big" style={{ color: montant < 0 ? 'var(--danger)' : color }}>
|
||||||
|
{fmt(montant)}
|
||||||
|
</div>
|
||||||
|
<div className="budget-sub">Par mois · {fmt(montant * 12)} / an</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graphiques perso */}
|
||||||
|
<BudgetCharts recurExpenses={persoRecur} categories={categories} color={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vue commune ──────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="grid">
|
||||||
|
{/* Revenus A */}
|
||||||
|
<div className="card ca">
|
||||||
|
<div className="card-lbl">Revenus</div>
|
||||||
|
<div className="card-name">
|
||||||
|
<div className="dot" style={{ background: 'var(--aa)' }} />
|
||||||
|
{settings.nameA}
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Revenu mensuel net</span>
|
||||||
|
<span className="stat-val">{fmt(ia)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Annuel</span>
|
||||||
|
<span className="stat-val">{fmt(ia * 12)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Part des revenus</span>
|
||||||
|
<span className="stat-val">{fmtPct(ia + ib > 0 ? ia / (ia + ib) * 100 : 50)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenus B */}
|
||||||
|
<div className="card cb">
|
||||||
|
<div className="card-lbl">Revenus</div>
|
||||||
|
<div className="card-name">
|
||||||
|
<div className="dot" style={{ background: 'var(--ab)' }} />
|
||||||
|
{settings.nameB}
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Revenu mensuel net</span>
|
||||||
|
<span className="stat-val">{fmt(ib)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Annuel</span>
|
||||||
|
<span className="stat-val">{fmt(ib * 12)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Part des revenus</span>
|
||||||
|
<span className="stat-val">{fmtPct(ia + ib > 0 ? ib / (ia + ib) * 100 : 50)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget commun */}
|
||||||
|
<div className="card cc full">
|
||||||
|
<div className="card-lbl">Budget commun</div>
|
||||||
|
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Dépenses récurrentes / mois</span>
|
||||||
|
<span className="stat-val">{fmt(total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par an</span>
|
||||||
|
<span className="stat-val">{fmt(total * 12)}</span>
|
||||||
|
</div>
|
||||||
|
{recur.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 8px' }}>
|
||||||
|
Ajoutez des dépenses récurrentes dans l'onglet Dépenses.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rep-grid">
|
||||||
|
<div className="rep-card">
|
||||||
|
<div className="rep-head">
|
||||||
|
<div className="dot" style={{ background: 'var(--aa)' }} /> {settings.nameA}
|
||||||
|
</div>
|
||||||
|
<div className="rep-amt">{fmt(cA)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
|
||||||
|
<div className="rep-sub">{fmt(cA * 12)} / an</div>
|
||||||
|
</div>
|
||||||
|
<div className="rep-card">
|
||||||
|
<div className="rep-head">
|
||||||
|
<div className="dot" style={{ background: 'var(--ab)' }} /> {settings.nameB}
|
||||||
|
</div>
|
||||||
|
<div className="rep-amt">{fmt(cB)}<span style={{ fontSize: 12, color: 'var(--muted)' }}>/mois</span></div>
|
||||||
|
<div className="rep-sub">{fmt(cB * 12)} / an</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bar-wrap">
|
||||||
|
<div className="bar-lbl">
|
||||||
|
<span>{settings.nameA} : {pct}%</span>
|
||||||
|
<span>{settings.nameB} : {100 - pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bar-track">
|
||||||
|
<div className="bar-seg" style={{ width: pct + '%', background: 'var(--aa)' }} />
|
||||||
|
<div className="bar-seg" style={{ width: (100 - pct) + '%', background: 'var(--ab)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alert && <div className={`alert ${alert.type}`}>{alert.msg}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reste à vivre */}
|
||||||
|
<div className="card ca">
|
||||||
|
<div className="card-lbl">Reste à vivre</div>
|
||||||
|
<div className="card-name">
|
||||||
|
<div className="dot" style={{ background: 'var(--aa)' }} />
|
||||||
|
{settings.nameA}
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par mois</span>
|
||||||
|
<span className="stat-val" style={{ color: rA < 0 ? 'var(--danger)' : 'inherit' }}>{fmt(rA)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">% du revenu</span>
|
||||||
|
<span className="stat-val">{ia > 0 ? fmtPct(rA / ia * 100) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card cb">
|
||||||
|
<div className="card-lbl">Reste à vivre</div>
|
||||||
|
<div className="card-name">
|
||||||
|
<div className="dot" style={{ background: 'var(--ab)' }} />
|
||||||
|
{settings.nameB}
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">Par mois</span>
|
||||||
|
<span className="stat-val" style={{ color: rB < 0 ? 'var(--danger)' : 'inherit' }}>{fmt(rB)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span className="stat-lbl">% du revenu</span>
|
||||||
|
<span className="stat-val">{ib > 0 ? fmtPct(rB / ib * 100) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graphiques commun */}
|
||||||
|
<BudgetCharts recurExpenses={commonRecur} categories={categories} color="var(--aa)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
|
||||||
|
|
||||||
|
export default function EpargnesPage({ onToast }) {
|
||||||
|
const [savings, setSavings] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showClosed, setShowClosed] = useState(false)
|
||||||
|
// form state for new saving
|
||||||
|
const [emoji, setEmoji] = useState('💰')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [monthly, setMonthly] = useState('')
|
||||||
|
const [target, setTarget] = useState('')
|
||||||
|
// transaction state: { savingId, type: 'credit'|'debit' }
|
||||||
|
const [txForm, setTxForm] = useState(null)
|
||||||
|
const [txAmount, setTxAmount] = useState('')
|
||||||
|
const [txNote, setTxNote] = useState('')
|
||||||
|
const [closeConfirm, setCloseConfirm] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.get('/savings')
|
||||||
|
setSavings(data)
|
||||||
|
} catch { onToast('Erreur chargement épargnes', 'err') }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [onToast])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function handleCreate(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) { onToast('Nom requis', 'err'); return }
|
||||||
|
try {
|
||||||
|
const created = await api.post('/savings', {
|
||||||
|
name: name.trim(), emoji,
|
||||||
|
monthly_amount: monthly || 0,
|
||||||
|
target: target || null,
|
||||||
|
})
|
||||||
|
setSavings(prev => [created, ...prev])
|
||||||
|
setName(''); setEmoji('💰'); setMonthly(''); setTarget('')
|
||||||
|
onToast('Épargne créée ✓')
|
||||||
|
} catch { onToast('Erreur création', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransaction(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!txAmount || isNaN(parseFloat(txAmount))) { onToast('Montant invalide', 'err'); return }
|
||||||
|
const amount = txForm.type === 'debit' ? -Math.abs(parseFloat(txAmount)) : Math.abs(parseFloat(txAmount))
|
||||||
|
try {
|
||||||
|
const updated = await api.post(`/savings/${txForm.savingId}/transaction`, { amount, note: txNote })
|
||||||
|
setSavings(prev => prev.map(s => s.id === updated.id ? updated : s))
|
||||||
|
setTxForm(null); setTxAmount(''); setTxNote('')
|
||||||
|
onToast(txForm.type === 'credit' ? 'Montant ajouté ✓' : 'Retrait effectué ✓')
|
||||||
|
} catch { onToast('Erreur transaction', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose(id) {
|
||||||
|
try {
|
||||||
|
const updated = await api.post(`/savings/${id}/close`, {})
|
||||||
|
setSavings(prev => prev.map(s => s.id === updated.id ? updated : s))
|
||||||
|
setCloseConfirm(null)
|
||||||
|
onToast('Épargne clôturée')
|
||||||
|
} catch { onToast('Erreur clôture', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/savings/${id}`)
|
||||||
|
setSavings(prev => prev.filter(s => s.id !== id))
|
||||||
|
onToast('Épargne supprimée')
|
||||||
|
} catch { onToast('Erreur suppression', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = savings.filter(s => !s.closed)
|
||||||
|
const closed = savings.filter(s => s.closed)
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
{/* Create form */}
|
||||||
|
<div className="card full" style={{ borderTop: '3px solid var(--ab)' }}>
|
||||||
|
<div className="card-lbl">Nouvelle épargne</div>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="simu-form-grid">
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Emoji</label>
|
||||||
|
<input type="text" value={emoji} onChange={e => setEmoji(e.target.value)} maxLength={2}
|
||||||
|
style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:8,
|
||||||
|
color:'var(--text)', fontSize:20, padding:9, width:48, textAlign:'center', outline:'none' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Nom</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="text" placeholder="ex: Vacances" value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Versement mensuel</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="0" min="0" value={monthly} onChange={e => setMonthly(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Objectif (optionnel)</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="—" min="0" value={target} onChange={e => setTarget(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-add" style={{ marginTop:14, width:'100%' }} type="submit">+ Créer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active savings */}
|
||||||
|
{active.length === 0 && (
|
||||||
|
<div style={{ textAlign:'center', color:'var(--muted)', fontSize:13, padding:'24px 0' }}>
|
||||||
|
Aucune épargne active. Créez votre première ci-dessus.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active.map(s => {
|
||||||
|
const progress = s.target ? Math.min(100, s.balance / s.target * 100) : null
|
||||||
|
const isTx = txForm?.savingId === s.id
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="card full">
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize:22, lineHeight:1 }}>{s.emoji}</div>
|
||||||
|
<div style={{ fontWeight:600, fontSize:15, marginTop:4 }}>{s.name}</div>
|
||||||
|
{s.monthly_amount > 0 && (
|
||||||
|
<div style={{ fontSize:11, color:'var(--muted)' }}>{fmt(s.monthly_amount)}/mois versé</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign:'right' }}>
|
||||||
|
<div style={{ fontSize:22, fontWeight:700, color:'var(--ab)' }}>{fmt(s.balance)}</div>
|
||||||
|
{s.target && <div style={{ fontSize:11, color:'var(--muted)' }}>sur {fmt(s.target)}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progress !== null && (
|
||||||
|
<div className="simu-progress-track" style={{ marginBottom:10 }}>
|
||||||
|
<div className="simu-progress-fill" style={{ width: progress + '%', background:'var(--ab)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction inline form */}
|
||||||
|
{isTx && (
|
||||||
|
<form onSubmit={handleTransaction} style={{ background:'var(--surface2)', borderRadius:8, padding:12, marginBottom:10 }}>
|
||||||
|
<div style={{ fontSize:11, color:'var(--muted)', marginBottom:8 }}>
|
||||||
|
{txForm.type === 'credit' ? '+ Ajouter des fonds' : '− Retirer des fonds'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:8, marginBottom:8 }}>
|
||||||
|
<div className="inp-row" style={{ flex:1 }}>
|
||||||
|
<input type="number" placeholder="Montant" min="0" step="0.01"
|
||||||
|
value={txAmount} onChange={e => setTxAmount(e.target.value)} autoFocus />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
<div className="inp-row" style={{ flex:2 }}>
|
||||||
|
<input type="text" placeholder="Note (optionnel)"
|
||||||
|
value={txNote} onChange={e => setTxNote(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:8 }}>
|
||||||
|
<button className="btn-add" type="submit" style={{ flex:1 }}>Confirmer</button>
|
||||||
|
<button className="btn-cancel" type="button" onClick={() => setTxForm(null)}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{closeConfirm === s.id && (
|
||||||
|
<div style={{ background:'var(--surface2)', borderRadius:8, padding:12, marginBottom:10, fontSize:12, color:'var(--muted)' }}>
|
||||||
|
Clôturer supprimera le versement mensuel lié. Confirmer ?
|
||||||
|
<div style={{ display:'flex', gap:8, marginTop:8 }}>
|
||||||
|
<button className="btn-confirm" onClick={() => handleClose(s.id)}>Oui, clôturer</button>
|
||||||
|
<button className="btn-cancel" onClick={() => setCloseConfirm(null)}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
|
||||||
|
<button className="btn-sm" style={{ color:'var(--ab)', borderColor:'var(--ab)' }}
|
||||||
|
onClick={() => { setTxForm({ savingId: s.id, type:'credit' }); setTxAmount(''); setTxNote('') }}>
|
||||||
|
+ Ajouter
|
||||||
|
</button>
|
||||||
|
<button className="btn-sm"
|
||||||
|
onClick={() => { setTxForm({ savingId: s.id, type:'debit' }); setTxAmount(''); setTxNote('') }}>
|
||||||
|
− Retirer
|
||||||
|
</button>
|
||||||
|
<button className="btn-sm" style={{ marginLeft:'auto', color:'var(--muted)' }}
|
||||||
|
onClick={() => setCloseConfirm(s.id)}>
|
||||||
|
Clôturer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Closed savings */}
|
||||||
|
{closed.length > 0 && (
|
||||||
|
<div className="card full">
|
||||||
|
<button className="card-lbl" style={{ background:'none', border:'none', cursor:'pointer', color:'var(--muted)', textAlign:'left', width:'100%', padding:0 }}
|
||||||
|
onClick={() => setShowClosed(v => !v)}>
|
||||||
|
Épargnes clôturées ({closed.length}) {showClosed ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{showClosed && closed.map(s => (
|
||||||
|
<div key={s.id} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0', borderTop:'1px solid var(--border)', opacity:0.6 }}>
|
||||||
|
<div style={{ display:'flex', gap:10, alignItems:'center' }}>
|
||||||
|
<span style={{ fontSize:18 }}>{s.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize:13, fontWeight:600 }}>{s.name}</div>
|
||||||
|
<div style={{ fontSize:11, color:'var(--muted)' }}>Solde final : {fmt(s.balance)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={() => handleDelete(s.id)} title="Supprimer">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
|
||||||
|
|
||||||
|
const FREQS = {
|
||||||
|
mensuel: { label: 'mensuel', mult: 1 },
|
||||||
|
hebdo: { label: 'hebdo ×4', mult: 4.33 },
|
||||||
|
annuel: { label: 'annuel ÷12', mult: 1 / 12 },
|
||||||
|
ponctuel: { label: 'ponctuel', mult: 0 },
|
||||||
|
echelonne: { label: 'en x fois', mult: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
|
||||||
|
|
||||||
|
function mly(e) {
|
||||||
|
if (e.freq === 'ponctuel') return 0
|
||||||
|
if (e.freq === 'echelonne') return (e.installments_paid >= e.installments) ? 0 : e.amount
|
||||||
|
return e.amount * (FREQS[e.freq]?.mult ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMonth(m) {
|
||||||
|
if (!m) return ''
|
||||||
|
const [y, mo] = m.split('-')
|
||||||
|
const labels = { '01':'Jan','02':'Fév','03':'Mar','04':'Avr','05':'Mai','06':'Jun','07':'Jul','08':'Aoû','09':'Sep','10':'Oct','11':'Nov','12':'Déc' }
|
||||||
|
return (labels[mo] || mo) + ' ' + y
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthOptions() {
|
||||||
|
const now = new Date()
|
||||||
|
const opts = []
|
||||||
|
for (let i = -3; i < 12; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + i, 1)
|
||||||
|
const val = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
opts.push({ value: val, label: MONTHS_FR[d.getMonth()] + ' ' + d.getFullYear() })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Modal ────────────────────────────────────────────────
|
||||||
|
function EditModal({ expense, categories, onSave, onClose }) {
|
||||||
|
const isEch = expense.freq === 'echelonne'
|
||||||
|
const [name, setName] = useState(expense.name)
|
||||||
|
const [total, setTotal] = useState(isEch ? Math.round(expense.amount * expense.installments) : expense.amount)
|
||||||
|
const [amount, setAmount] = useState(expense.amount)
|
||||||
|
const [installments, setInstallments] = useState(expense.installments || 1)
|
||||||
|
const [installments_paid, setInstallmentsPaid] = useState(expense.installments_paid || 0)
|
||||||
|
const [cat, setCat] = useState(expense.cat)
|
||||||
|
const [freq, setFreq] = useState(expense.freq)
|
||||||
|
const [month, setMonth] = useState(expense.month || monthOptions()[3]?.value)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const monthlyPreview = freq === 'echelonne' && installments > 0
|
||||||
|
? parseFloat(total) / parseInt(installments)
|
||||||
|
: null
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError('')
|
||||||
|
if (!name.trim()) { setError('Libellé requis.'); return }
|
||||||
|
const finalAmount = freq === 'echelonne'
|
||||||
|
? (parseFloat(total) / parseInt(installments || 1))
|
||||||
|
: parseFloat(amount)
|
||||||
|
if (!finalAmount || finalAmount <= 0) { setError('Montant invalide.'); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(), amount: finalAmount, cat, freq,
|
||||||
|
month: freq === 'ponctuel' ? month : null,
|
||||||
|
}
|
||||||
|
if (freq === 'echelonne') {
|
||||||
|
payload.installments = parseInt(installments)
|
||||||
|
payload.installments_paid = parseInt(installments_paid)
|
||||||
|
}
|
||||||
|
const updated = await api.put('/expenses/' + expense.id, payload)
|
||||||
|
onSave(updated)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal-card">
|
||||||
|
<div className="modal-title">Modifier la dépense</div>
|
||||||
|
{error && <div className="modal-err">{error}</div>}
|
||||||
|
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Libellé</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSave()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Catégorie</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={cat} onChange={e => setCat(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{categories.filter(c => c.active).map(c => (
|
||||||
|
<option key={c.slug} value={c.slug}>{c.emoji} {c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Fréquence</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={freq} onChange={e => setFreq(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{Object.entries(FREQS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label.charAt(0).toUpperCase() + v.label.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{freq === 'echelonne' ? (
|
||||||
|
<>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Montant total</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" min="1" value={total} onChange={e => setTotal(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Nombre de mensualités</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" min="2" max="120" value={installments}
|
||||||
|
onChange={e => setInstallments(e.target.value)} />
|
||||||
|
<span className="inp-sfx">fois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Mensualités déjà payées</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" min="0" max={installments} value={installments_paid}
|
||||||
|
onChange={e => setInstallmentsPaid(e.target.value)} />
|
||||||
|
<span className="inp-sfx">/ {installments}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{monthlyPreview > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 10 }}>
|
||||||
|
= {fmt(monthlyPreview)} / mois
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Montant</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" min="0" value={amount} onChange={e => setAmount(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSave()} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{freq === 'ponctuel' && (
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Mois</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-btns">
|
||||||
|
<button className="btn-cancel" onClick={onClose}>Annuler</button>
|
||||||
|
<button className="btn-confirm" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expense item ──────────────────────────────────────────────
|
||||||
|
function ExpItem({ expense, categories, onEdit, onDelete, onPayInstallment }) {
|
||||||
|
const cat = categories.find(c => c.slug === expense.cat) || { label: 'Autre', emoji: '📦' }
|
||||||
|
const isPonctuel = expense.freq === 'ponctuel'
|
||||||
|
const isEchelonne = expense.freq === 'echelonne'
|
||||||
|
const isDone = isEchelonne && expense.installments_paid >= expense.installments
|
||||||
|
const progress = isEchelonne ? Math.min(100, expense.installments_paid / expense.installments * 100) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`exp-item${isDone ? ' exp-done' : ''}`}>
|
||||||
|
<div className="exp-icon" style={{ background: isDone ? 'rgba(130,130,130,.1)' : 'rgba(232,184,109,.12)' }}>{cat.emoji}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="exp-name">{expense.name}</div>
|
||||||
|
<div className="exp-meta">{cat.label}</div>
|
||||||
|
{isEchelonne && (
|
||||||
|
<div className="ech-progress-track">
|
||||||
|
<div className="ech-progress-fill" style={{ width: progress + '%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exp-amt">{fmt(expense.amount)}{isEchelonne ? '/mois' : ''}</div>
|
||||||
|
{isEchelonne ? (
|
||||||
|
<span className={`exp-badge${isDone ? ' done' : ''}`}>
|
||||||
|
✂️ {expense.installments_paid}/{expense.installments}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`exp-badge${isPonctuel ? ' ponctuel' : ''}`}>
|
||||||
|
{isPonctuel ? `🗓 ${fmtMonth(expense.month)}` : FREQS[expense.freq]?.label || expense.freq}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{isEchelonne && !isDone && (
|
||||||
|
<button className="btn-del" onClick={() => onPayInstallment(expense.id)} title="Mensualité payée" style={{ color: 'var(--aa)' }}>✓</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-del" onClick={() => onEdit(expense)} title="Modifier">✏️</button>
|
||||||
|
<button className="btn-del" onClick={() => onDelete(expense.id)} title="Supprimer">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────
|
||||||
|
export default function ExpensesPage({ expenses, categories, onExpensesChange, onToast }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [totalAmount, setTotalAmount] = useState('')
|
||||||
|
const [installments,setInstallments]= useState('3')
|
||||||
|
const [cat, setCat] = useState('')
|
||||||
|
const [freq, setFreq] = useState('mensuel')
|
||||||
|
const [month, setMonth] = useState(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
})
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [editExp, setEditExp] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const activeCat = cat || (categories.find(c => c.active)?.slug ?? 'autre')
|
||||||
|
|
||||||
|
const monthlyPreview = freq === 'echelonne' && totalAmount > 0 && installments > 0
|
||||||
|
? parseFloat(totalAmount) / parseInt(installments)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const isDoneEch = e => e.freq === 'echelonne' && e.installments_paid >= e.installments
|
||||||
|
const recur = expenses.filter(e => e.freq !== 'ponctuel' && !isDoneEch(e))
|
||||||
|
const ponctuel = expenses.filter(e => e.freq === 'ponctuel')
|
||||||
|
const termines = expenses.filter(isDoneEch)
|
||||||
|
const total = recur.reduce((s, e) => s + mly(e), 0)
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const n = name.trim()
|
||||||
|
const a = freq === 'echelonne'
|
||||||
|
? parseFloat(totalAmount) / parseInt(installments || 1)
|
||||||
|
: parseFloat(amount)
|
||||||
|
if (!n || !a || a <= 0) { onToast('Libellé et montant requis', 'err'); return }
|
||||||
|
if (freq === 'echelonne' && parseInt(installments) < 2) { onToast('Minimum 2 mensualités', 'err'); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: n, amount: a, cat: activeCat, freq,
|
||||||
|
month: freq === 'ponctuel' ? month : null,
|
||||||
|
}
|
||||||
|
if (freq === 'echelonne') payload.installments = parseInt(installments)
|
||||||
|
const created = await api.post('/expenses', payload)
|
||||||
|
onExpensesChange([...expenses, created])
|
||||||
|
setName(''); setAmount(''); setTotalAmount(''); setInstallments('3')
|
||||||
|
onToast('Dépense ajoutée ✓')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await api.delete('/expenses/' + id)
|
||||||
|
onExpensesChange(expenses.filter(e => e.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePayInstallment(id) {
|
||||||
|
try {
|
||||||
|
const updated = await api.post('/expenses/' + id + '/pay-installment', {})
|
||||||
|
onExpensesChange(expenses.map(e => e.id === updated.id ? updated : e))
|
||||||
|
const e = expenses.find(e => e.id === id)
|
||||||
|
if (e && e.installments_paid + 1 >= e.installments) {
|
||||||
|
onToast('Remboursement terminé ! 🎉')
|
||||||
|
} else {
|
||||||
|
onToast('Mensualité enregistrée ✓')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaved(updated) {
|
||||||
|
onExpensesChange(expenses.map(e => e.id === updated.id ? updated : e))
|
||||||
|
setEditExp(null)
|
||||||
|
onToast('Dépense modifiée ✓')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPonctuel = filter === '__ponctuel__'
|
||||||
|
const showTermines = filter === '__termines__'
|
||||||
|
const filtered = showPonctuel ? ponctuel
|
||||||
|
: showTermines ? termines
|
||||||
|
: filter === 'all' ? recur
|
||||||
|
: recur.filter(e => e.cat === filter)
|
||||||
|
|
||||||
|
const catTotals = Object.entries(
|
||||||
|
recur.reduce((acc, e) => { acc[e.cat] = (acc[e.cat] || 0) + mly(e); return acc }, {})
|
||||||
|
).sort((a, b) => b[1] - a[1])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card cc full">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="dep-sum">
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val">{fmt(total)}</div>
|
||||||
|
<div className="sum-lbl">/ mois</div>
|
||||||
|
</div>
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val">{fmt(total * 12)}</div>
|
||||||
|
<div className="sum-lbl">/ an</div>
|
||||||
|
</div>
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val">{expenses.length}</div>
|
||||||
|
<div className="sum-lbl">dépenses</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
<div className="add-form">
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Libellé</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="text" placeholder="Loyer, EDF…" value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdd()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Catégorie</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={activeCat} onChange={e => setCat(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{categories.filter(c => c.active).map(c => (
|
||||||
|
<option key={c.slug} value={c.slug}>{c.emoji} {c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Fréquence</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={freq} onChange={e => setFreq(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{Object.entries(FREQS).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label.charAt(0).toUpperCase() + v.label.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{freq === 'echelonne' ? (
|
||||||
|
<>
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Montant total</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="1200" min="1" value={totalAmount}
|
||||||
|
onChange={e => setTotalAmount(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Nombre de mensualités</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="12" min="2" max="120" value={installments}
|
||||||
|
onChange={e => setInstallments(e.target.value)} />
|
||||||
|
<span className="inp-sfx">fois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{monthlyPreview > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: '#f9a875', gridColumn: '1 / -1' }}>
|
||||||
|
= {fmt(monthlyPreview)} / mois
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Montant</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="0" min="0" value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdd()} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{freq === 'ponctuel' && (
|
||||||
|
<div className="inp-grp" style={{ margin: 0 }}>
|
||||||
|
<label className="inp-lbl">Mois</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn-add" onClick={handleAdd} disabled={saving} style={{ alignSelf: 'flex-end' }}>
|
||||||
|
{saving ? '…' : '+ Ajouter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filters */}
|
||||||
|
<div className="cat-filters">
|
||||||
|
<button className={`cat-pill${filter === 'all' ? ' active' : ''}`} onClick={() => setFilter('all')}>Toutes</button>
|
||||||
|
<button className={`cat-pill${filter === '__ponctuel__' ? ' active' : ''}`} onClick={() => setFilter('__ponctuel__')}>🗓 Ponctuelles</button>
|
||||||
|
{termines.length > 0 && (
|
||||||
|
<button className={`cat-pill${filter === '__termines__' ? ' active' : ''}`} onClick={() => setFilter('__termines__')}>✂️ Soldés</button>
|
||||||
|
)}
|
||||||
|
{categories.filter(c => c.active).map(c => (
|
||||||
|
<button key={c.slug} className={`cat-pill${filter === c.slug ? ' active' : ''}`} onClick={() => setFilter(c.slug)}>
|
||||||
|
{c.emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{showPonctuel ? (
|
||||||
|
<>
|
||||||
|
<div className="section-title">
|
||||||
|
Dépenses ponctuelles <span className="section-badge">{ponctuel.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="exp-list">
|
||||||
|
{ponctuel.length === 0
|
||||||
|
? <div className="exp-empty">Aucune dépense ponctuelle.</div>
|
||||||
|
: ponctuel.map(e => (
|
||||||
|
<ExpItem key={e.id} expense={e} categories={categories}
|
||||||
|
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : showTermines ? (
|
||||||
|
<>
|
||||||
|
<div className="section-title">
|
||||||
|
Remboursements soldés <span className="section-badge">{termines.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="exp-list">
|
||||||
|
{termines.map(e => (
|
||||||
|
<ExpItem key={e.id} expense={e} categories={categories}
|
||||||
|
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="exp-list">
|
||||||
|
{filtered.length === 0
|
||||||
|
? <div className="exp-empty">
|
||||||
|
{recur.length === 0
|
||||||
|
? 'Aucune dépense pour l\'instant.\nAjoutez votre première dépense ci-dessus.'
|
||||||
|
: 'Aucune dépense dans cette catégorie.'}
|
||||||
|
</div>
|
||||||
|
: filtered.map(e => (
|
||||||
|
<ExpItem key={e.id} expense={e} categories={categories}
|
||||||
|
onEdit={setEditExp} onDelete={handleDelete} onPayInstallment={handlePayInstallment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catTotals.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontSize: 10, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)', marginBottom: 12 }}>
|
||||||
|
Répartition par catégorie (récurrentes)
|
||||||
|
</div>
|
||||||
|
{catTotals.map(([slug, amount]) => {
|
||||||
|
const c = categories.find(c => c.slug === slug) || { label: 'Autre', emoji: '📦' }
|
||||||
|
const pct = total > 0 ? amount / total * 100 : 0
|
||||||
|
return (
|
||||||
|
<div className="cat-tot-row" key={slug}>
|
||||||
|
<span style={{ fontSize: 12 }}>{c.emoji} {c.label}</span>
|
||||||
|
<div className="cat-bar-wrap">
|
||||||
|
<div className="cat-bar-fill" style={{ width: pct + '%', background: 'var(--aa)' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--muted)' }}>{fmt(amount)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editExp && (
|
||||||
|
<EditModal
|
||||||
|
expense={editExp}
|
||||||
|
categories={categories}
|
||||||
|
onSave={handleSaved}
|
||||||
|
onClose={() => setEditExp(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (!username || !password) { setError('Remplissez tous les champs.'); return }
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Identifiants incorrects')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||||
|
<div className="eyebrow">Espace privé</div>
|
||||||
|
<h1>Budget <span>Commun</span></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="login-err">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Identifiant</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="user1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Mot de passe</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-login" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Connexion…' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
|
||||||
|
|
||||||
|
const MONTH_LABELS = {
|
||||||
|
'01':'Janvier','02':'Février','03':'Mars','04':'Avril','05':'Mai','06':'Juin',
|
||||||
|
'07':'Juillet','08':'Août','09':'Septembre','10':'Octobre','11':'Novembre','12':'Décembre',
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMonth(m) {
|
||||||
|
if (!m) return ''
|
||||||
|
const [y, mo] = m.split('-')
|
||||||
|
return (MONTH_LABELS[mo] || mo) + ' ' + y
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(m, delta) {
|
||||||
|
const [y, mo] = m.split('-').map(Number)
|
||||||
|
const d = new Date(y, mo - 1 + delta, 1)
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBillingMonth() {
|
||||||
|
const d = new Date()
|
||||||
|
if (d.getDate() < 26) {
|
||||||
|
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
||||||
|
return prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0')
|
||||||
|
}
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PointagePage({ categories }) {
|
||||||
|
const [month, setMonth] = useState(currentBillingMonth)
|
||||||
|
const [rows, setRows] = useState([])
|
||||||
|
const [unpointedPrev,setUnpointedPrev]= useState([])
|
||||||
|
const [prevMonth, setPrevMonth] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const currentBilling = currentBillingMonth()
|
||||||
|
|
||||||
|
const load = useCallback(async (m) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.get('/pointage?month=' + m)
|
||||||
|
setRows(data.rows)
|
||||||
|
setUnpointedPrev(data.unpointedPrev)
|
||||||
|
setPrevMonth(data.prevMonth)
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load(month) }, [month, load])
|
||||||
|
|
||||||
|
async function togglePointed(row) {
|
||||||
|
const newVal = !row.pointed
|
||||||
|
try {
|
||||||
|
const updated = await api.put('/pointage/' + row.id, { pointed: newVal })
|
||||||
|
setRows(rs => rs.map(r => r.id === row.id ? { ...r, ...updated, cat: r.cat, freq: r.freq } : r))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pointAll() {
|
||||||
|
const unpointed = rows.filter(r => !r.pointed)
|
||||||
|
await Promise.all(unpointed.map(r => togglePointed(r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointed = rows.filter(r => r.pointed)
|
||||||
|
const unpointed = rows.filter(r => !r.pointed)
|
||||||
|
const totalPointed = pointed.reduce((s, r) => s + r.amount, 0)
|
||||||
|
const progress = rows.length > 0 ? (pointed.length / rows.length) * 100 : 0
|
||||||
|
|
||||||
|
function getCat(row) {
|
||||||
|
return categories.find(c => c.slug === row.cat) || { emoji: '📦', label: 'Autre' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PointageItem = ({ row, fromPrev }) => {
|
||||||
|
const cat = getCat(row)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ptg-item${row.pointed ? ' pointed' : ''}`}
|
||||||
|
onClick={() => togglePointed(row)}
|
||||||
|
>
|
||||||
|
<div className="ptg-check">{row.pointed ? '✓' : ''}</div>
|
||||||
|
<div>
|
||||||
|
<div className="ptg-name">{row.name}</div>
|
||||||
|
<div className="ptg-meta">{cat.label}{fromPrev ? ` · ${fmtMonth(row.month)}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="ptg-amt">{fmt(row.amount)}</div>
|
||||||
|
{row.freq && (
|
||||||
|
<span className="ptg-badge">{row.freq}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card full">
|
||||||
|
<div className="card-lbl">Pointage mensuel</div>
|
||||||
|
|
||||||
|
{/* Navigation mois */}
|
||||||
|
<div className="ptg-month-nav">
|
||||||
|
<button className="ptg-nav-btn" onClick={() => setMonth(m => addMonths(m, -1))}>‹</button>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="ptg-month-label">{fmtMonth(month)}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||||
|
{month === currentBilling ? 'Mois en cours' : 'Mois passé'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ptg-nav-btn"
|
||||||
|
onClick={() => setMonth(m => addMonths(m, 1))}
|
||||||
|
disabled={month >= currentBilling}
|
||||||
|
>›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
<div className="ptg-progress">
|
||||||
|
<div className="ptg-progress-fill" style={{ width: progress + '%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Résumé */}
|
||||||
|
<div className="ptg-summary">
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val" style={{ color: 'var(--ab)' }}>{pointed.length}</div>
|
||||||
|
<div className="sum-lbl">Pointées</div>
|
||||||
|
</div>
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val" style={{ color: 'var(--danger)' }}>{unpointed.length}</div>
|
||||||
|
<div className="sum-lbl">Restantes</div>
|
||||||
|
</div>
|
||||||
|
<div className="sum-box">
|
||||||
|
<div className="sum-val">{fmt(totalPointed)}</div>
|
||||||
|
<div className="sum-lbl">Total pointé</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alertes mois précédent */}
|
||||||
|
{unpointedPrev.length > 0 && (
|
||||||
|
<div className="alert warn">
|
||||||
|
⚠ {unpointedPrev.length} dépense{unpointedPrev.length > 1 ? 's' : ''} non pointée{unpointedPrev.length > 1 ? 's' : ''} en {fmtMonth(prevMonth)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>
|
||||||
|
<div className="spinner" style={{ margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* À pointer */}
|
||||||
|
{unpointed.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="ptg-section-title">
|
||||||
|
<span>À pointer ({unpointed.length})</span>
|
||||||
|
<button className="btn-sm" onClick={pointAll}>✓ Tout pointer</button>
|
||||||
|
</div>
|
||||||
|
{unpointed.map(r => <PointageItem key={r.id} row={r} />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Déjà pointées */}
|
||||||
|
{pointed.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="ptg-section-title">
|
||||||
|
<span>Pointées ({pointed.length})</span>
|
||||||
|
</div>
|
||||||
|
{pointed.map(r => <PointageItem key={r.id} row={r} />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="exp-empty">
|
||||||
|
Aucune dépense récurrente pour ce mois.<br />
|
||||||
|
Ajoutez des dépenses dans l'onglet Dépenses.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mois précédent non pointé */}
|
||||||
|
{unpointedPrev.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="ptg-section-title" style={{ color: 'var(--danger)' }}>
|
||||||
|
Non pointées — {fmtMonth(prevMonth)}
|
||||||
|
</div>
|
||||||
|
{unpointedPrev.map(r => <PointageItem key={r.id} row={r} fromPrev />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
export default function SettingsPage({ onToast }) {
|
||||||
|
const { user, changePassword } = useAuth()
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [newPwd, setNewPwd] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const isUserA = user?.username === 'user1'
|
||||||
|
const [prénom, setPrénom] = useState('')
|
||||||
|
const [revenu, setRevenu] = useState('')
|
||||||
|
const [profilSaved, setProfilSaved] = useState(false)
|
||||||
|
const [profilError, setProfilError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/settings').then(s => {
|
||||||
|
setPrénom((isUserA ? s.nameA : s.nameB) || '')
|
||||||
|
setRevenu((isUserA ? s.incomeA : s.incomeB) || '')
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [user?.username])
|
||||||
|
|
||||||
|
async function handleSaveProfil(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setProfilError('')
|
||||||
|
if (!prénom.trim()) { setProfilError('Le prénom ne peut pas être vide.'); return }
|
||||||
|
try {
|
||||||
|
const nameField = isUserA ? 'nameA' : 'nameB'
|
||||||
|
const incomeField = isUserA ? 'incomeA' : 'incomeB'
|
||||||
|
await api.post('/settings', { [nameField]: prénom.trim(), [incomeField]: revenu || '0' })
|
||||||
|
setProfilSaved(true)
|
||||||
|
setTimeout(() => setProfilSaved(false), 3000)
|
||||||
|
onToast('Profil mis à jour', 'ok')
|
||||||
|
} catch {
|
||||||
|
setProfilError('Erreur lors de la sauvegarde.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
if (!current || !newPwd || !confirm) { setError('Remplissez tous les champs.'); return }
|
||||||
|
if (newPwd !== confirm) { setError('Les mots de passe ne correspondent pas.'); return }
|
||||||
|
if (newPwd.length < 6) { setError('6 caractères minimum.'); return }
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await changePassword(current, newPwd)
|
||||||
|
setSuccess(true)
|
||||||
|
setCurrent(''); setNewPwd(''); setConfirm('')
|
||||||
|
setTimeout(() => setSuccess(false), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card cc full">
|
||||||
|
<div className="card-lbl">Compte</div>
|
||||||
|
<div className="card-name">{user?.username}</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, marginBottom: 24 }}>
|
||||||
|
<div className="admin-section-title">Apparence</div>
|
||||||
|
<div className="theme-selector">
|
||||||
|
{[
|
||||||
|
{ id: 'dark', label: '🌙 Sombre' },
|
||||||
|
{ id: 'light', label: '☀️ Clair' },
|
||||||
|
{ id: 'system', label: '⚙️ Système' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
className={`theme-btn${theme === opt.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setTheme(opt.id)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, marginBottom: 24 }}>
|
||||||
|
<div className="admin-section-title">Profil</div>
|
||||||
|
|
||||||
|
{profilError && <div className="modal-err">{profilError}</div>}
|
||||||
|
{profilSaved && <div className="modal-ok">Profil mis à jour ✓</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveProfil}>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Prénom</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="text" value={prénom} onChange={e => setPrénom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Revenu mensuel net</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" min="0" value={revenu} onChange={e => setRevenu(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-add" type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||||
|
<div className="admin-section-title">Changer le mot de passe</div>
|
||||||
|
|
||||||
|
{error && <div className="modal-err">{error}</div>}
|
||||||
|
{success && <div className="modal-ok">Mot de passe modifié ✓</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleChangePassword}>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Mot de passe actuel</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="password" value={current} onChange={e => setCurrent(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Nouveau mot de passe</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Confirmer</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-add" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Enregistrement…' : 'Changer le mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
export default function SetupPage({ onComplete }) {
|
||||||
|
const [nameA, setNameA] = useState('')
|
||||||
|
const [incomeA, setIncomeA] = useState('')
|
||||||
|
const [nameB, setNameB] = useState('')
|
||||||
|
const [incomeB, setIncomeB] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (!nameA.trim() || !nameB.trim()) {
|
||||||
|
setError('Les deux prénoms sont requis.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.post('/settings', {
|
||||||
|
nameA: nameA.trim(),
|
||||||
|
nameB: nameB.trim(),
|
||||||
|
incomeA: incomeA || '0',
|
||||||
|
incomeB: incomeB || '0',
|
||||||
|
setupDone: 'true',
|
||||||
|
})
|
||||||
|
onComplete()
|
||||||
|
} catch {
|
||||||
|
setError('Erreur lors de la sauvegarde.')
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setup-shell">
|
||||||
|
<div className="setup-card">
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
|
<div className="eyebrow">Première connexion</div>
|
||||||
|
<h1>Budget <span>Commun</span></h1>
|
||||||
|
<p className="setup-desc">Configurez votre espace en quelques secondes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="setup-section">
|
||||||
|
<div className="setup-section-lbl">
|
||||||
|
<div className="dot" style={{ background: 'var(--aa)' }} />
|
||||||
|
user1 — première personne
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Prénom</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Prénom"
|
||||||
|
value={nameA}
|
||||||
|
onChange={e => setNameA(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Revenu mensuel net</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
value={incomeA}
|
||||||
|
onChange={e => setIncomeA(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-section">
|
||||||
|
<div className="setup-section-lbl">
|
||||||
|
<div className="dot" style={{ background: 'var(--ab)' }} />
|
||||||
|
user2 — deuxième personne
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Prénom</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Prénom"
|
||||||
|
value={nameB}
|
||||||
|
onChange={e => setNameB(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">Revenu mensuel net</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
value={incomeB}
|
||||||
|
onChange={e => setIncomeB(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="login-err" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
<button className="btn-login" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Sauvegarde…' : 'Commencer →'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { useBudget } from '../contexts/BudgetContext'
|
||||||
|
|
||||||
|
const fmt = n => new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' €'
|
||||||
|
const fmtPct = n => n.toFixed(1) + '%'
|
||||||
|
|
||||||
|
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']
|
||||||
|
|
||||||
|
function monthOptions(count = 24) {
|
||||||
|
const now = new Date()
|
||||||
|
const opts = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + i, 1)
|
||||||
|
const val = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
opts.push({ value: val, label: MONTHS_FR[d.getMonth()] + ' ' + d.getFullYear() })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMonthsLeft(deadline) {
|
||||||
|
if (!deadline) return null
|
||||||
|
const [y, m] = deadline.split('-').map(Number)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = (y - now.getFullYear()) * 12 + (m - now.getMonth() - 1)
|
||||||
|
return Math.max(0, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate Modal ────────────────────────────────────────────
|
||||||
|
function ValidateModal({ project, settings, isPerso, onConfirm, onClose }) {
|
||||||
|
const [month, setMonth] = useState(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
const ia = parseFloat(settings.incomeA) || 0
|
||||||
|
const ib = parseFloat(settings.incomeB) || 0
|
||||||
|
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 50
|
||||||
|
|
||||||
|
const remaining = project.target - project.saved
|
||||||
|
const monthsLeft = computeMonthsLeft(project.deadline)
|
||||||
|
const monthlyTotal = monthsLeft > 0 ? remaining / monthsLeft : remaining
|
||||||
|
|
||||||
|
const shareA = isPerso ? monthlyTotal
|
||||||
|
: project.split === '50' ? monthlyTotal / 2
|
||||||
|
: project.split === 'auto' ? monthlyTotal * pct / 100
|
||||||
|
: monthlyTotal * (project.customPct || 50) / 100
|
||||||
|
const shareB = monthlyTotal - shareA
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal-card">
|
||||||
|
<div className="modal-title">✓ Valider le projet</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 18 }}>
|
||||||
|
{project.emoji} {project.name} — {fmt(project.target)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inp-grp">
|
||||||
|
<label className="inp-lbl">À partir de quel mois ?</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={month} onChange={e => setMonth(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
{monthOptions().map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface2)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8, padding: 12, fontSize: 11, color: 'var(--muted)',
|
||||||
|
lineHeight: 1.7, marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
{monthsLeft != null
|
||||||
|
? `Sur ${monthsLeft} mois restants → ${fmt(monthlyTotal)}/mois`
|
||||||
|
: `Montant total : ${fmt(remaining)}`}
|
||||||
|
{!isPerso && <><br />{settings.nameA} : {fmt(shareA)}/mois · {settings.nameB} : {fmt(shareB)}/mois</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-btns">
|
||||||
|
<button className="btn-cancel" onClick={onClose}>Annuler</button>
|
||||||
|
<button
|
||||||
|
className="btn-confirm"
|
||||||
|
style={{ background: '#f9a875' }}
|
||||||
|
onClick={() => onConfirm(month, monthlyTotal, shareA, shareB)}
|
||||||
|
>
|
||||||
|
Ajouter aux dépenses
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project card ──────────────────────────────────────────────
|
||||||
|
function ProjectCard({ project, settings, isPerso, onDelete, onValidate }) {
|
||||||
|
const remaining = project.target - project.saved
|
||||||
|
const progress = Math.min(100, project.saved / project.target * 100)
|
||||||
|
const monthsLeft = computeMonthsLeft(project.deadline)
|
||||||
|
const monthly = monthsLeft > 0 ? remaining / monthsLeft : null
|
||||||
|
|
||||||
|
const ia = parseFloat(settings.incomeA) || 0
|
||||||
|
const ib = parseFloat(settings.incomeB) || 0
|
||||||
|
const pct = ia + ib > 0 ? Math.round(ia / (ia + ib) * 100) : 50
|
||||||
|
|
||||||
|
const shareA = (!isPerso && monthly)
|
||||||
|
? (project.split === '50' ? monthly / 2
|
||||||
|
: project.split === 'auto' ? monthly * pct / 100
|
||||||
|
: monthly * (project.customPct || 50) / 100)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="simu-project">
|
||||||
|
<div className="simu-proj-header">
|
||||||
|
<div className="simu-proj-icon">{project.emoji}</div>
|
||||||
|
<div>
|
||||||
|
<div className="simu-proj-name">{project.name}</div>
|
||||||
|
<div className="simu-proj-sub">
|
||||||
|
{fmt(project.saved)} économisé sur {fmt(project.target)}
|
||||||
|
{project.deadline ? ` · échéance ${project.deadline}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="simu-proj-actions">
|
||||||
|
<button className="btn-sm" onClick={() => onValidate(project)}>✓ Valider</button>
|
||||||
|
<button className="btn-icon" onClick={() => onDelete(project.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="simu-progress-track">
|
||||||
|
<div className="simu-progress-fill" style={{ width: progress + '%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="simu-stats">
|
||||||
|
<div>
|
||||||
|
<div className="simu-stat-lbl">Restant</div>
|
||||||
|
<div className="simu-stat-val">{fmt(remaining)}</div>
|
||||||
|
</div>
|
||||||
|
{monthsLeft != null && (
|
||||||
|
<div>
|
||||||
|
<div className="simu-stat-lbl">Mois restants</div>
|
||||||
|
<div className="simu-stat-val">{monthsLeft}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{monthly && (
|
||||||
|
<div>
|
||||||
|
<div className="simu-stat-lbl">/ mois</div>
|
||||||
|
<div className="simu-stat-val">{fmt(monthly)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shareA && (
|
||||||
|
<div>
|
||||||
|
<div className="simu-stat-lbl">{settings.nameA}</div>
|
||||||
|
<div className="simu-stat-val">{fmt(shareA)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shareA && (
|
||||||
|
<div>
|
||||||
|
<div className="simu-stat-lbl">{settings.nameB}</div>
|
||||||
|
<div className="simu-stat-val">{fmt(monthly - shareA)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────
|
||||||
|
const STORAGE_KEY = 'budget-simulations'
|
||||||
|
|
||||||
|
export default function SimulateurPage({ settings, onExpensesChange, expenses, onToast }) {
|
||||||
|
const { activeBudget } = useBudget()
|
||||||
|
const isPerso = activeBudget.type === 'perso'
|
||||||
|
const [projects, setProjects] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') }
|
||||||
|
catch { return [] }
|
||||||
|
})
|
||||||
|
const [emoji, setEmoji] = useState('🏖')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [target, setTarget] = useState('')
|
||||||
|
const [saved, setSaved] = useState('0')
|
||||||
|
const [deadline, setDeadline] = useState('')
|
||||||
|
const [split, setSplit] = useState('auto')
|
||||||
|
const [customPct, setCustomPct] = useState(50)
|
||||||
|
const [validate, setValidate] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
||||||
|
}, [projects])
|
||||||
|
|
||||||
|
function addProject() {
|
||||||
|
if (!name.trim() || !target || parseFloat(target) <= 0) {
|
||||||
|
onToast('Nom et montant cible requis', 'err'); return
|
||||||
|
}
|
||||||
|
const project = {
|
||||||
|
id: Date.now(),
|
||||||
|
emoji, name: name.trim(),
|
||||||
|
target: parseFloat(target),
|
||||||
|
saved: parseFloat(saved) || 0,
|
||||||
|
deadline, split,
|
||||||
|
customPct: parseInt(customPct),
|
||||||
|
}
|
||||||
|
setProjects(prev => [...prev, project])
|
||||||
|
setName(''); setTarget(''); setSaved('0'); setDeadline('')
|
||||||
|
onToast('Projet créé ✓')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProject(id) {
|
||||||
|
setProjects(prev => prev.filter(p => p.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleValidate(month, monthlyTotal, shareA, shareB) {
|
||||||
|
try {
|
||||||
|
const created = await api.post('/expenses', {
|
||||||
|
name: validate.emoji + ' ' + validate.name,
|
||||||
|
amount: monthlyTotal,
|
||||||
|
cat: 'autre',
|
||||||
|
freq: 'mensuel',
|
||||||
|
month: null,
|
||||||
|
})
|
||||||
|
onExpensesChange([...expenses, created])
|
||||||
|
deleteProject(validate.id)
|
||||||
|
setValidate(null)
|
||||||
|
onToast('Projet ajouté aux dépenses ✓')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message, 'err')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthOpts = monthOptions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card simu-card full">
|
||||||
|
<div className="card-lbl">Simulateur de budget</div>
|
||||||
|
<div className="card-name" style={{ color: '#f9a875' }}>🎯 Projets & Objectifs</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="add-simu-form">
|
||||||
|
<div className="add-simu-title">Nouveau projet</div>
|
||||||
|
<div className="simu-form-grid">
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Emoji</label>
|
||||||
|
<input
|
||||||
|
type="text" value={emoji} onChange={e => setEmoji(e.target.value)} maxLength={2}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8, color: 'var(--text)', fontSize: 20,
|
||||||
|
padding: 9, width: 48, textAlign: 'center', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Nom du projet</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="text" placeholder="ex: Vacances Espagne" value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addProject()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Montant cible</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="3000" min="1" value={target}
|
||||||
|
onChange={e => setTarget(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Déjà économisé</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="number" placeholder="0" min="0" value={saved}
|
||||||
|
onChange={e => setSaved(e.target.value)} />
|
||||||
|
<span className="inp-sfx">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Échéance</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<input type="month" value={deadline} onChange={e => setDeadline(e.target.value)}
|
||||||
|
style={{ padding: '11px 8px', fontSize: 13 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isPerso && (
|
||||||
|
<div>
|
||||||
|
<label className="inp-lbl">Répartition</label>
|
||||||
|
<div className="inp-row">
|
||||||
|
<select value={split} onChange={e => setSplit(e.target.value)} style={{ fontSize: 13, padding: '11px 8px' }}>
|
||||||
|
<option value="auto">Proportionnelle aux revenus</option>
|
||||||
|
<option value="50">50% / 50%</option>
|
||||||
|
<option value="custom">Personnalisée</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isPerso && split === 'custom' && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<label className="inp-lbl">Part de {settings.nameA} (%)</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="100" step="1" value={customPct}
|
||||||
|
onChange={e => setCustomPct(e.target.value)}
|
||||||
|
style={{ flex: 1, accentColor: '#f9a875' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: '#f9a875', whiteSpace: 'nowrap', minWidth: 90 }}>
|
||||||
|
{customPct}% / {100 - customPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-add"
|
||||||
|
style={{ background: '#f9a875', marginTop: 14, width: '100%' }}
|
||||||
|
onClick={addProject}
|
||||||
|
>
|
||||||
|
+ Créer le projet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="simu-empty">
|
||||||
|
Aucun projet pour l'instant.<br />
|
||||||
|
Créez votre premier objectif d'épargne ci-dessus.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projects.map(p => (
|
||||||
|
<ProjectCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
settings={settings}
|
||||||
|
isPerso={isPerso}
|
||||||
|
onDelete={deleteProject}
|
||||||
|
onValidate={setValidate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validate && (
|
||||||
|
<ValidateModal
|
||||||
|
project={validate}
|
||||||
|
settings={settings}
|
||||||
|
isPerso={isPerso}
|
||||||
|
onConfirm={handleValidate}
|
||||||
|
onClose={() => setValidate(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../backend/public',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user