commit ac63c4be9980199975cd07c49c5701df162a7843 Author: alexandre grondin Date: Sun Apr 19 22:24:59 2026 +0200 Set up initial frontend with Vite and integrated Docker for full-stack build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..185b36d --- /dev/null +++ b/.gitignore @@ -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* diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4d9de6 --- /dev/null +++ b/README.md @@ -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@ "sudo docker load" +``` + +### 2. Créer l'application + +```bash +ssh admin@ + +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@ "sudo docker load" + +# Redéployer +ssh admin@ "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 pg_dump -U budget budget > backup.sql +``` + +Pour restaurer : +```bash +docker exec -i psql -U budget budget < backup.sql +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..78c0430 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..150b190 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1127 @@ +{ + "name": "budget-commun-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-commun-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.3" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d4640f8 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/public/icon-192.png b/backend/public/icon-192.png new file mode 100644 index 0000000..ffbb69e Binary files /dev/null and b/backend/public/icon-192.png differ diff --git a/backend/public/icon-512.png b/backend/public/icon-512.png new file mode 100644 index 0000000..13b93db Binary files /dev/null and b/backend/public/icon-512.png differ diff --git a/backend/public/index.html b/backend/public/index.html new file mode 100644 index 0000000..2da8169 --- /dev/null +++ b/backend/public/index.html @@ -0,0 +1,1652 @@ + + + + + + + + + + + +Budget Commun + + + + + + +
+ + Tirer pour rafraîchir +
+ + + + + + + + +
+ +
+
Gestion financière · Couple
+

Budget Commun

+
+ + +
+
Chargement…
+
+ + + +
+
+ + + + + +
+
+
+
Partenaire A
+
+
+
€/mois
+
Annuel
+
Part du foyer
+
+
+
Partenaire B
+
+
+
€/mois
+
Annuel
+
Part du foyer
+
+
+
Compte Commun
+
Charges & Budget partagé
+
+
+
Budget mensuel récurrent (calculé)
+
€/mois
+
Ajoutez des dépenses dans l'onglet Dépenses.
+
+
par an
+
+
+
RépartitionGlissez pour affiner
+ +
+
+
+
+
+
+
+
+
+
+
+
Reste
+
Reste
+
+
+
+
+
+ + +
+
+
+
Dépenses communes
+
Liste & Suivi
+
+
0 €
Récurrent/mois
+
0 €
Récurrent/an
+
0
dépenses
+
+ + +
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+
+
+ + + + + + +
+
+
+ + +
+
+
+
Pointage mensuel
+ + +
+ +
+
+
Chargement…
+
+ +
+ + +
+ + +
+
0
Pointées
+
0
Restantes
+
0 €
Total pointé
+
+ + +
+ + +
+ À pointer + +
+
+ +
+
+
+ + +
+
+
+
Administration
+
⚙ Gestion des catégories
+ +
+
Catégories existantes
+
+
+ +
+
Ajouter une catégorie
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
Simulateur de budget
+
🎯 Projets & Objectifs
+ + +
+
Nouveau projet
+
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ + +
+ + +
+
Aucun projet pour l'instant.
Créez votre premier objectif d'épargne ci-dessus.
+
+
+
+
+ + + + + + + +
Données hébergées sur votre réseau · Budget Commun
+ + + + diff --git a/backend/public/manifest.json b/backend/public/manifest.json new file mode 100644 index 0000000..0abf83d --- /dev/null +++ b/backend/public/manifest.json @@ -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" } + ] +} diff --git a/backend/public/sw.js b/backend/public/sw.js new file mode 100644 index 0000000..3553f20 --- /dev/null +++ b/backend/public/sw.js @@ -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)) + ); +}); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..94cbd2e --- /dev/null +++ b/backend/server.js @@ -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); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b9dd17 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3a13ec7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + Budget Commun + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..47f1be7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1677 @@ +{ + "name": "budget-commun-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-commun-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..51e3bf5 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..96008f1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + B + diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..ffbb69e Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..13b93db Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..13033fa --- /dev/null +++ b/frontend/public/sw.js @@ -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)) + ) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..b0549bf --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
{msg}
+ ) +} + +// ── Budget switcher ─────────────────────────────────────────── +function BudgetSwitcher() { + const { activeBudgetId, accessibleBudgets, switchBudget } = useBudget() + if (accessibleBudgets.length <= 1) return null + return ( +
+ {accessibleBudgets.map(b => ( + + ))} +
+ ) +} + +// ── 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 ( +
+
+
+ ) + } + + if (settings.setupDone !== 'true') { + return + } + + return ( +
+ {/* Pull-to-refresh indicator */} + {(pullY > 0 || refreshing) && ( +
+
+
+ )} + +
+
+
Espace familial
+

Budget {activeBudget.nom === 'Commun' ? 'Commun' : activeBudget.nom}

+
+ + {accountOpen && ( + <> +
setAccountOpen(false)} /> +
+ + {user?.isAdmin && ( + + )} + +
+ + )} +
+
+ + {/* Budget switcher (multi-profil — étape 4) */} +
+ +
+ + {/* Tab nav */} + +
+ + {/* Page content */} + {tab === 'budget' && ( + + )} + {tab === 'depenses' && ( + + )} + {tab === 'pointage' && ( + + )} + {tab === 'simu' && ( + + )} + {tab === 'epargnes' && } + {tab === 'admin' && user?.isAdmin && ( + + )} + {tab === 'compte' && ( + + )} + +
Données hébergées sur votre réseau · Budget Commun
+ + {toast && } +
+ ) +} + +// ── Root ────────────────────────────────────────────────────── +export default function App() { + return ( + + + + + + + + ) +} + +function AppInner() { + const { user, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) return + return +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..db72c25 --- /dev/null +++ b/frontend/src/api/client.js @@ -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), +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..0a823ea --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} diff --git a/frontend/src/contexts/BudgetContext.jsx b/frontend/src/contexts/BudgetContext.jsx new file mode 100644 index 0000000..23812d3 --- /dev/null +++ b/frontend/src/contexts/BudgetContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useBudget() { + return useContext(BudgetContext) +} diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..5e92f1c --- /dev/null +++ b/frontend/src/contexts/ThemeContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useTheme() { + return useContext(ThemeContext) +} diff --git a/frontend/src/hooks/usePullToRefresh.js b/frontend/src/hooks/usePullToRefresh.js new file mode 100644 index 0000000..c7ed0c6 --- /dev/null +++ b/frontend/src/hooks/usePullToRefresh.js @@ -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 } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..891fd1c --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1605 @@ +:root, [data-theme="dark"] { + --bg: #0f0e0c; + --surface: #1a1814; + --surface2: #232018; + --border: #2e2a22; + --aa: #e8b86d; + --ab: #7eb8a4; + --text: #e8e2d6; + --muted: #6b6456; + --danger: #c97b6a; + --safe-top: env(safe-area-inset-top); + --safe-bottom: env(safe-area-inset-bottom); +} + +[data-theme="light"] { + --bg: #f8f4ec; + --surface: #fefcf7; + --surface2: #ede8dd; + --border: #d6cec2; + --aa: #c9953a; + --ab: #4e9080; + --text: #1e1a14; + --muted: #8a7d6e; + --danger: #b85a48; + --safe-top: env(safe-area-inset-top); + --safe-bottom: env(safe-area-inset-bottom); +} + +[data-theme="light"] body::before { + background: + radial-gradient(ellipse 70% 50% at 20% 10%, rgba(201, 149, 58, .07) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 80% 90%, rgba(78, 144, 128, .07) 0%, transparent 60%); +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html, body, #root { + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'DM Mono', monospace; + min-height: 100vh; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 70% 50% at 20% 10%, rgba(232, 184, 109, .05) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 80% 90%, rgba(126, 184, 164, .05) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +/* ── Layout ─────────────────────────────────────────────────── */ +.app-shell { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + padding: calc(var(--safe-top) + 28px) 16px calc(var(--safe-bottom) + 60px); + position: relative; + z-index: 1; +} + +.wrap { + width: 100%; + max-width: 860px; +} + +/* ── Header ─────────────────────────────────────────────────── */ +header { + text-align: center; + margin-bottom: 18px; + position: relative; +} + +.account-wrap { + position: absolute; + top: 0; + right: 0; +} + +.account-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .2s; +} + +.account-btn:hover, +.account-btn.active { + background: var(--surface2); + color: var(--aa); + border-color: var(--aa); +} + +.account-backdrop { + position: fixed; + inset: 0; + z-index: 99; +} + +.account-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 100; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px; + min-width: 130px; + box-shadow: 0 8px 24px rgba(0,0,0,.5); +} + +.account-dropdown button { + display: block; + width: 100%; + padding: 9px 14px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--text); + font-family: 'DM Mono', monospace; + font-size: 11px; + letter-spacing: .06em; + text-transform: uppercase; + cursor: pointer; + transition: background .15s; + text-align: left; +} + +.account-dropdown button:hover { + background: var(--surface2); +} + +.account-dropdown .dropdown-logout { + color: var(--danger); + border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 10px; +} +.account-dropdown .dropdown-logout:hover { + background: rgba(201, 123, 106, .1); +} + +.eyebrow { + font-size: 10px; + letter-spacing: .25em; + color: var(--muted); + text-transform: uppercase; + margin-bottom: 8px; +} + +h1 { + font-family: 'Playfair Display', serif; + font-size: clamp(22px, 5vw, 36px); + font-weight: 400; + letter-spacing: -.02em; +} + +h1 span { color: var(--aa); } + +/* ── Save bar ────────────────────────────────────────────────── */ +.save-bar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.save-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--muted); +} + +.save-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); + transition: background .3s; + flex-shrink: 0; +} +.save-dot.ok { background: var(--ab); } +.save-dot.saving { background: var(--aa); animation: pulse .8s infinite; } +.save-dot.err { background: var(--danger); } + +/* ── Tabs ────────────────────────────────────────────────────── */ +.tabs-nav { + display: flex; + gap: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px; + margin-bottom: 18px; +} + +.tab-btn { + flex: 1; + padding: 9px 10px; + border: none; + border-radius: 9px; + background: transparent; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 10px; + letter-spacing: .06em; + cursor: pointer; + transition: all .2s; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + white-space: nowrap; +} + +.tab-btn.active { + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); +} + +.tab-btn .tdot { + width: 5px; + height: 5px; + border-radius: 50%; + opacity: .3; + transition: opacity .2s; + flex-shrink: 0; +} + +.tab-btn.active .tdot { opacity: 1; } + +/* ── Grid & Cards ────────────────────────────────────────────── */ +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +@media (max-width: 540px) { + .grid { grid-template-columns: 1fr; } +} + +.full { grid-column: 1 / -1; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 22px; + position: relative; + overflow: hidden; +} + +.card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; +} + +.card.ca::before { background: var(--aa); } +.card.cb::before { background: var(--ab); } +.card.cc::before { background: linear-gradient(90deg, var(--aa), var(--ab)); } +.card.cd::before { background: linear-gradient(90deg, var(--ab), var(--aa)); } +.card.admin-card::before { background: linear-gradient(90deg, #a78bfa, #7eb8a4); } +.card.simu-card::before { background: linear-gradient(90deg, #f9a875, #e8b86d); } + +.card-lbl { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; +} + +.card-name { + font-family: 'Playfair Display', serif; + font-size: 18px; + font-weight: 400; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 7px; +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +/* ── Inputs ──────────────────────────────────────────────────── */ +.inp-grp { margin-bottom: 12px; } + +.inp-lbl { + display: block; + font-size: 10px; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 5px; +} + +.inp-row { + display: flex; + align-items: center; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0 12px; +} + +.inp-row input, +.inp-row select { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: 'DM Mono', monospace; + font-size: 15px; + font-weight: 500; + padding: 11px 8px; + width: 100%; + min-width: 0; +} + +input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; } + +.inp-sfx { + font-size: 12px; + color: var(--muted); + flex-shrink: 0; +} + +select { + -webkit-appearance: none; + cursor: pointer; +} + +/* ── Stat rows ───────────────────────────────────────────────── */ +.stat-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 7px 0; + border-bottom: 1px solid var(--border); +} +.stat-row:last-child { border-bottom: none; } +.stat-lbl { font-size: 11px; color: var(--muted); } +.stat-val { font-size: 14px; font-weight: 500; } + +/* ── Budget display ──────────────────────────────────────────── */ +.budget-row { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.budget-row > div:first-child { flex: 1; min-width: 180px; } +.budget-box { + text-align: center; + padding: 16px; + background: var(--surface2); + border-radius: 12px; + border: 1px solid var(--border); + flex-shrink: 0; + min-width: 130px; +} +.budget-big { + font-family: 'Playfair Display', serif; + font-size: 26px; +} +.budget-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: .1em; + text-transform: uppercase; + margin-top: 3px; +} + +/* ── Repartition grid ────────────────────────────────────────── */ +.rep-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 14px; +} +.rep-card { + background: var(--surface2); + border-radius: 10px; + padding: 14px; + border: 1px solid var(--border); +} +.rep-head { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: .12em; + color: var(--muted); + margin-bottom: 8px; +} +.rep-amt { + font-family: 'Playfair Display', serif; + font-size: 22px; + margin-bottom: 3px; +} +.rep-sub { font-size: 11px; color: var(--muted); } + +/* ── Bar ─────────────────────────────────────────────────────── */ +.bar-wrap { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--border); +} +.bar-lbl { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--muted); + margin-bottom: 7px; +} +.bar-track { + height: 7px; + background: var(--surface2); + border-radius: 99px; + overflow: hidden; + display: flex; + gap: 2px; +} +.bar-seg { + height: 100%; + border-radius: 99px; + transition: width .5s cubic-bezier(.34, 1.56, .64, 1); +} + +/* ── Slider ──────────────────────────────────────────────────── */ +.slider-sec { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--border); +} +.slider-hdr { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} +.slider-ttl { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); +} +input[type=range] { + -webkit-appearance: none; + width: 100%; + height: 4px; + border-radius: 99px; + background: var(--border); + outline: none; + cursor: pointer; +} +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--text); + border: 3px solid var(--aa); + box-shadow: 0 0 0 4px rgba(232, 184, 109, .15); +} +.slider-vals { + display: flex; + justify-content: space-between; + margin-top: 7px; + font-size: 11px; +} + +/* ── Alerts ──────────────────────────────────────────────────── */ +.alert { + margin-top: 14px; + padding: 11px 14px; + border-radius: 8px; + font-size: 11px; + display: flex; + align-items: center; + gap: 8px; +} +.alert.warn { + background: rgba(201, 123, 106, .1); + border: 1px solid rgba(201, 123, 106, .3); + color: var(--danger); +} +.alert.ok-al { + background: rgba(126, 184, 164, .08); + border: 1px solid rgba(126, 184, 164, .2); + color: var(--ab); +} + +/* ── Dépenses ────────────────────────────────────────────────── */ +.dep-sum { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 18px; +} +.sum-box { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + text-align: center; +} +.sum-val { + font-family: 'Playfair Display', serif; + font-size: 20px; + margin-bottom: 3px; +} +.sum-lbl { + font-size: 10px; + color: var(--muted); + letter-spacing: .1em; + text-transform: uppercase; +} + +.add-form { + display: grid; + grid-template-columns: 1fr auto auto auto auto auto; + gap: 7px; + margin-bottom: 16px; + align-items: end; +} +@media (max-width: 600px) { + .add-form { grid-template-columns: 1fr 1fr; } + .add-form .btn-add { grid-column: 1 / -1; } +} + +.cat-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.cat-pill { + padding: 3px 11px; + border-radius: 20px; + font-size: 10px; + letter-spacing: .1em; + text-transform: uppercase; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + font-family: 'DM Mono', monospace; + transition: all .15s; +} +.cat-pill.active { + background: var(--surface2); + color: var(--text); + border-color: var(--aa); +} + +.exp-list { + display: flex; + flex-direction: column; + gap: 5px; +} +.exp-empty { + text-align: center; + padding: 32px 16px; + color: var(--muted); + font-size: 11px; + border: 1px dashed var(--border); + border-radius: 10px; + line-height: 1.9; +} +.exp-item { + display: grid; + grid-template-columns: auto 1fr auto auto auto; + align-items: center; + gap: 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 11px 14px; + animation: fadeIn .2s ease both; +} +.exp-icon { + width: 30px; + height: 30px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; +} +.exp-name { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.exp-meta { + font-size: 9px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .1em; + margin-top: 1px; +} +.exp-amt { + font-family: 'Playfair Display', serif; + font-size: 16px; + white-space: nowrap; +} +.exp-badge { + font-size: 9px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; + white-space: nowrap; +} +.exp-badge.ponctuel { + color: #a78bfa; + border-color: rgba(167, 139, 250, .3); + background: rgba(167, 139, 250, .08); +} +.exp-badge.done { + color: var(--muted); + opacity: .6; +} +.exp-item.exp-done { + opacity: .55; +} +.ech-progress-track { + height: 3px; + background: var(--border); + border-radius: 2px; + margin-top: 5px; + overflow: hidden; +} +.ech-progress-fill { + height: 100%; + background: var(--aa); + border-radius: 2px; + transition: width .3s; +} + +.section-title { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); + margin: 20px 0 10px; + padding-top: 18px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; +} +.section-badge { + background: rgba(167, 139, 250, .12); + color: #a78bfa; + border: 1px solid rgba(167, 139, 250, .2); + border-radius: 20px; + padding: 2px 10px; + font-size: 10px; +} + +.cat-tot-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px 0; + border-bottom: 1px solid var(--border); +} +.cat-tot-row:last-child { border-bottom: none; } +.cat-bar-wrap { + flex: 1; + margin: 0 10px; + height: 4px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} +.cat-bar-fill { + height: 100%; + border-radius: 99px; + transition: width .4s; +} + +/* ── Buttons ─────────────────────────────────────────────────── */ +.btn-add { + padding: 11px 16px; + background: var(--aa); + color: #0f0e0c; + border: none; + border-radius: 8px; + font-family: 'DM Mono', monospace; + font-size: 12px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} +.btn-add:disabled { opacity: .5; cursor: not-allowed; } + +.btn-sm { + padding: 6px 12px; + border-radius: 7px; + font-family: 'DM Mono', monospace; + font-size: 10px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--muted); + transition: all .15s; +} +.btn-sm:hover { color: var(--text); } +.btn-sm.admin-btn { color: #a78bfa; border-color: rgba(167, 139, 250, .3); } + +.btn-del { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--muted); + cursor: pointer; + font-size: 13px; + padding: 4px 6px; + font-family: 'DM Mono', monospace; +} +.btn-del:hover { color: var(--danger); } + +.btn-icon { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--muted); + cursor: pointer; + font-size: 13px; + padding: 4px 7px; + font-family: 'DM Mono', monospace; +} +.btn-icon:hover { color: var(--danger); border-color: rgba(201, 123, 106, .3); } + +/* ── Admin ───────────────────────────────────────────────────── */ +.admin-section { margin-bottom: 24px; } +.admin-section-title { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 12px; +} +.cat-admin-list { display: flex; flex-direction: column; gap: 6px; } +.cat-admin-item { + display: grid; + grid-template-columns: auto auto 1fr auto auto auto; + align-items: center; + gap: 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 14px; +} +.cat-admin-item.inactive { opacity: .45; } +.cat-emoji-btn { + font-size: 20px; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + width: 36px; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.cat-label-inp { + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: 'DM Mono', monospace; + font-size: 13px; + font-weight: 500; + width: 100%; +} +.toggle-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + cursor: pointer; + font-size: 11px; + padding: 4px 9px; + font-family: 'DM Mono', monospace; + white-space: nowrap; +} +.toggle-btn.active { color: var(--ab); border-color: rgba(126, 184, 164, .3); } + +.emoji-picker { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.emoji-opt { + font-size: 20px; + cursor: pointer; + padding: 4px; + border-radius: 6px; + border: 1px solid transparent; +} +.emoji-opt:hover { border-color: var(--border); } + +/* ── Pointage ────────────────────────────────────────────────── */ +.ptg-month-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.ptg-month-label { + font-family: 'Playfair Display', serif; + font-size: 20px; + text-align: center; +} +.ptg-nav-btn { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + cursor: pointer; + font-size: 20px; + padding: 6px 14px; + font-family: 'DM Mono', monospace; +} +.ptg-nav-btn:disabled { opacity: .3; cursor: default; } +.ptg-progress { + height: 4px; + background: var(--border); + border-radius: 99px; + overflow: hidden; + margin-bottom: 14px; +} +.ptg-progress-fill { + height: 100%; + border-radius: 99px; + background: var(--ab); + transition: width .4s; +} +.ptg-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 14px; +} +.ptg-section-title { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); + margin: 14px 0 10px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.ptg-item { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 11px 14px; + margin-bottom: 6px; + cursor: pointer; + transition: opacity .2s; +} +.ptg-item.pointed { opacity: .5; } +.ptg-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all .2s; +} +.ptg-item.pointed .ptg-check { + background: var(--ab); + border-color: var(--ab); + color: #0f0e0c; +} +.ptg-name { font-size: 13px; font-weight: 500; } +.ptg-meta { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; margin-top: 1px; } +.ptg-amt { + font-family: 'Playfair Display', serif; + font-size: 16px; + white-space: nowrap; +} +.ptg-badge { + font-size: 9px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; +} + +/* ── Simulateur ──────────────────────────────────────────────── */ +.add-simu-form { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + margin-bottom: 20px; +} +.add-simu-title { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 14px; +} +.simu-form-grid { + display: grid; + grid-template-columns: auto 1fr 1fr 1fr 1fr 1fr; + gap: 10px; + align-items: end; +} +@media (max-width: 700px) { + .simu-form-grid { grid-template-columns: auto 1fr 1fr; } +} +@media (max-width: 480px) { + .simu-form-grid { grid-template-columns: 1fr; } +} +.simu-empty { + text-align: center; + padding: 32px 16px; + color: var(--muted); + font-size: 11px; + border: 1px dashed var(--border); + border-radius: 10px; + line-height: 1.9; +} +.simu-project { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + margin-bottom: 12px; + animation: fadeIn .2s ease both; +} +.simu-proj-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.simu-proj-icon { + font-size: 28px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface); + border-radius: 10px; + border: 1px solid var(--border); + flex-shrink: 0; +} +.simu-proj-name { + font-family: 'Playfair Display', serif; + font-size: 17px; + font-weight: 400; +} +.simu-proj-sub { font-size: 10px; color: var(--muted); margin-top: 2px; } +.simu-proj-actions { margin-left: auto; display: flex; gap: 6px; } +.simu-progress-track { + height: 6px; + background: var(--border); + border-radius: 99px; + overflow: hidden; + margin-bottom: 10px; +} +.simu-progress-fill { + height: 100%; + border-radius: 99px; + background: #f9a875; + transition: width .4s; +} +.simu-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + font-size: 11px; +} +.simu-stat-lbl { color: var(--muted); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 2px; } +.simu-stat-val { font-family: 'Playfair Display', serif; font-size: 16px; } + +/* ── Modal ───────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .7); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.modal-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 26px; + width: 100%; + max-width: 340px; +} +.modal-title { + font-family: 'Playfair Display', serif; + font-size: 18px; + font-weight: 400; + margin-bottom: 18px; +} +.modal-err { + background: rgba(201, 123, 106, .1); + border: 1px solid rgba(201, 123, 106, .3); + color: var(--danger); + font-size: 11px; + padding: 9px 12px; + border-radius: 7px; + margin-bottom: 12px; +} +.modal-ok { + background: rgba(126, 184, 164, .08); + border: 1px solid rgba(126, 184, 164, .2); + color: var(--ab); + font-size: 11px; + padding: 9px 12px; + border-radius: 7px; + margin-bottom: 12px; +} +.modal-btns { + display: flex; + gap: 8px; + margin-top: 18px; +} +.btn-cancel { + flex: 1; + padding: 11px; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 12px; + cursor: pointer; +} +.btn-confirm { + flex: 1; + padding: 11px; + background: var(--aa); + border: none; + border-radius: 8px; + color: #0f0e0c; + font-family: 'DM Mono', monospace; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +/* ── Login ───────────────────────────────────────────────────── */ +.login-screen { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 400; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 32px 28px; + width: 100%; + max-width: 340px; + position: relative; +} +.login-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + border-radius: 20px 20px 0 0; + background: linear-gradient(90deg, var(--aa), var(--ab)); +} +.login-err { + background: rgba(201, 123, 106, .1); + border: 1px solid rgba(201, 123, 106, .3); + color: var(--danger); + font-size: 12px; + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 14px; +} +.btn-login { + width: 100%; + padding: 13px; + background: var(--aa); + color: #0f0e0c; + border: none; + border-radius: 9px; + font-family: 'DM Mono', monospace; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-top: 6px; +} + +/* ── Setup ───────────────────────────────────────────────────── */ +.setup-shell { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 400; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow-y: auto; +} + +.setup-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 36px 28px; + width: 100%; + max-width: 400px; + position: relative; +} + +.setup-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + border-radius: 20px 20px 0 0; + background: linear-gradient(90deg, var(--aa), var(--ab)); +} + +.setup-desc { + font-size: 12px; + color: var(--muted); + margin-top: 8px; +} + +.setup-section { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + margin-bottom: 14px; +} + +.setup-section-lbl { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 14px; +} + +/* ── Toast ───────────────────────────────────────────────────── */ +.toast { + position: fixed; + bottom: calc(var(--safe-bottom) + 80px); + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 18px; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: all .3s; + z-index: 600; + white-space: nowrap; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.toast.t-err { border-color: rgba(201, 123, 106, .4); color: var(--danger); } +.toast.t-ok { border-color: rgba(126, 184, 164, .3); color: var(--ab); } + +/* ── Budget switcher (multi-profil) ──────────────────────────── */ +.budget-switcher { + display: flex; + gap: 6px; + align-items: center; +} +.budget-switch-btn { + padding: 5px 12px; + border-radius: 20px; + font-size: 10px; + letter-spacing: .1em; + text-transform: uppercase; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + font-family: 'DM Mono', monospace; + transition: all .15s; +} +.budget-switch-btn.active { + background: var(--surface2); + color: var(--text); + border-color: var(--aa); +} + +/* ── Loading ─────────────────────────────────────────────────── */ +.loading-overlay { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; +} +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--aa); + border-radius: 50%; + animation: spin .7s linear infinite; +} + +/* ── Animations ──────────────────────────────────────────────── */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Charts ──────────────────────────────────────────────────── */ +.period-tabs { + display: flex; + gap: 4px; + margin-bottom: 18px; +} + +.period-tab { + padding: 5px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 10px; + letter-spacing: .06em; + text-transform: uppercase; + cursor: pointer; + transition: all .15s; +} + +.period-tab.active { + background: var(--surface2); + color: var(--text); + border-color: var(--aa); +} + +.chart-layout { + display: flex; + gap: 24px; + align-items: flex-start; +} + +@media (max-width: 560px) { + .chart-layout { flex-direction: column; align-items: center; } +} + +.donut-wrap { flex-shrink: 0; } + +.chart-legend { + flex: 1; + display: flex; + flex-direction: column; + gap: 9px; + min-width: 0; +} + +.legend-row { + display: grid; + grid-template-columns: 18px 1fr auto 80px 38px; + align-items: center; + gap: 6px; +} + +.legend-emoji { font-size: 13px; text-align: center; } + +.legend-label { + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.legend-val { + font-size: 11px; + color: var(--text); + white-space: nowrap; + text-align: right; +} + +.legend-bar-track { + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.legend-bar-fill { + height: 100%; + border-radius: 2px; + transition: width .4s ease; +} + +.legend-pct { + font-size: 10px; + color: var(--muted); + text-align: right; + white-space: nowrap; +} + +.chart-totals { + display: flex; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.chart-total-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 8px 6px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid transparent; + transition: border-color .15s; +} + +.chart-total-item.active { + border-color: var(--aa); +} + +.ct-label { + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); +} + +.ct-val { + font-size: 13px; + color: var(--text); + font-weight: 600; +} + +/* ── Responsive ─────────────────────────────────────────────── */ + +/* Touch targets — toutes tailles */ +.account-btn { width: 44px; height: 44px; } +.cat-emoji-btn { width: 44px; height: 44px; } +.ptg-nav-btn { min-width: 44px; min-height: 44px; } +.btn-del, +.btn-icon { padding: 8px 10px; } + +/* ── ≤ 640px ───────────────────────────── */ +@media (max-width: 640px) { + .app-shell { + padding: calc(var(--safe-top) + 14px) 12px calc(var(--safe-bottom) + 60px); + } + + /* Cards */ + .card { padding: 16px; } + + /* Budget box */ + .budget-box { min-width: 0; padding: 12px; } + .budget-row > div:first-child { min-width: 0; } + .budget-big { font-size: 22px; } + + /* Rep grid */ + .rep-amt { font-size: 18px; } + + /* Dep/ptg summary : 2 colonnes → 3 colonnes → flex sur mobile */ + .dep-sum { grid-template-columns: 1fr 1fr; } + .ptg-summary { grid-template-columns: 1fr 1fr; } + .simu-stats { grid-template-columns: 1fr 1fr; } + + /* Charts */ + .legend-row { grid-template-columns: 18px 1fr auto 60px; } + .legend-pct { display: none; } + + /* Modales */ + .modal-overlay { padding: 12px; } + .modal-card { padding: 20px 16px; max-width: 100%; } + + /* Setup & login */ + .setup-shell { padding: 12px; } + .setup-card { padding: 24px 16px; max-width: 100%; } + .login-card { padding: 24px 16px; } + +} + +/* ── ≤ 440px ───────────────────────────── */ +@media (max-width: 440px) { + /* Tabs : défilement horizontal */ + .tabs-nav { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + border-radius: 10px; + } + .tabs-nav::-webkit-scrollbar { display: none; } + .tab-btn { + flex: 0 0 auto; + padding: 9px 10px; + font-size: 9px; + letter-spacing: 0; + min-width: 68px; + white-space: nowrap; + } + + /* Budget switcher */ + .budget-switcher { gap: 6px; } + .budget-switch-btn { font-size: 11px; padding: 5px 10px; } + + /* Grids → 1 colonne */ + .rep-grid { grid-template-columns: 1fr; } + .dep-sum { grid-template-columns: 1fr; } + .ptg-summary { grid-template-columns: 1fr; } + .simu-stats { grid-template-columns: 1fr; } + + /* Admin catégories */ + .cat-admin-item { + grid-template-columns: auto 1fr auto auto; + } + .cat-admin-item .toggle-btn { display: none; } + + /* Ligne dépense */ + .exp-item { + grid-template-columns: auto 1fr auto auto; + } + .exp-badge { display: none; } + + /* Pointage */ + .ptg-item { + grid-template-columns: auto 1fr auto; + } + .ptg-badge { display: none; } + + /* Charts légende */ + .legend-row { grid-template-columns: 18px 1fr auto; } + .legend-bar-track { display: none; } + + /* Chart totaux */ + .chart-totals { gap: 5px; } + .ct-val { font-size: 11px; } +} + +/* ── Pull-to-refresh ─────────────────────────────────────────── */ +.ptr-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: flex-end; + height: calc(var(--ptr-y, 0px) + 8px); + z-index: 200; + pointer-events: none; + transition: height .15s ease; +} +.ptr-spinner { + font-size: 22px; + color: var(--aa); + line-height: 1; + margin-bottom: 6px; + transition: transform .2s; +} +.ptr-spinner.spin { + animation: ptr-spin .6s linear infinite; +} +@keyframes ptr-spin { + to { transform: rotate(360deg); } +} + +/* ── Theme selector ──────────────────────────────────────────── */ +.theme-selector { + display: flex; + gap: 8px; +} +.theme-btn { + flex: 1; + padding: 8px 0; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 11px; + cursor: pointer; + transition: all .15s; +} +.theme-btn:hover { color: var(--text); } +.theme-btn.active { + border-color: var(--aa); + color: var(--aa); + background: var(--surface); +} + +/* ── Footer ──────────────────────────────────────────────────── */ +footer { + margin-top: 32px; + font-size: 10px; + color: var(--muted); + letter-spacing: .1em; + text-align: center; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..99ecf0b --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + +) + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') +} diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx new file mode 100644 index 0000000..da93c20 --- /dev/null +++ b/frontend/src/pages/AdminPage.jsx @@ -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 ( +
+
+
Administration
+
⚙ Gestion des catégories
+ + {/* Existing categories */} +
+
Catégories existantes
+
+ {categories.map(cat => { + const p = pending[cat.id] || {} + const showPicker = editEmojis[cat.id] + return ( +
+
+ + #{cat.sort} + setPendingField(cat.id, 'label', e.target.value)} + onBlur={() => pending[cat.id] && saveLabel(cat)} + onKeyDown={e => e.key === 'Enter' && saveLabel(cat)} + /> + + +
+ {showPicker && ( +
+ {EMOJI_LIST.map(em => ( + { + setPendingField(cat.id, 'emoji', em) + setEditEmojis(prev => ({ ...prev, [cat.id]: false })) + // Auto-save emoji + setTimeout(() => saveLabel({ ...cat, ...pending[cat.id], emoji: em }), 0) + }} + > + {em} + + ))} +
+ )} +
+ ) + })} +
+
+ + {/* Add category */} +
+
Ajouter une catégorie
+
+
+ + 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', + }} + /> +
+
+ +
+ setNewLabel(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addCategory()} + /> +
+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..81a78bc --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ( + + + vide + + ) + } + + 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 ( + + ) + }).filter(Boolean) + + return ( + + {paths} + TOTAL + + {fmt(total)} + + + ) +} + +// ── 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 ( +
+
Synthèse par catégorie
+
+ Ajoutez des dépenses récurrentes pour voir la synthèse. +
+
+ ) + + return ( +
+
Synthèse par catégorie
+ + {/* Sélecteur période */} +
+ {PERIODS.map(p => ( + + ))} +
+ +
+ {/* Donut */} +
+ +
+ + {/* Légende + barres */} +
+ {slices.map(s => ( +
+ {s.emoji} + {s.label} + {fmt(s.value * mult)} +
+
+
+ {fmtPct(s.value / total * 100)} +
+ ))} + + {/* Totaux période */} +
+ {PERIODS.map(p => ( +
+ {p.label} + {fmt(total * p.mult)} +
+ ))} +
+
+
+
+ ) +} + +// ── 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 ( +
+
+ {/* Budget perso */} +
+
Budget perso — {name}
+
+ Dépenses récurrentes / mois + {fmt(persoTotal)} +
+
+ Par an + {fmt(persoTotal * 12)} +
+ {persoRecur.length === 0 && ( +
+ Ajoutez des dépenses dans l'onglet Dépenses (budget perso actif). +
+ )} + +
+
+
Montant défini
+
{fmt(montant)}/mois
+
+
+
Dépenses perso
+
{fmt(persoTotal)}/mois
+
+
+ +
+ Reste disponible + + {fmt(solde)} / mois + +
+ + {solde < 0 && ( +
+ ⚠ Les dépenses perso dépassent le montant défini de {fmt(Math.abs(solde))}. +
+ )} +
+ + {/* Contribution budget commun */} +
+
Contribution budget commun
+
+ Par mois + {fmt(contrib)} +
+
+ Par an + {fmt(contrib * 12)} +
+
+ Répartition + {isA ? pct : 100 - pct}% +
+
+ + {/* Montant défini */} +
+
Montant défini
+
+ Reste du budget commun disponible pour le perso +
+
+ {fmt(montant)} +
+
Par mois · {fmt(montant * 12)} / an
+
+ + {/* Graphiques perso */} + +
+
+ ) + } + + // ── Vue commune ────────────────────────────────────────────── + return ( +
+
+ {/* Revenus A */} +
+
Revenus
+
+
+ {settings.nameA} +
+
+ Revenu mensuel net + {fmt(ia)} +
+
+ Annuel + {fmt(ia * 12)} +
+
+ Part des revenus + {fmtPct(ia + ib > 0 ? ia / (ia + ib) * 100 : 50)} +
+
+ + {/* Revenus B */} +
+
Revenus
+
+
+ {settings.nameB} +
+
+ Revenu mensuel net + {fmt(ib)} +
+
+ Annuel + {fmt(ib * 12)} +
+
+ Part des revenus + {fmtPct(ia + ib > 0 ? ib / (ia + ib) * 100 : 50)} +
+
+ + {/* Budget commun */} +
+
Budget commun
+ +
+ Dépenses récurrentes / mois + {fmt(total)} +
+
+ Par an + {fmt(total * 12)} +
+ {recur.length === 0 && ( +
+ Ajoutez des dépenses récurrentes dans l'onglet Dépenses. +
+ )} + +
+
+
+
{settings.nameA} +
+
{fmt(cA)}/mois
+
{fmt(cA * 12)} / an
+
+
+
+
{settings.nameB} +
+
{fmt(cB)}/mois
+
{fmt(cB * 12)} / an
+
+
+ +
+
+ {settings.nameA} : {pct}% + {settings.nameB} : {100 - pct}% +
+
+
+
+
+
+ + {alert &&
{alert.msg}
} +
+ + {/* Reste à vivre */} +
+
Reste à vivre
+
+
+ {settings.nameA} +
+
+ Par mois + {fmt(rA)} +
+
+ % du revenu + {ia > 0 ? fmtPct(rA / ia * 100) : '—'} +
+
+ +
+
Reste à vivre
+
+
+ {settings.nameB} +
+
+ Par mois + {fmt(rB)} +
+
+ % du revenu + {ib > 0 ? fmtPct(rB / ib * 100) : '—'} +
+
+ + {/* Graphiques commun */} + +
+
+ ) +} diff --git a/frontend/src/pages/EpargnesPage.jsx b/frontend/src/pages/EpargnesPage.jsx new file mode 100644 index 0000000..da4f3aa --- /dev/null +++ b/frontend/src/pages/EpargnesPage.jsx @@ -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 ( +
+ {/* Create form */} +
+
Nouvelle épargne
+
+
+
+ + 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' }} /> +
+
+ +
+ setName(e.target.value)} /> +
+
+
+ +
+ setMonthly(e.target.value)} /> + +
+
+
+ +
+ setTarget(e.target.value)} /> + +
+
+
+ +
+
+ + {/* Active savings */} + {active.length === 0 && ( +
+ Aucune épargne active. Créez votre première ci-dessus. +
+ )} + {active.map(s => { + const progress = s.target ? Math.min(100, s.balance / s.target * 100) : null + const isTx = txForm?.savingId === s.id + return ( +
+
+
+
{s.emoji}
+
{s.name}
+ {s.monthly_amount > 0 && ( +
{fmt(s.monthly_amount)}/mois versé
+ )} +
+
+
{fmt(s.balance)}
+ {s.target &&
sur {fmt(s.target)}
} +
+
+ + {progress !== null && ( +
+
+
+ )} + + {/* Transaction inline form */} + {isTx && ( +
+
+ {txForm.type === 'credit' ? '+ Ajouter des fonds' : '− Retirer des fonds'} +
+
+
+ setTxAmount(e.target.value)} autoFocus /> + +
+
+ setTxNote(e.target.value)} /> +
+
+
+ + +
+
+ )} + + {closeConfirm === s.id && ( +
+ Clôturer supprimera le versement mensuel lié. Confirmer ? +
+ + +
+
+ )} + +
+ + + +
+
+ ) + })} + + {/* Closed savings */} + {closed.length > 0 && ( +
+ + {showClosed && closed.map(s => ( +
+
+ {s.emoji} +
+
{s.name}
+
Solde final : {fmt(s.balance)}
+
+
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/ExpensesPage.jsx b/frontend/src/pages/ExpensesPage.jsx new file mode 100644 index 0000000..5b12cd1 --- /dev/null +++ b/frontend/src/pages/ExpensesPage.jsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
Modifier la dépense
+ {error &&
{error}
} + +
+ +
+ setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSave()} /> +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + {freq === 'echelonne' ? ( + <> +
+ +
+ setTotal(e.target.value)} /> + +
+
+
+ +
+ setInstallments(e.target.value)} /> + fois +
+
+
+ +
+ setInstallmentsPaid(e.target.value)} /> + / {installments} +
+
+ {monthlyPreview > 0 && ( +
+ = {fmt(monthlyPreview)} / mois +
+ )} + + ) : ( +
+ +
+ setAmount(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSave()} /> + +
+
+ )} + + {freq === 'ponctuel' && ( +
+ +
+ +
+
+ )} + +
+ + +
+
+
+ ) +} + +// ── 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 ( +
+
{cat.emoji}
+
+
{expense.name}
+
{cat.label}
+ {isEchelonne && ( +
+
+
+ )} +
+
{fmt(expense.amount)}{isEchelonne ? '/mois' : ''}
+ {isEchelonne ? ( + + ✂️ {expense.installments_paid}/{expense.installments} + + ) : ( + + {isPonctuel ? `🗓 ${fmtMonth(expense.month)}` : FREQS[expense.freq]?.label || expense.freq} + + )} +
+ {isEchelonne && !isDone && ( + + )} + + +
+
+ ) +} + +// ── 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 ( +
+
+ {/* Summary */} +
+
+
{fmt(total)}
+
/ mois
+
+
+
{fmt(total * 12)}
+
/ an
+
+
+
{expenses.length}
+
dépenses
+
+
+ + {/* Add form */} +
+
+ +
+ setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd()} /> +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + {freq === 'echelonne' ? ( + <> +
+ +
+ setTotalAmount(e.target.value)} /> + +
+
+
+ +
+ setInstallments(e.target.value)} /> + fois +
+
+ {monthlyPreview > 0 && ( +
+ = {fmt(monthlyPreview)} / mois +
+ )} + + ) : ( +
+ +
+ setAmount(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd()} /> + +
+
+ )} + + {freq === 'ponctuel' && ( +
+ +
+ +
+
+ )} + + +
+ + {/* Category filters */} +
+ + + {termines.length > 0 && ( + + )} + {categories.filter(c => c.active).map(c => ( + + ))} +
+ + {/* List */} + {showPonctuel ? ( + <> +
+ Dépenses ponctuelles {ponctuel.length} +
+
+ {ponctuel.length === 0 + ?
Aucune dépense ponctuelle.
+ : ponctuel.map(e => ( + + ))} +
+ + ) : showTermines ? ( + <> +
+ Remboursements soldés {termines.length} +
+
+ {termines.map(e => ( + + ))} +
+ + ) : ( + <> +
+ {filtered.length === 0 + ?
+ {recur.length === 0 + ? 'Aucune dépense pour l\'instant.\nAjoutez votre première dépense ci-dessus.' + : 'Aucune dépense dans cette catégorie.'} +
+ : filtered.map(e => ( + + ))} +
+ + {catTotals.length > 0 && ( +
+
+ Répartition par catégorie (récurrentes) +
+ {catTotals.map(([slug, amount]) => { + const c = categories.find(c => c.slug === slug) || { label: 'Autre', emoji: '📦' } + const pct = total > 0 ? amount / total * 100 : 0 + return ( +
+ {c.emoji} {c.label} +
+
+
+ {fmt(amount)} +
+ ) + })} +
+ )} + + )} +
+ + {editExp && ( + setEditExp(null)} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..e224b3a --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -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 ( +
+
+
+
Espace privé
+

Budget Commun

+
+ + {error &&
{error}
} + +
+
+ +
+ setUsername(e.target.value)} + placeholder="user1" + /> +
+
+
+ +
+ setPassword(e.target.value)} + /> +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/PointagePage.jsx b/frontend/src/pages/PointagePage.jsx new file mode 100644 index 0000000..61ffdfb --- /dev/null +++ b/frontend/src/pages/PointagePage.jsx @@ -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 ( +
togglePointed(row)} + > +
{row.pointed ? '✓' : ''}
+
+
{row.name}
+
{cat.label}{fromPrev ? ` · ${fmtMonth(row.month)}` : ''}
+
+
{fmt(row.amount)}
+ {row.freq && ( + {row.freq} + )} +
+ ) + } + + return ( +
+
+
Pointage mensuel
+ + {/* Navigation mois */} +
+ +
+
{fmtMonth(month)}
+
+ {month === currentBilling ? 'Mois en cours' : 'Mois passé'} +
+
+ +
+ + {/* Barre de progression */} +
+
+
+ + {/* Résumé */} +
+
+
{pointed.length}
+
Pointées
+
+
+
{unpointed.length}
+
Restantes
+
+
+
{fmt(totalPointed)}
+
Total pointé
+
+
+ + {/* Alertes mois précédent */} + {unpointedPrev.length > 0 && ( +
+ ⚠ {unpointedPrev.length} dépense{unpointedPrev.length > 1 ? 's' : ''} non pointée{unpointedPrev.length > 1 ? 's' : ''} en {fmtMonth(prevMonth)} +
+ )} + + {loading ? ( +
+
+
+ ) : ( + <> + {/* À pointer */} + {unpointed.length > 0 && ( + <> +
+ À pointer ({unpointed.length}) + +
+ {unpointed.map(r => )} + + )} + + {/* Déjà pointées */} + {pointed.length > 0 && ( + <> +
+ Pointées ({pointed.length}) +
+ {pointed.map(r => )} + + )} + + {rows.length === 0 && ( +
+ Aucune dépense récurrente pour ce mois.
+ Ajoutez des dépenses dans l'onglet Dépenses. +
+ )} + + {/* Mois précédent non pointé */} + {unpointedPrev.length > 0 && ( + <> +
+ Non pointées — {fmtMonth(prevMonth)} +
+ {unpointedPrev.map(r => )} + + )} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx new file mode 100644 index 0000000..efde0e8 --- /dev/null +++ b/frontend/src/pages/SettingsPage.jsx @@ -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 ( +
+
+
Compte
+
{user?.username}
+ +
+
Apparence
+
+ {[ + { id: 'dark', label: '🌙 Sombre' }, + { id: 'light', label: '☀️ Clair' }, + { id: 'system', label: '⚙️ Système' }, + ].map(opt => ( + + ))} +
+
+ +
+
Profil
+ + {profilError &&
{profilError}
} + {profilSaved &&
Profil mis à jour ✓
} + +
+
+ +
+ setPrénom(e.target.value)} /> +
+
+
+ +
+ setRevenu(e.target.value)} /> + +
+
+ +
+
+ +
+
Changer le mot de passe
+ + {error &&
{error}
} + {success &&
Mot de passe modifié ✓
} + +
+
+ +
+ setCurrent(e.target.value)} /> +
+
+
+ +
+ setNewPwd(e.target.value)} /> +
+
+
+ +
+ setConfirm(e.target.value)} /> +
+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/SetupPage.jsx b/frontend/src/pages/SetupPage.jsx new file mode 100644 index 0000000..53775d1 --- /dev/null +++ b/frontend/src/pages/SetupPage.jsx @@ -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 ( +
+
+
+
Première connexion
+

Budget Commun

+

Configurez votre espace en quelques secondes.

+
+ +
+
+
+
+ user1 — première personne +
+
+ +
+ setNameA(e.target.value)} + autoFocus + /> +
+
+
+ +
+ setIncomeA(e.target.value)} + /> + +
+
+
+ +
+
+
+ user2 — deuxième personne +
+
+ +
+ setNameB(e.target.value)} + /> +
+
+
+ +
+ setIncomeB(e.target.value)} + /> + +
+
+
+ + {error &&
{error}
} + + + +
+
+ ) +} diff --git a/frontend/src/pages/SimulateurPage.jsx b/frontend/src/pages/SimulateurPage.jsx new file mode 100644 index 0000000..d0bcd55 --- /dev/null +++ b/frontend/src/pages/SimulateurPage.jsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
✓ Valider le projet
+
+ {project.emoji} {project.name} — {fmt(project.target)} +
+ +
+ +
+ +
+
+ +
+ {monthsLeft != null + ? `Sur ${monthsLeft} mois restants → ${fmt(monthlyTotal)}/mois` + : `Montant total : ${fmt(remaining)}`} + {!isPerso && <>
{settings.nameA} : {fmt(shareA)}/mois · {settings.nameB} : {fmt(shareB)}/mois} +
+ +
+ + +
+
+
+ ) +} + +// ── 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 ( +
+
+
{project.emoji}
+
+
{project.name}
+
+ {fmt(project.saved)} économisé sur {fmt(project.target)} + {project.deadline ? ` · échéance ${project.deadline}` : ''} +
+
+
+ + +
+
+ +
+
+
+ +
+
+
Restant
+
{fmt(remaining)}
+
+ {monthsLeft != null && ( +
+
Mois restants
+
{monthsLeft}
+
+ )} + {monthly && ( +
+
/ mois
+
{fmt(monthly)}
+
+ )} + {shareA && ( +
+
{settings.nameA}
+
{fmt(shareA)}
+
+ )} + {shareA && ( +
+
{settings.nameB}
+
{fmt(monthly - shareA)}
+
+ )} +
+
+ ) +} + +// ── 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 ( +
+
+
Simulateur de budget
+
🎯 Projets & Objectifs
+ + {/* Form */} +
+
Nouveau projet
+
+
+ + 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', + }} + /> +
+
+ +
+ setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addProject()} /> +
+
+
+ +
+ setTarget(e.target.value)} /> + +
+
+
+ +
+ setSaved(e.target.value)} /> + +
+
+
+ +
+ setDeadline(e.target.value)} + style={{ padding: '11px 8px', fontSize: 13 }} /> +
+
+ {!isPerso && ( +
+ +
+ +
+
+ )} +
+ + {!isPerso && split === 'custom' && ( +
+ +
+ setCustomPct(e.target.value)} + style={{ flex: 1, accentColor: '#f9a875' }} + /> + + {customPct}% / {100 - customPct}% + +
+
+ )} + + +
+ + {/* Projects list */} + {projects.length === 0 ? ( +
+ Aucun projet pour l'instant.
+ Créez votre premier objectif d'épargne ci-dessus. +
+ ) : ( + projects.map(p => ( + + )) + )} +
+ + {validate && ( + setValidate(null)} + /> + )} +
+ ) +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..231f48d --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } +})