From 53532f7f590ae525069b40c1fc59dbd685cd271e Mon Sep 17 00:00:00 2001 From: "deploy.py" Date: Tue, 28 Apr 2026 19:51:14 +0200 Subject: [PATCH] =?UTF-8?q?deploy:=20equitask=20=E2=80=94=202026-04-28=201?= =?UTF-8?q?9:51:14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 12 + .gitignore | 32 ++ Dockerfile | 67 +++ README.md | 165 ++++++ backend/package.json | 32 ++ backend/src/db/index.ts | 94 ++++ backend/src/db/schema.ts | 72 +++ backend/src/db/seed-sync.ts | 71 +++ backend/src/db/seed.ts | 106 ++++ backend/src/index.ts | 126 +++++ backend/src/routes/categories.ts | 72 +++ backend/src/routes/foyer.ts | 51 ++ backend/src/routes/membres.ts | 76 +++ backend/src/routes/saisies.ts | 380 ++++++++++++++ backend/src/routes/taches.ts | 101 ++++ backend/tsconfig.json | 16 + docker-compose.yml | 28 + frontend/index.html | 17 + frontend/package.json | 33 ++ frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons/icon-192.png | Bin 0 -> 547 bytes frontend/public/icons/icon-512.png | Bin 0 -> 1881 bytes frontend/src/App.tsx | 78 +++ frontend/src/api/client.ts | 36 ++ frontend/src/api/index.ts | 83 +++ frontend/src/components/ui/Layout.tsx | 96 ++++ frontend/src/components/ui/Modal.tsx | 39 ++ .../src/components/ui/PenibiliteSelector.tsx | 36 ++ frontend/src/components/ui/ScoreBadge.tsx | 23 + frontend/src/components/ui/Toast.tsx | 39 ++ frontend/src/context/AppContext.tsx | 61 +++ frontend/src/hooks/useOfflineSync.ts | 58 +++ frontend/src/hooks/useToast.ts | 38 ++ frontend/src/index.css | 36 ++ frontend/src/main.tsx | 13 + frontend/src/offline/queue.ts | 63 +++ frontend/src/pages/Dashboard.tsx | 416 +++++++++++++++ frontend/src/pages/Parametres.tsx | 383 ++++++++++++++ frontend/src/pages/Saisie.tsx | 490 ++++++++++++++++++ frontend/src/pages/SelectionProfil.tsx | 137 +++++ frontend/src/pages/Setup.tsx | 173 +++++++ frontend/src/types/index.ts | 129 +++++ frontend/tailwind.config.js | 22 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 51 ++ 47 files changed, 4093 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/package.json create mode 100644 backend/src/db/index.ts create mode 100644 backend/src/db/schema.ts create mode 100644 backend/src/db/seed-sync.ts create mode 100644 backend/src/db/seed.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/categories.ts create mode 100644 backend/src/routes/foyer.ts create mode 100644 backend/src/routes/membres.ts create mode 100644 backend/src/routes/saisies.ts create mode 100644 backend/src/routes/taches.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/components/ui/Layout.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/PenibiliteSelector.tsx create mode 100644 frontend/src/components/ui/ScoreBadge.tsx create mode 100644 frontend/src/components/ui/Toast.tsx create mode 100644 frontend/src/context/AppContext.tsx create mode 100644 frontend/src/hooks/useOfflineSync.ts create mode 100644 frontend/src/hooks/useToast.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/offline/queue.ts create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Parametres.tsx create mode 100644 frontend/src/pages/Saisie.tsx create mode 100644 frontend/src/pages/SelectionProfil.tsx create mode 100644 frontend/src/pages/Setup.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9eb20e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +*/node_modules +*/dist +*/.vite +*.db +*.db-wal +*.db-shm +data/ +.git +.gitignore +*.md +.env* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d90c7d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Node +node_modules/ +npm-debug.log* + +# Build +backend/dist/ +frontend/dist/ +frontend/.vite/ + +# Base de données locale +*.db +*.db-wal +*.db-shm +data/ + +# Environnement +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/settings.json +.idea/ +*.swp + +# Logs +*.log +logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e801f4a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# Dockerfile multi-stage pour EquiTask +# Stage 1 : Build frontend React +# Stage 2 : Build backend + compilation modules natifs +# Stage 3 : Runtime léger + +# ─── Stage 1 : Build frontend ───────────────────────────────────────────────── +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci --prefer-offline +COPY frontend/ ./ +RUN npm run build + +# ─── Stage 2 : Build backend (TypeScript + modules natifs) ──────────────────── +FROM node:20-alpine AS backend-builder + +# Outils pour compiler better-sqlite3 (module natif) +RUN apk add --no-cache python3 make g++ + +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci + +COPY backend/ ./ +RUN npm run build + +# ─── Stage 3 : Runtime ──────────────────────────────────────────────────────── +FROM node:20-alpine AS runtime + +# Libs runtime nécessaires pour better-sqlite3 +RUN apk add --no-cache libstdc++ + +WORKDIR /app + +# Copier node_modules compilés depuis le builder (incl. better-sqlite3 natif) +COPY --from=backend-builder /app/backend/node_modules ./node_modules + +# Copier le backend compilé +COPY --from=backend-builder /app/backend/dist ./dist + +# Copier le package.json (pour les metadata) +COPY --from=backend-builder /app/backend/package.json ./ + +# Copier le frontend buildé dans le dossier public servi par Express +COPY --from=frontend-builder /app/frontend/dist ./public + +# Créer le dossier data pour la base SQLite +RUN mkdir -p /data + +# Variables d'environnement +ENV NODE_ENV=production +ENV PORT=3001 +ENV DATABASE_PATH=/data/equitask.db + +# Volume pour la persistance de la base de données +VOLUME ["/data"] + +# Exposer le port +EXPOSE 3001 + +# Healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 + +# Démarrer l'application +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ac2864 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# EquiTask + +**Progressive Web App de mesure de la répartition des tâches domestiques dans un foyer.** + +Mesurez et visualisez objectivement qui fait quoi à la maison via un système de scoring combinant temps passé et pénibilité. + +--- + +## Fonctionnalités + +- 📱 **PWA installable** — fonctionne sur mobile et desktop +- 📵 **Mode offline** — saisie sans connexion, synchronisation automatique au retour +- 👥 **Multi-membres** — jusqu'à 7 personnes (2 adultes + 5 enfants) +- 📊 **Dashboard complet** — scores, répartition par catégorie, évolution temporelle, indicateur d'équilibre +- 📋 **Catalogue de tâches** — 30 tâches récurrentes prédéfinies + tâches ponctuelles +- 🔧 **Administration** — CRUD complet membres, catégories, tâches +- ↓ **Export** — CSV (Excel/Sheets) ou JSON + +--- + +## Stack technique + +| Couche | Technologie | +|--------|-------------| +| Frontend | React 18 + Vite 5 + TypeScript + Tailwind CSS | +| Graphiques | Recharts | +| PWA | vite-plugin-pwa + Workbox | +| Offline | IndexedDB (idb) | +| Backend | Node.js + Express + TypeScript | +| Base de données | SQLite + Drizzle ORM | +| Déploiement | Docker (single container) | + +--- + +## Installation et développement + +### Prérequis +- Node.js 20+ +- npm 9+ + +### Démarrage en développement + +```bash +# 1. Installer les dépendances backend +cd backend +npm install + +# 2. Démarrer le backend (port 3001) +npm run dev +``` + +Dans un second terminal : + +```bash +# 3. Installer les dépendances frontend +cd frontend +npm install + +# 4. Démarrer le frontend (port 5173, proxy vers 3001) +npm run dev +``` + +L'application est accessible sur **http://localhost:5173** + +### Build de production + +```bash +# Builder le frontend +cd frontend && npm run build + +# Le build est dans frontend/dist/ +# Copier dans backend/public/ pour le servir via Express +cp -r frontend/dist/* backend/public/ + +# Builder et démarrer le backend +cd backend +npm run build +npm start +``` + +--- + +## Déploiement avec Docker + +### Build et démarrage local + +```bash +docker-compose up --build +``` + +Application accessible sur **http://localhost:3001** + +### Déploiement sur Coolify (VPS) + +L'application est configurée pour être déployée automatiquement via Coolify : + +1. Pousser le code sur Gitea : `git.domench.fr` +2. Coolify détecte le `Dockerfile` et build l'image +3. Le container est déployé sur `equitask.domench.fr` avec HTTPS via Traefik +4. La base SQLite est persistée dans un volume Docker + +**Variables d'environnement Coolify :** +``` +NODE_ENV=production +PORT=3001 +DATABASE_PATH=/data/equitask.db +``` + +**Volume à créer dans Coolify :** +- Container path : `/data` +- Host path : (géré par Coolify) + +--- + +## Modèle de données + +``` +foyer → 1 seul enregistrement +membres → jusqu'à 7 (2 adultes + 5 enfants) +categories → 7 par défaut, éditables +taches_rec. → catalogue de tâches récurrentes +saisies → historique de toutes les tâches effectuées +``` + +**Formule de score :** `score = durée (min) × coefficient pénibilité (1-5)` + +--- + +## Catégories et tâches par défaut + +| Catégorie | Tâches incluses | +|-----------|----------------| +| 🍳 Cuisine | Préparation repas, Vaisselle, Batch cooking… | +| 🧹 Ménage | Aspiration, Salle de bain, Lessive, Repassage… | +| 🛒 Courses | Courses alimentaires, Gestion liste… | +| 👶 Enfants | Bain, Devoirs, Activités, Cartable… | +| 📋 Administratif | RDV médicaux, Papiers, Impôts… | +| 🔧 Entretien maison | Bricolage, Jardinage, Vitres… | +| 🧠 Charge mentale | Planification, Scolarité, Santé famille… | + +--- + +## Structure du projet + +``` +equitask/ +├── backend/ +│ ├── src/ +│ │ ├── db/ # Schéma, seed, connexion SQLite +│ │ ├── routes/ # API REST (foyer, membres, categories, taches, saisies) +│ │ └── index.ts # Serveur Express +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── pages/ # SelectionProfil, Saisie, Dashboard, Parametres +│ │ ├── components/ # UI, saisie, dashboard, settings +│ │ ├── api/ # Clients REST +│ │ ├── offline/ # Queue IndexedDB +│ │ ├── context/ # AppContext (membre actif, online status) +│ │ └── hooks/ # useOfflineSync, useToast +│ └── package.json +├── Dockerfile +├── docker-compose.yml +└── README.md +``` diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..006d21b --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "equitask-backend", + "version": "1.0.0", + "description": "Backend Express + SQLite pour EquiTask", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "better-sqlite3": "^9.4.3", + "cors": "^2.8.5", + "drizzle-orm": "^0.30.10", + "express": "^4.18.3", + "helmet": "^7.1.0", + "morgan": "^1.10.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.9", + "@types/node": "^20.12.2", + "drizzle-kit": "^0.21.4", + "tsx": "^4.7.2", + "typescript": "^5.4.5" + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..231c29e --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,94 @@ +// src/db/index.ts +// Connexion à la base de données SQLite via Drizzle ORM + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema'; +import path from 'path'; +import fs from 'fs'; + +// Chemin de la base de données - configurable via variable d'environnement +const DB_PATH = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'equitask.db'); + +// Créer le répertoire si nécessaire +const dbDir = path.dirname(DB_PATH); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); +} + +// Connexion SQLite +const sqlite = new Database(DB_PATH); + +// Activer WAL mode pour de meilleures performances +sqlite.pragma('journal_mode = WAL'); +sqlite.pragma('foreign_keys = ON'); + +// Instance Drizzle +export const db = drizzle(sqlite, { schema }); + +// Initialiser les tables si elles n'existent pas +export function initDb() { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS foyer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL, + cree_le TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS membres ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('adulte', 'enfant')), + couleur TEXT NOT NULL, + actif INTEGER DEFAULT 1, + ordre INTEGER DEFAULT 0, + cree_le TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL, + icone TEXT NOT NULL, + couleur TEXT NOT NULL, + ordre INTEGER DEFAULT 0, + actif INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS taches_recurrentes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL, + categorie_id INTEGER NOT NULL, + duree_moyenne_min INTEGER NOT NULL, + coefficient_penibilite INTEGER NOT NULL CHECK(coefficient_penibilite BETWEEN 1 AND 5), + actif INTEGER DEFAULT 1, + FOREIGN KEY (categorie_id) REFERENCES categories(id) + ); + + CREATE TABLE IF NOT EXISTS saisies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tache_recurrente_id INTEGER, + nom_tache_oneshot TEXT, + categorie_id INTEGER NOT NULL, + membre_id INTEGER NOT NULL, + date_heure TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + duree_reelle_min INTEGER, + coefficient_penibilite INTEGER NOT NULL CHECK(coefficient_penibilite BETWEEN 1 AND 5), + score_final INTEGER NOT NULL, + notes TEXT, + synced INTEGER DEFAULT 1, + cree_le TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (tache_recurrente_id) REFERENCES taches_recurrentes(id), + FOREIGN KEY (categorie_id) REFERENCES categories(id), + FOREIGN KEY (membre_id) REFERENCES membres(id), + CHECK (tache_recurrente_id IS NOT NULL OR nom_tache_oneshot IS NOT NULL) + ); + + CREATE INDEX IF NOT EXISTS idx_saisies_membre ON saisies(membre_id); + CREATE INDEX IF NOT EXISTS idx_saisies_date ON saisies(date_heure); + CREATE INDEX IF NOT EXISTS idx_saisies_categorie ON saisies(categorie_id); + `); + + console.log('✅ Base de données initialisée :', DB_PATH); +} + +export { sqlite }; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..6da1e59 --- /dev/null +++ b/backend/src/db/schema.ts @@ -0,0 +1,72 @@ +// src/db/schema.ts +// Schéma de la base de données EquiTask - Drizzle ORM + SQLite + +import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +/** Table foyer - un seul enregistrement par instance */ +export const foyer = sqliteTable('foyer', { + id: integer('id').primaryKey({ autoIncrement: true }), + nom: text('nom').notNull(), + cree_le: text('cree_le').default(sql`(datetime('now', 'localtime'))`), +}); + +/** Table membres - jusqu'à 7 membres par foyer */ +export const membres = sqliteTable('membres', { + id: integer('id').primaryKey({ autoIncrement: true }), + nom: text('nom').notNull(), + role: text('role').notNull(), // 'adulte' | 'enfant' + couleur: text('couleur').notNull(), // hex color ex: #ef4444 + actif: integer('actif').default(1), // 1 = actif, 0 = inactif + ordre: integer('ordre').default(0), // ordre d'affichage + cree_le: text('cree_le').default(sql`(datetime('now', 'localtime'))`), +}); + +/** Table categories - catégories de tâches, éditables */ +export const categories = sqliteTable('categories', { + id: integer('id').primaryKey({ autoIncrement: true }), + nom: text('nom').notNull(), + icone: text('icone').notNull(), // emoji + couleur: text('couleur').notNull(), // hex color + ordre: integer('ordre').default(0), + actif: integer('actif').default(1), +}); + +/** Table taches_recurrentes - catalogue des tâches habituelles */ +export const tachesRecurrentes = sqliteTable('taches_recurrentes', { + id: integer('id').primaryKey({ autoIncrement: true }), + nom: text('nom').notNull(), + categorie_id: integer('categorie_id').notNull().references(() => categories.id), + duree_moyenne_min: integer('duree_moyenne_min').notNull(), // durée en minutes + coefficient_penibilite: integer('coefficient_penibilite').notNull(), // 1 à 5 + // score_calcule = duree_moyenne_min * coefficient_penibilite (calculé côté serveur) + actif: integer('actif').default(1), +}); + +/** Table saisies - historique des tâches effectuées */ +export const saisies = sqliteTable('saisies', { + id: integer('id').primaryKey({ autoIncrement: true }), + tache_recurrente_id: integer('tache_recurrente_id').references(() => tachesRecurrentes.id), + nom_tache_oneshot: text('nom_tache_oneshot'), // nullable - tâche ponctuelle + categorie_id: integer('categorie_id').notNull().references(() => categories.id), + membre_id: integer('membre_id').notNull().references(() => membres.id), + date_heure: text('date_heure').notNull().default(sql`(datetime('now', 'localtime'))`), + duree_reelle_min: integer('duree_reelle_min'), // nullable - override de la durée moyenne + coefficient_penibilite: integer('coefficient_penibilite').notNull(), + score_final: integer('score_final').notNull(), // duree * coef, stocké en snapshot + notes: text('notes'), // nullable + synced: integer('synced').default(1), // flag pour sync offline + cree_le: text('cree_le').default(sql`(datetime('now', 'localtime'))`), +}); + +// Types TypeScript exportés +export type Foyer = typeof foyer.$inferSelect; +export type NouveauFoyer = typeof foyer.$inferInsert; +export type Membre = typeof membres.$inferSelect; +export type NouveauMembre = typeof membres.$inferInsert; +export type Categorie = typeof categories.$inferSelect; +export type NouvelleCategorie = typeof categories.$inferInsert; +export type TacheRecurrente = typeof tachesRecurrentes.$inferSelect; +export type NouvelleTache = typeof tachesRecurrentes.$inferInsert; +export type Saisie = typeof saisies.$inferSelect; +export type NouvelleSaisie = typeof saisies.$inferInsert; diff --git a/backend/src/db/seed-sync.ts b/backend/src/db/seed-sync.ts new file mode 100644 index 0000000..f1f4d05 --- /dev/null +++ b/backend/src/db/seed-sync.ts @@ -0,0 +1,71 @@ +// src/db/seed-sync.ts +// Seed synchrone exécuté au premier démarrage + +import { db } from './index'; +import { categories, tachesRecurrentes } from './schema'; + +const CATEGORIES_DEFAUT = [ + { nom: 'Cuisine', icone: '🍳', couleur: '#ef4444', ordre: 1 }, + { nom: 'Ménage', icone: '🧹', couleur: '#f97316', ordre: 2 }, + { nom: 'Courses', icone: '🛒', couleur: '#eab308', ordre: 3 }, + { nom: 'Enfants', icone: '👶', couleur: '#22c55e', ordre: 4 }, + { nom: 'Administratif', icone: '📋', couleur: '#3b82f6', ordre: 5 }, + { nom: 'Entretien maison', icone: '🔧', couleur: '#8b5cf6', ordre: 6 }, + { nom: 'Charge mentale', icone: '🧠', couleur: '#ec4899', ordre: 7 }, +]; + +const TACHES_DEFAUT = [ + { nom: 'Préparation repas', cat: 'Cuisine', duree: 45, coef: 3 }, + { nom: 'Vaisselle / Plonge', cat: 'Cuisine', duree: 20, coef: 2 }, + { nom: 'Rangement cuisine', cat: 'Cuisine', duree: 15, coef: 1 }, + { nom: 'Batch cooking semaine', cat: 'Cuisine', duree: 90, coef: 3 }, + { nom: 'Petit-déjeuner / Goûter enfants', cat: 'Cuisine', duree: 20, coef: 2 }, + { nom: 'Aspiration / Balayage sols', cat: 'Ménage', duree: 30, coef: 2 }, + { nom: 'Nettoyage salle de bain', cat: 'Ménage', duree: 25, coef: 3 }, + { nom: 'Nettoyage WC', cat: 'Ménage', duree: 10, coef: 4 }, + { nom: 'Lessive (tri + machine)', cat: 'Ménage', duree: 20, coef: 2 }, + { nom: 'Étendage / Rangement linge', cat: 'Ménage', duree: 25, coef: 2 }, + { nom: 'Repassage', cat: 'Ménage', duree: 45, coef: 3 }, + { nom: 'Sortie des poubelles', cat: 'Ménage', duree: 10, coef: 2 }, + { nom: 'Courses alimentaires', cat: 'Courses', duree: 60, coef: 2 }, + { nom: 'Gestion liste de courses', cat: 'Courses', duree: 15, coef: 2 }, + { nom: 'Commandes en ligne', cat: 'Courses', duree: 20, coef: 2 }, + { nom: 'Bain / Douche enfants', cat: 'Enfants', duree: 30, coef: 3 }, + { nom: 'Aide aux devoirs', cat: 'Enfants', duree: 45, coef: 3 }, + { nom: 'Conduite activités extrascolaires', cat: 'Enfants', duree: 60, coef: 2 }, + { nom: 'Préparation cartable / affaires', cat: 'Enfants', duree: 15, coef: 2 }, + { nom: 'Planifier RDV médicaux', cat: 'Administratif', duree: 20, coef: 3 }, + { nom: 'Gestion papiers / courrier', cat: 'Administratif', duree: 30, coef: 3 }, + { nom: 'Déclarations / impôts', cat: 'Administratif', duree: 60, coef: 4 }, + { nom: 'Suivi factures / finances', cat: 'Administratif', duree: 30, coef: 3 }, + { nom: 'Bricolage / Réparations', cat: 'Entretien maison', duree: 60, coef: 3 }, + { nom: 'Jardinage / Balcon', cat: 'Entretien maison', duree: 60, coef: 2 }, + { nom: 'Nettoyage vitres', cat: 'Entretien maison', duree: 45, coef: 3 }, + { nom: 'Planification semaine famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Suivi scolarité enfants', cat: 'Charge mentale', duree: 20, coef: 3 }, + { nom: 'Gestion santé famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Organisation vacances / sorties', cat: 'Charge mentale', duree: 45, coef: 3 }, +]; + +// Insérer catégories +for (const cat of CATEGORIES_DEFAUT) { + db.insert(categories).values(cat).run(); +} + +const catsInserted = db.select().from(categories).all(); +const catMap = new Map(catsInserted.map(c => [c.nom, c.id])); + +// Insérer tâches +for (const tache of TACHES_DEFAUT) { + const catId = catMap.get(tache.cat); + if (catId) { + db.insert(tachesRecurrentes).values({ + nom: tache.nom, + categorie_id: catId, + duree_moyenne_min: tache.duree, + coefficient_penibilite: tache.coef, + }).run(); + } +} + +console.log('✅ Seed synchrone terminé'); diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts new file mode 100644 index 0000000..ea7f04a --- /dev/null +++ b/backend/src/db/seed.ts @@ -0,0 +1,106 @@ +// src/db/seed.ts +// Données initiales : 7 catégories par défaut + tâches récurrentes courantes + +import { db, initDb } from './index'; +import { categories, tachesRecurrentes, foyer } from './schema'; +import { eq } from 'drizzle-orm'; + +/** Catégories par défaut avec emojis et couleurs */ +const CATEGORIES_DEFAUT = [ + { nom: 'Cuisine', icone: '🍳', couleur: '#ef4444', ordre: 1 }, + { nom: 'Ménage', icone: '🧹', couleur: '#f97316', ordre: 2 }, + { nom: 'Courses', icone: '🛒', couleur: '#eab308', ordre: 3 }, + { nom: 'Enfants', icone: '👶', couleur: '#22c55e', ordre: 4 }, + { nom: 'Administratif', icone: '📋', couleur: '#3b82f6', ordre: 5 }, + { nom: 'Entretien maison', icone: '🔧', couleur: '#8b5cf6', ordre: 6 }, + { nom: 'Charge mentale', icone: '🧠', couleur: '#ec4899', ordre: 7 }, +]; + +/** Tâches récurrentes par catégorie (durée en min, pénibilité 1-5) */ +const TACHES_DEFAUT: { nom: string; cat: string; duree: number; coef: number }[] = [ + // Cuisine + { nom: 'Préparation repas', cat: 'Cuisine', duree: 45, coef: 3 }, + { nom: 'Vaisselle / Plonge', cat: 'Cuisine', duree: 20, coef: 2 }, + { nom: 'Rangement cuisine', cat: 'Cuisine', duree: 15, coef: 1 }, + { nom: 'Batch cooking semaine', cat: 'Cuisine', duree: 90, coef: 3 }, + { nom: 'Petit-déjeuner / Goûter enfants', cat: 'Cuisine', duree: 20, coef: 2 }, + + // Ménage + { nom: 'Aspiration / Balayage sols', cat: 'Ménage', duree: 30, coef: 2 }, + { nom: 'Nettoyage salle de bain', cat: 'Ménage', duree: 25, coef: 3 }, + { nom: 'Nettoyage WC', cat: 'Ménage', duree: 10, coef: 4 }, + { nom: 'Lessive (tri + machine)', cat: 'Ménage', duree: 20, coef: 2 }, + { nom: 'Étendage / Rangement linge', cat: 'Ménage', duree: 25, coef: 2 }, + { nom: 'Repassage', cat: 'Ménage', duree: 45, coef: 3 }, + { nom: 'Sortie des poubelles', cat: 'Ménage', duree: 10, coef: 2 }, + + // Courses + { nom: 'Courses alimentaires', cat: 'Courses', duree: 60, coef: 2 }, + { nom: 'Gestion liste de courses', cat: 'Courses', duree: 15, coef: 2 }, + { nom: 'Commandes en ligne', cat: 'Courses', duree: 20, coef: 2 }, + + // Enfants + { nom: 'Bain / Douche enfants', cat: 'Enfants', duree: 30, coef: 3 }, + { nom: 'Aide aux devoirs', cat: 'Enfants', duree: 45, coef: 3 }, + { nom: 'Conduite activités extrascolaires', cat: 'Enfants', duree: 60, coef: 2 }, + { nom: 'Préparation cartable / affaires', cat: 'Enfants', duree: 15, coef: 2 }, + + // Administratif + { nom: 'Planifier RDV médicaux', cat: 'Administratif', duree: 20, coef: 3 }, + { nom: 'Gestion papiers / courrier', cat: 'Administratif', duree: 30, coef: 3 }, + { nom: 'Déclarations / impôts', cat: 'Administratif', duree: 60, coef: 4 }, + { nom: 'Suivi factures / finances', cat: 'Administratif', duree: 30, coef: 3 }, + + // Entretien maison + { nom: 'Bricolage / Réparations', cat: 'Entretien maison', duree: 60, coef: 3 }, + { nom: 'Jardinage / Balcon', cat: 'Entretien maison', duree: 60, coef: 2 }, + { nom: 'Nettoyage vitres', cat: 'Entretien maison', duree: 45, coef: 3 }, + + // Charge mentale + { nom: 'Planification semaine famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Suivi scolarité enfants', cat: 'Charge mentale', duree: 20, coef: 3 }, + { nom: 'Gestion santé famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Organisation vacances / sorties', cat: 'Charge mentale', duree: 45, coef: 3 }, +]; + +async function seed() { + console.log('🌱 Démarrage du seed...'); + initDb(); + + // Vérifier si les catégories existent déjà + const existantes = db.select().from(categories).all(); + if (existantes.length > 0) { + console.log('ℹ️ Données déjà présentes, seed ignoré.'); + return; + } + + // Insérer les catégories + console.log('📂 Insertion des catégories...'); + for (const cat of CATEGORIES_DEFAUT) { + db.insert(categories).values(cat).run(); + } + + // Récupérer les catégories insérées pour obtenir leurs IDs + const catsInserted = db.select().from(categories).all(); + const catMap = new Map(catsInserted.map(c => [c.nom, c.id])); + + // Insérer les tâches récurrentes + console.log('📋 Insertion des tâches récurrentes...'); + for (const tache of TACHES_DEFAUT) { + const catId = catMap.get(tache.cat); + if (!catId) { + console.warn(`⚠️ Catégorie "${tache.cat}" introuvable pour la tâche "${tache.nom}"`); + continue; + } + db.insert(tachesRecurrentes).values({ + nom: tache.nom, + categorie_id: catId, + duree_moyenne_min: tache.duree, + coefficient_penibilite: tache.coef, + }).run(); + } + + console.log(`✅ Seed terminé : ${CATEGORIES_DEFAUT.length} catégories, ${TACHES_DEFAUT.length} tâches`); +} + +seed().catch(console.error); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..9591832 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,126 @@ +// src/index.ts +// Serveur Express principal - API REST + service des fichiers statiques du frontend + +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import path from 'path'; +import fs from 'fs'; + +import { initDb } from './db/index'; +import { db } from './db/index'; +import { categories, tachesRecurrentes } from './db/schema'; + +import foyerRouter from './routes/foyer'; +import membresRouter from './routes/membres'; +import categoriesRouter from './routes/categories'; +import tachesRouter from './routes/taches'; +import saisiesRouter from './routes/saisies'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +// ─── Middlewares ───────────────────────────────────────────────────────────── +app.use(helmet({ + contentSecurityPolicy: false, // Désactivé pour simplifier le développement +})); +app.use(cors({ + origin: process.env.NODE_ENV === 'production' ? false : '*', +})); +app.use(morgan('dev')); +app.use(express.json({ limit: '5mb' })); + +// ─── Initialisation DB ─────────────────────────────────────────────────────── +initDb(); + +// Seed automatique si aucune catégorie n'existe +const existantes = db.select().from(categories).limit(1).all(); +if (existantes.length === 0) { + console.log('🌱 Première exécution - seed automatique...'); + try { + // Seed synchrone inline (évite les problèmes d'import dynamique) + const CATS = [ + { nom: 'Cuisine', icone: '🍳', couleur: '#ef4444', ordre: 1 }, + { nom: 'Ménage', icone: '🧹', couleur: '#f97316', ordre: 2 }, + { nom: 'Courses', icone: '🛒', couleur: '#eab308', ordre: 3 }, + { nom: 'Enfants', icone: '👶', couleur: '#22c55e', ordre: 4 }, + { nom: 'Administratif', icone: '📋', couleur: '#3b82f6', ordre: 5 }, + { nom: 'Entretien maison', icone: '🔧', couleur: '#8b5cf6', ordre: 6 }, + { nom: 'Charge mentale', icone: '🧠', couleur: '#ec4899', ordre: 7 }, + ]; + const TACHES = [ + { nom: 'Préparation repas', cat: 'Cuisine', duree: 45, coef: 3 }, + { nom: 'Vaisselle / Plonge', cat: 'Cuisine', duree: 20, coef: 2 }, + { nom: 'Rangement cuisine', cat: 'Cuisine', duree: 15, coef: 1 }, + { nom: 'Batch cooking semaine', cat: 'Cuisine', duree: 90, coef: 3 }, + { nom: 'Aspiration / Balayage sols', cat: 'Ménage', duree: 30, coef: 2 }, + { nom: 'Nettoyage salle de bain', cat: 'Ménage', duree: 25, coef: 3 }, + { nom: 'Nettoyage WC', cat: 'Ménage', duree: 10, coef: 4 }, + { nom: 'Lessive (tri + machine)', cat: 'Ménage', duree: 20, coef: 2 }, + { nom: 'Étendage / Rangement linge', cat: 'Ménage', duree: 25, coef: 2 }, + { nom: 'Repassage', cat: 'Ménage', duree: 45, coef: 3 }, + { nom: 'Sortie des poubelles', cat: 'Ménage', duree: 10, coef: 2 }, + { nom: 'Courses alimentaires', cat: 'Courses', duree: 60, coef: 2 }, + { nom: 'Gestion liste de courses', cat: 'Courses', duree: 15, coef: 2 }, + { nom: 'Commandes en ligne', cat: 'Courses', duree: 20, coef: 2 }, + { nom: 'Bain / Douche enfants', cat: 'Enfants', duree: 30, coef: 3 }, + { nom: 'Aide aux devoirs', cat: 'Enfants', duree: 45, coef: 3 }, + { nom: 'Conduite activités extrascolaires', cat: 'Enfants', duree: 60, coef: 2 }, + { nom: 'Préparation cartable / affaires', cat: 'Enfants', duree: 15, coef: 2 }, + { nom: 'Planifier RDV médicaux', cat: 'Administratif', duree: 20, coef: 3 }, + { nom: 'Gestion papiers / courrier', cat: 'Administratif', duree: 30, coef: 3 }, + { nom: 'Déclarations / impôts', cat: 'Administratif', duree: 60, coef: 4 }, + { nom: 'Suivi factures / finances', cat: 'Administratif', duree: 30, coef: 3 }, + { nom: 'Bricolage / Réparations', cat: 'Entretien maison', duree: 60, coef: 3 }, + { nom: 'Jardinage / Balcon', cat: 'Entretien maison', duree: 60, coef: 2 }, + { nom: 'Nettoyage vitres', cat: 'Entretien maison', duree: 45, coef: 3 }, + { nom: 'Planification semaine famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Suivi scolarité enfants', cat: 'Charge mentale', duree: 20, coef: 3 }, + { nom: 'Gestion santé famille', cat: 'Charge mentale', duree: 30, coef: 4 }, + { nom: 'Organisation vacances / sorties', cat: 'Charge mentale', duree: 45, coef: 3 }, + ]; + for (const cat of CATS) db.insert(categories).values(cat).run(); + const catsDb = db.select().from(categories).all(); + const catMap = new Map(catsDb.map(c => [c.nom, c.id])); + for (const t of TACHES) { + const catId = catMap.get(t.cat); + if (catId) db.insert(tachesRecurrentes).values({ nom: t.nom, categorie_id: catId, duree_moyenne_min: t.duree, coefficient_penibilite: t.coef }).run(); + } + console.log(`✅ Seed : ${CATS.length} catégories, ${TACHES.length} tâches`); + } catch (e) { + console.warn('Seed échoué:', e); + } +} + +// ─── Routes API ────────────────────────────────────────────────────────────── +app.use('/api/foyer', foyerRouter); +app.use('/api/membres', membresRouter); +app.use('/api/categories', categoriesRouter); +app.use('/api/taches', tachesRouter); +app.use('/api/saisies', saisiesRouter); + +// ─── Santé ──────────────────────────────────────────────────────────────────── +app.get('/api/health', (_req, res) => { + res.json({ ok: true, version: '1.0.0', timestamp: new Date().toISOString() }); +}); + +// ─── Frontend statique (en production) ────────────────────────────────────── +const frontendPath = path.join(__dirname, '..', 'public'); +if (fs.existsSync(frontendPath)) { + app.use(express.static(frontendPath)); + // SPA fallback - toutes les routes non-API servent index.html + app.get('*', (req, res) => { + if (!req.path.startsWith('/api')) { + res.sendFile(path.join(frontendPath, 'index.html')); + } + }); +} + +// ─── Démarrage ─────────────────────────────────────────────────────────────── +app.listen(PORT, () => { + console.log(`🚀 EquiTask API démarrée sur le port ${PORT}`); + console.log(` Mode : ${process.env.NODE_ENV || 'development'}`); +}); + +export default app; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts new file mode 100644 index 0000000..f0d3858 --- /dev/null +++ b/backend/src/routes/categories.ts @@ -0,0 +1,72 @@ +// src/routes/categories.ts +// CRUD pour les catégories de tâches + +import { Router } from 'express'; +import { eq, asc } from 'drizzle-orm'; +import { db } from '../db/index'; +import { categories } from '../db/schema'; + +const router = Router(); + +router.get('/', (_req, res) => { + try { + const result = db.select().from(categories) + .where(eq(categories.actif, 1)) + .orderBy(asc(categories.ordre), asc(categories.id)) + .all(); + res.json({ categories: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.post('/', (req, res) => { + try { + const { nom, icone, couleur, ordre } = req.body as { + nom: string; icone: string; couleur: string; ordre?: number; + }; + if (!nom?.trim()) return res.status(400).json({ erreur: 'Nom obligatoire' }); + if (!icone) return res.status(400).json({ erreur: 'Icône obligatoire' }); + if (!couleur) return res.status(400).json({ erreur: 'Couleur obligatoire' }); + + const result = db.insert(categories).values({ + nom: nom.trim(), icone, couleur, ordre: ordre ?? 0, + }).returning().get(); + res.status(201).json({ categorie: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + const { nom, icone, couleur, ordre, actif } = req.body as { + nom?: string; icone?: string; couleur?: string; ordre?: number; actif?: number; + }; + const updates: Record = {}; + if (nom !== undefined) updates.nom = nom.trim(); + if (icone !== undefined) updates.icone = icone; + if (couleur !== undefined) updates.couleur = couleur; + if (ordre !== undefined) updates.ordre = ordre; + if (actif !== undefined) updates.actif = actif; + + const result = db.update(categories).set(updates).where(eq(categories.id, id)).returning().get(); + if (!result) return res.status(404).json({ erreur: 'Catégorie introuvable' }); + res.json({ categorie: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + db.update(categories).set({ actif: 0 }).where(eq(categories.id, id)).run(); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +export default router; diff --git a/backend/src/routes/foyer.ts b/backend/src/routes/foyer.ts new file mode 100644 index 0000000..48ae066 --- /dev/null +++ b/backend/src/routes/foyer.ts @@ -0,0 +1,51 @@ +// src/routes/foyer.ts +import { Router } from 'express'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/index'; +import { foyer } from '../db/schema'; + +const router = Router(); + +/** GET /api/foyer - Récupère le foyer (null si non configuré) */ +router.get('/', (_req, res) => { + try { + const result = db.select().from(foyer).limit(1).all(); + res.json({ foyer: result[0] || null }); + } catch (err) { + res.status(500).json({ erreur: 'Erreur serveur', detail: String(err) }); + } +}); + +/** POST /api/foyer - Crée le foyer (première configuration) */ +router.post('/', (req, res) => { + try { + const { nom } = req.body as { nom: string }; + if (!nom?.trim()) { + return res.status(400).json({ erreur: 'Le nom du foyer est obligatoire' }); + } + const existant = db.select().from(foyer).limit(1).all(); + if (existant.length > 0) { + return res.status(400).json({ erreur: 'Le foyer est déjà configuré', foyer: existant[0] }); + } + const result = db.insert(foyer).values({ nom: nom.trim() }).returning().get(); + res.status(201).json({ foyer: result }); + } catch (err) { + res.status(500).json({ erreur: 'Erreur serveur', detail: String(err) }); + } +}); + +/** PUT /api/foyer/:id - Modifie le nom du foyer */ +router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + const { nom } = req.body as { nom: string }; + if (!nom?.trim()) return res.status(400).json({ erreur: 'Nom obligatoire' }); + const result = db.update(foyer).set({ nom: nom.trim() }).where(eq(foyer.id, id)).returning().get(); + if (!result) return res.status(404).json({ erreur: 'Foyer introuvable' }); + res.json({ foyer: result }); + } catch (err) { + res.status(500).json({ erreur: 'Erreur serveur', detail: String(err) }); + } +}); + +export default router; diff --git a/backend/src/routes/membres.ts b/backend/src/routes/membres.ts new file mode 100644 index 0000000..f0daae0 --- /dev/null +++ b/backend/src/routes/membres.ts @@ -0,0 +1,76 @@ +// src/routes/membres.ts +// CRUD pour les membres du foyer + +import { Router } from 'express'; +import { eq, asc } from 'drizzle-orm'; +import { db } from '../db/index'; +import { membres } from '../db/schema'; + +const router = Router(); + +/** GET /api/membres - Liste tous les membres actifs */ +router.get('/', (_req, res) => { + try { + const result = db.select().from(membres) + .where(eq(membres.actif, 1)) + .orderBy(asc(membres.ordre), asc(membres.id)) + .all(); + res.json({ membres: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** POST /api/membres - Crée un nouveau membre */ +router.post('/', (req, res) => { + try { + const { nom, role, couleur, ordre } = req.body as { + nom: string; role: string; couleur: string; ordre?: number; + }; + if (!nom?.trim()) return res.status(400).json({ erreur: 'Nom obligatoire' }); + if (!['adulte', 'enfant'].includes(role)) return res.status(400).json({ erreur: 'Rôle invalide' }); + if (!couleur) return res.status(400).json({ erreur: 'Couleur obligatoire' }); + + const result = db.insert(membres).values({ + nom: nom.trim(), role, couleur, ordre: ordre ?? 0, actif: 1, + }).returning().get(); + res.status(201).json({ membre: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** PUT /api/membres/:id - Modifie un membre */ +router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + const { nom, role, couleur, actif, ordre } = req.body as { + nom?: string; role?: string; couleur?: string; actif?: number; ordre?: number; + }; + const updates: Record = {}; + if (nom !== undefined) updates.nom = nom.trim(); + if (role !== undefined) updates.role = role; + if (couleur !== undefined) updates.couleur = couleur; + if (actif !== undefined) updates.actif = actif; + if (ordre !== undefined) updates.ordre = ordre; + + const result = db.update(membres).set(updates).where(eq(membres.id, id)).returning().get(); + if (!result) return res.status(404).json({ erreur: 'Membre introuvable' }); + res.json({ membre: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** DELETE /api/membres/:id - Désactive un membre (soft delete) */ +router.delete('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + db.update(membres).set({ actif: 0 }).where(eq(membres.id, id)).run(); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +export default router; diff --git a/backend/src/routes/saisies.ts b/backend/src/routes/saisies.ts new file mode 100644 index 0000000..f3a3ac4 --- /dev/null +++ b/backend/src/routes/saisies.ts @@ -0,0 +1,380 @@ +// src/routes/saisies.ts +// Routes pour les saisies de tâches + dashboard stats + export + +import { Router, Request, Response } from 'express'; +import { eq, and, gte, lte, desc, asc, sql, inArray } from 'drizzle-orm'; +import { db } from '../db/index'; +import { saisies, membres, categories, tachesRecurrentes, foyer } from '../db/schema'; + +const router = Router(); + +// ─── SAISIES CRUD ───────────────────────────────────────────────────────────── + +/** GET /api/saisies - Liste des saisies avec filtres optionnels */ +router.get('/', (req, res) => { + try { + const { debut, fin, membre_id, categorie_id, type, page = '1', limit = '50' } = req.query as Record; + const pageNum = parseInt(page); + const limitNum = Math.min(parseInt(limit), 200); + const offset = (pageNum - 1) * limitNum; + + let query = db.select({ + id: saisies.id, + tache_recurrente_id: saisies.tache_recurrente_id, + nom_tache_oneshot: saisies.nom_tache_oneshot, + categorie_id: saisies.categorie_id, + membre_id: saisies.membre_id, + date_heure: saisies.date_heure, + duree_reelle_min: saisies.duree_reelle_min, + coefficient_penibilite: saisies.coefficient_penibilite, + score_final: saisies.score_final, + notes: saisies.notes, + synced: saisies.synced, + cree_le: saisies.cree_le, + // Jointures + membre_nom: membres.nom, + membre_couleur: membres.couleur, + membre_role: membres.role, + categorie_nom: categories.nom, + categorie_icone: categories.icone, + categorie_couleur: categories.couleur, + tache_nom: tachesRecurrentes.nom, + }) + .from(saisies) + .leftJoin(membres, eq(saisies.membre_id, membres.id)) + .leftJoin(categories, eq(saisies.categorie_id, categories.id)) + .leftJoin(tachesRecurrentes, eq(saisies.tache_recurrente_id, tachesRecurrentes.id)); + + // Filtres + const conditions = []; + if (debut) conditions.push(gte(saisies.date_heure, debut)); + if (fin) conditions.push(lte(saisies.date_heure, fin + 'T23:59:59')); + if (membre_id) conditions.push(eq(saisies.membre_id, parseInt(membre_id))); + if (categorie_id) conditions.push(eq(saisies.categorie_id, parseInt(categorie_id))); + if (type === 'recurrente') conditions.push(sql`${saisies.tache_recurrente_id} IS NOT NULL`); + if (type === 'oneshot') conditions.push(sql`${saisies.nom_tache_oneshot} IS NOT NULL`); + + const result = conditions.length > 0 + ? query.where(and(...conditions)).orderBy(desc(saisies.date_heure)).limit(limitNum).offset(offset).all() + : query.orderBy(desc(saisies.date_heure)).limit(limitNum).offset(offset).all(); + + // Compter le total + const total = db.select({ count: sql`count(*)` }).from(saisies) + .where(conditions.length > 0 ? and(...conditions) : undefined).get()?.count ?? 0; + + res.json({ saisies: result, total, page: pageNum, limit: limitNum }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** POST /api/saisies - Crée une saisie */ +router.post('/', (req, res) => { + try { + const body = req.body as { + tache_recurrente_id?: number; + nom_tache_oneshot?: string; + categorie_id: number; + membre_id: number; + date_heure?: string; + duree_reelle_min?: number; + coefficient_penibilite: number; + score_final: number; + notes?: string; + }; + + if (!body.categorie_id) return res.status(400).json({ erreur: 'Catégorie obligatoire' }); + if (!body.membre_id) return res.status(400).json({ erreur: 'Membre obligatoire' }); + if (!body.tache_recurrente_id && !body.nom_tache_oneshot) { + return res.status(400).json({ erreur: 'Tâche récurrente ou nom ponctuel obligatoire' }); + } + if (body.coefficient_penibilite < 1 || body.coefficient_penibilite > 5) { + return res.status(400).json({ erreur: 'Coefficient invalide (1-5)' }); + } + + const result = db.insert(saisies).values({ + tache_recurrente_id: body.tache_recurrente_id || null, + nom_tache_oneshot: body.nom_tache_oneshot || null, + categorie_id: body.categorie_id, + membre_id: body.membre_id, + date_heure: body.date_heure || new Date().toISOString().slice(0, 19).replace('T', ' '), + duree_reelle_min: body.duree_reelle_min || null, + coefficient_penibilite: body.coefficient_penibilite, + score_final: body.score_final, + notes: body.notes || null, + synced: 1, + }).returning().get(); + + res.status(201).json({ saisie: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** POST /api/saisies/batch - Sync offline (multiple saisies) */ +router.post('/batch', (req, res) => { + try { + const { saisiesData } = req.body as { saisiesData: any[] }; + if (!Array.isArray(saisiesData)) return res.status(400).json({ erreur: 'Array attendu' }); + + let count = 0; + for (const s of saisiesData) { + try { + db.insert(saisies).values({ ...s, synced: 1 }).run(); + count++; + } catch (e) { + console.warn('Saisie batch ignorée:', e); + } + } + res.json({ ok: true, count }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** PUT /api/saisies/:id - Modifie une saisie */ +router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + const { duree_reelle_min, coefficient_penibilite, score_final, notes, date_heure } = req.body; + const updates: Record = {}; + if (duree_reelle_min !== undefined) updates.duree_reelle_min = duree_reelle_min; + if (coefficient_penibilite !== undefined) updates.coefficient_penibilite = coefficient_penibilite; + if (score_final !== undefined) updates.score_final = score_final; + if (notes !== undefined) updates.notes = notes; + if (date_heure !== undefined) updates.date_heure = date_heure; + + const result = db.update(saisies).set(updates).where(eq(saisies.id, id)).returning().get(); + if (!result) return res.status(404).json({ erreur: 'Saisie introuvable' }); + res.json({ saisie: result }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +/** DELETE /api/saisies/:id */ +router.delete('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + db.delete(saisies).where(eq(saisies.id, id)).run(); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +// ─── DASHBOARD STATS ────────────────────────────────────────────────────────── + +/** GET /api/saisies/stats - Statistiques pour le dashboard */ +router.get('/stats', (req, res) => { + try { + const { debut, fin, inclure_enfants = 'false', categorie_id } = req.query as Record; + + // Construire conditions de base + const conditions: any[] = []; + if (debut) conditions.push(gte(saisies.date_heure, debut)); + if (fin) conditions.push(lte(saisies.date_heure, fin + 'T23:59:59')); + if (categorie_id) conditions.push(eq(saisies.categorie_id, parseInt(categorie_id))); + if (inclure_enfants !== 'true') conditions.push(eq(membres.role, 'adulte')); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // 1. Scores par membre + const scoresMembres = db.select({ + membre_id: membres.id, + nom: membres.nom, + couleur: membres.couleur, + role: membres.role, + score_total: sql`COALESCE(SUM(${saisies.score_final}), 0)`, + nb_saisies: sql`COUNT(${saisies.id})`, + }) + .from(membres) + .leftJoin(saisies, and( + eq(saisies.membre_id, membres.id), + debut ? gte(saisies.date_heure, debut) : undefined, + fin ? lte(saisies.date_heure, fin + 'T23:59:59') : undefined, + categorie_id ? eq(saisies.categorie_id, parseInt(categorie_id)) : undefined, + )) + .where(and( + eq(membres.actif, 1), + inclure_enfants !== 'true' ? eq(membres.role, 'adulte') : undefined, + )) + .groupBy(membres.id) + .all(); + + const scoreTotal = scoresMembres.reduce((acc, m) => acc + (m.score_total || 0), 0); + const avecPct = scoresMembres.map(m => ({ + ...m, + pourcentage: scoreTotal > 0 ? Math.round((m.score_total / scoreTotal) * 100) : 0, + })); + + // 2. Scores par catégorie × membre + const scoresParCat = db.select({ + categorie_id: categories.id, + categorie_nom: categories.nom, + categorie_icone: categories.icone, + categorie_couleur: categories.couleur, + membre_id: membres.id, + membre_nom: membres.nom, + membre_couleur: membres.couleur, + score: sql`COALESCE(SUM(${saisies.score_final}), 0)`, + }) + .from(categories) + .leftJoin(saisies, and( + eq(saisies.categorie_id, categories.id), + debut ? gte(saisies.date_heure, debut) : undefined, + fin ? lte(saisies.date_heure, fin + 'T23:59:59') : undefined, + )) + .leftJoin(membres, and( + eq(saisies.membre_id, membres.id), + eq(membres.actif, 1), + inclure_enfants !== 'true' ? eq(membres.role, 'adulte') : undefined, + )) + .where(eq(categories.actif, 1)) + .groupBy(categories.id, membres.id) + .all(); + + // Restructurer par catégorie + const catMap = new Map(); + for (const row of scoresParCat) { + if (!catMap.has(row.categorie_id)) { + catMap.set(row.categorie_id, { + categorie_id: row.categorie_id, + nom: row.categorie_nom, + icone: row.categorie_icone, + couleur: row.categorie_couleur, + scores_membres: [], + }); + } + if (row.membre_id) { + catMap.get(row.categorie_id).scores_membres.push({ + membre_id: row.membre_id, + nom: row.membre_nom, + couleur: row.membre_couleur, + score: row.score, + }); + } + } + const scoresCategories = Array.from(catMap.values()).filter(c => + c.scores_membres.some((m: any) => m.score > 0) + ); + + // 3. Évolution temporelle (7 derniers jours ou période sélectionnée) + const membresActifs = db.select().from(membres) + .where(and( + eq(membres.actif, 1), + inclure_enfants !== 'true' ? eq(membres.role, 'adulte') : undefined, + )).all(); + + const evolutionRaw = db.select({ + date: sql`date(${saisies.date_heure})`, + membre_id: saisies.membre_id, + score_jour: sql`SUM(${saisies.score_final})`, + }) + .from(saisies) + .leftJoin(membres, eq(saisies.membre_id, membres.id)) + .where(and( + debut ? gte(saisies.date_heure, debut) : undefined, + fin ? lte(saisies.date_heure, fin + 'T23:59:59') : undefined, + eq(membres.actif, 1), + inclure_enfants !== 'true' ? eq(membres.role, 'adulte') : undefined, + categorie_id ? eq(saisies.categorie_id, parseInt(categorie_id)) : undefined, + )) + .groupBy(sql`date(${saisies.date_heure})`, saisies.membre_id) + .orderBy(asc(sql`date(${saisies.date_heure})`)) + .all(); + + // Grouper par date + const dateMap = new Map>(); + for (const row of evolutionRaw) { + if (!dateMap.has(row.date)) dateMap.set(row.date, new Map()); + dateMap.get(row.date)!.set(row.membre_id, row.score_jour); + } + const evolution = Array.from(dateMap.entries()).map(([date, scoresByMembre]) => ({ + date, + scores: membresActifs.map(m => ({ + membre_id: m.id, + nom: m.nom, + couleur: m.couleur, + score: scoresByMembre.get(m.id) || 0, + })), + })); + + // 4. Indicateur d'équilibre (adultes seulement) + const adultes = avecPct.filter(m => m.role === 'adulte'); + let indicateur = null; + if (adultes.length >= 2) { + const [a1, a2] = adultes.sort((a, b) => b.score_total - a.score_total); + const totalAdultes = a1.score_total + a2.score_total; + const ecart = totalAdultes > 0 + ? Math.abs(Math.round(((a1.score_total - a2.score_total) / totalAdultes) * 100)) + : 0; + indicateur = { + adulte1: { membre_id: a1.membre_id, nom: a1.nom, couleur: a1.couleur, score: a1.score_total, pourcentage: a1.pourcentage }, + adulte2: { membre_id: a2.membre_id, nom: a2.nom, couleur: a2.couleur, score: a2.score_total, pourcentage: a2.pourcentage }, + ecart_pct: ecart, + statut: ecart <= 10 ? 'vert' : ecart <= 25 ? 'orange' : 'rouge', + }; + } + + res.json({ + scores_par_membre: avecPct, + scores_par_categorie: scoresCategories, + evolution_temporelle: evolution, + indicateur_equilibre: indicateur, + }); + } catch (err) { + console.error(err); + res.status(500).json({ erreur: String(err) }); + } +}); + +// ─── EXPORT ─────────────────────────────────────────────────────────────────── + +/** GET /api/saisies/export?format=json|csv */ +router.get('/export', (req, res) => { + try { + const format = req.query.format === 'csv' ? 'csv' : 'json'; + + const data = db.select({ + id: saisies.id, + date: saisies.date_heure, + membre: membres.nom, + role: membres.role, + tache: sql`COALESCE(${tachesRecurrentes.nom}, ${saisies.nom_tache_oneshot})`, + categorie: categories.nom, + type: sql`CASE WHEN ${saisies.tache_recurrente_id} IS NOT NULL THEN 'recurrente' ELSE 'oneshot' END`, + duree: sql`COALESCE(${saisies.duree_reelle_min}, 0)`, + penibilite: saisies.coefficient_penibilite, + score: saisies.score_final, + notes: saisies.notes, + }) + .from(saisies) + .leftJoin(membres, eq(saisies.membre_id, membres.id)) + .leftJoin(categories, eq(saisies.categorie_id, categories.id)) + .leftJoin(tachesRecurrentes, eq(saisies.tache_recurrente_id, tachesRecurrentes.id)) + .orderBy(desc(saisies.date_heure)) + .all(); + + if (format === 'json') { + res.setHeader('Content-Disposition', 'attachment; filename="equitask-export.json"'); + res.setHeader('Content-Type', 'application/json'); + res.json(data); + } else { + // CSV + const headers = ['id', 'date', 'membre', 'role', 'tache', 'categorie', 'type', 'duree_min', 'penibilite', 'score', 'notes']; + const rows = data.map(row => [ + row.id, row.date, row.membre, row.role, row.tache, row.categorie, + row.type, row.duree, row.penibilite, row.score, row.notes || '', + ].map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')); + + res.setHeader('Content-Disposition', 'attachment; filename="equitask-export.csv"'); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.send('' + [headers.join(','), ...rows].join('\n')); // BOM pour Excel + } + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +export default router; diff --git a/backend/src/routes/taches.ts b/backend/src/routes/taches.ts new file mode 100644 index 0000000..7516ed4 --- /dev/null +++ b/backend/src/routes/taches.ts @@ -0,0 +1,101 @@ +// src/routes/taches.ts +// CRUD pour les tâches récurrentes + +import { Router } from 'express'; +import { eq, asc } from 'drizzle-orm'; +import { db } from '../db/index'; +import { tachesRecurrentes, categories } from '../db/schema'; + +const router = Router(); + +/** Calcule le score d'une tâche */ +const calcScore = (duree: number, coef: number) => duree * coef; + +router.get('/', (_req, res) => { + try { + const taches = db.select({ + id: tachesRecurrentes.id, + nom: tachesRecurrentes.nom, + categorie_id: tachesRecurrentes.categorie_id, + duree_moyenne_min: tachesRecurrentes.duree_moyenne_min, + coefficient_penibilite: tachesRecurrentes.coefficient_penibilite, + actif: tachesRecurrentes.actif, + // Jointure avec categories + categorie_nom: categories.nom, + categorie_icone: categories.icone, + categorie_couleur: categories.couleur, + }) + .from(tachesRecurrentes) + .leftJoin(categories, eq(tachesRecurrentes.categorie_id, categories.id)) + .where(eq(tachesRecurrentes.actif, 1)) + .orderBy(asc(categories.ordre), asc(tachesRecurrentes.nom)) + .all(); + + // Ajouter score_calcule + const avecScore = taches.map(t => ({ + ...t, + score_calcule: calcScore(t.duree_moyenne_min, t.coefficient_penibilite), + })); + + res.json({ taches: avecScore }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.post('/', (req, res) => { + try { + const { nom, categorie_id, duree_moyenne_min, coefficient_penibilite } = req.body as { + nom: string; categorie_id: number; duree_moyenne_min: number; coefficient_penibilite: number; + }; + if (!nom?.trim()) return res.status(400).json({ erreur: 'Nom obligatoire' }); + if (!categorie_id) return res.status(400).json({ erreur: 'Catégorie obligatoire' }); + if (!duree_moyenne_min || duree_moyenne_min < 1) return res.status(400).json({ erreur: 'Durée invalide' }); + if (!coefficient_penibilite || coefficient_penibilite < 1 || coefficient_penibilite > 5) { + return res.status(400).json({ erreur: 'Coefficient 1-5 obligatoire' }); + } + + const result = db.insert(tachesRecurrentes).values({ + nom: nom.trim(), categorie_id, duree_moyenne_min, coefficient_penibilite, + }).returning().get(); + + res.status(201).json({ + tache: { ...result, score_calcule: calcScore(result.duree_moyenne_min, result.coefficient_penibilite) } + }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + const { nom, categorie_id, duree_moyenne_min, coefficient_penibilite, actif } = req.body as { + nom?: string; categorie_id?: number; duree_moyenne_min?: number; coefficient_penibilite?: number; actif?: number; + }; + const updates: Record = {}; + if (nom !== undefined) updates.nom = nom.trim(); + if (categorie_id !== undefined) updates.categorie_id = categorie_id; + if (duree_moyenne_min !== undefined) updates.duree_moyenne_min = duree_moyenne_min; + if (coefficient_penibilite !== undefined) updates.coefficient_penibilite = coefficient_penibilite; + if (actif !== undefined) updates.actif = actif; + + const result = db.update(tachesRecurrentes).set(updates).where(eq(tachesRecurrentes.id, id)).returning().get(); + if (!result) return res.status(404).json({ erreur: 'Tâche introuvable' }); + res.json({ tache: { ...result, score_calcule: calcScore(result.duree_moyenne_min, result.coefficient_penibilite) } }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const id = parseInt(req.params.id); + db.update(tachesRecurrentes).set({ actif: 0 }).where(eq(tachesRecurrentes.id, id)).run(); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ erreur: String(err) }); + } +}); + +export default router; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4ba9de3 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d044863 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +# docker-compose.yml +# Démarrage local pour le développement et les tests + +version: '3.8' + +services: + equitask: + build: . + container_name: equitask + restart: unless-stopped + ports: + - "3001:3001" + volumes: + - equitask_data:/data + environment: + - NODE_ENV=production + - PORT=3001 + - DATABASE_PATH=/data/equitask.db + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + equitask_data: + driver: local diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4c8d98e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + EquiTask + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..52c3374 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "equitask-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.28.9", + "date-fns": "^3.6.0", + "idb": "^8.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", + "recharts": "^2.12.3" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.2.2", + "vite": "^5.2.0", + "vite-plugin-pwa": "^0.19.8", + "workbox-precaching": "^7.1.0", + "workbox-routing": "^7.1.0", + "workbox-strategies": "^7.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9e882fc --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ +1Y \ No newline at end of file diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..e755168040e86fad2857a221116320f604d501c3 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS+@c^MQK4j5ed z_$Z#i?!y5-r%Owi%5v81e0ZxO?!a{(mm~!t=7|y=M;uhxlnezr8$CvahE_;e&bz-R Vm#g+`C@}IFJYD@<);T3K0RS=gm@WVS literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..745c0b98851652d830667a5edf1ce2fb5fa2dc06 GIT binary patch literal 1881 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pKoPZ!6KiaBqu8uBt2@Eq8% z=(hDghGWSFRYxWu7)=GE8DX?67%dJ*M2%3p Y_-n+>#EX*}fK?`gr>mdKI;Vst0HBgi-v9sr literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ed9ebef --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,78 @@ +// src/App.tsx +// Routeur principal et initialisation de l'app + +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AppProvider, useApp } from './context/AppContext'; +import { Setup } from './pages/Setup'; +import { SelectionProfil } from './pages/SelectionProfil'; +import { Saisie } from './pages/Saisie'; +import { Dashboard } from './pages/Dashboard'; +import { Parametres } from './pages/Parametres'; +import { Layout } from './components/ui/Layout'; +import { useQuery } from '@tanstack/react-query'; +import { foyerApi } from './api'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 30_000 }, + }, +}); + +function AppRoutes() { + const { foyer, setFoyer } = useApp(); + + // Charger le foyer au démarrage + const { isLoading } = useQuery({ + queryKey: ['foyer'], + queryFn: async () => { + const f = await foyerApi.get(); + setFoyer(f); + return f; + }, + }); + + if (isLoading) { + return ( +
+
+
⚖️
+
Chargement…
+
+
+ ); + } + + // Si pas de foyer → setup + if (foyer === null) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..0e3e7ca --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,36 @@ +// src/api/client.ts +// Client HTTP de base pour les appels API + +const BASE_URL = import.meta.env.DEV ? '/api' : '/api'; + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'ApiError'; + } +} + +async function fetchApi(url: string, options?: RequestInit): Promise { + const res = await fetch(BASE_URL + url, { + headers: { 'Content-Type': 'application/json', ...options?.headers }, + ...options, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ erreur: res.statusText })); + throw new ApiError(res.status, body.erreur || res.statusText); + } + + return res.json() as Promise; +} + +export const api = { + get: (url: string) => fetchApi(url), + post: (url: string, data: unknown) => fetchApi(url, { + method: 'POST', body: JSON.stringify(data), + }), + put: (url: string, data: unknown) => fetchApi(url, { + method: 'PUT', body: JSON.stringify(data), + }), + delete: (url: string) => fetchApi(url, { method: 'DELETE' }), +}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..a4f5b3b --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,83 @@ +// src/api/index.ts +// Toutes les fonctions d'appel API regroupées + +import { api } from './client'; +import type { Foyer, Membre, Categorie, TacheRecurrente, Saisie, SaisieFormData, DashboardStats } from '../types'; + +// ─── Foyer ──────────────────────────────────────────────────────────────────── +export const foyerApi = { + get: () => api.get<{ foyer: Foyer | null }>('/foyer').then(r => r.foyer), + create: (nom: string) => api.post<{ foyer: Foyer }>('/foyer', { nom }).then(r => r.foyer), + update: (id: number, nom: string) => api.put<{ foyer: Foyer }>(`/foyer/${id}`, { nom }).then(r => r.foyer), +}; + +// ─── Membres ────────────────────────────────────────────────────────────────── +export const membresApi = { + list: () => api.get<{ membres: Membre[] }>('/membres').then(r => r.membres), + create: (data: Omit) => + api.post<{ membre: Membre }>('/membres', data).then(r => r.membre), + update: (id: number, data: Partial) => + api.put<{ membre: Membre }>(`/membres/${id}`, data).then(r => r.membre), + delete: (id: number) => api.delete<{ ok: boolean }>(`/membres/${id}`), +}; + +// ─── Catégories ─────────────────────────────────────────────────────────────── +export const categoriesApi = { + list: () => api.get<{ categories: Categorie[] }>('/categories').then(r => r.categories), + create: (data: Omit) => + api.post<{ categorie: Categorie }>('/categories', data).then(r => r.categorie), + update: (id: number, data: Partial) => + api.put<{ categorie: Categorie }>(`/categories/${id}`, data).then(r => r.categorie), + delete: (id: number) => api.delete<{ ok: boolean }>(`/categories/${id}`), +}; + +// ─── Tâches récurrentes ─────────────────────────────────────────────────────── +export const tachesApi = { + list: () => api.get<{ taches: TacheRecurrente[] }>('/taches').then(r => r.taches), + create: (data: Omit) => + api.post<{ tache: TacheRecurrente }>('/taches', data).then(r => r.tache), + update: (id: number, data: Partial) => + api.put<{ tache: TacheRecurrente }>(`/taches/${id}`, data).then(r => r.tache), + delete: (id: number) => api.delete<{ ok: boolean }>(`/taches/${id}`), +}; + +// ─── Saisies ────────────────────────────────────────────────────────────────── +export const saisiesApi = { + list: (params?: { + debut?: string; fin?: string; membre_id?: number; + categorie_id?: number; type?: string; page?: number; limit?: number; + }) => { + const qs = new URLSearchParams(); + if (params?.debut) qs.set('debut', params.debut); + if (params?.fin) qs.set('fin', params.fin); + if (params?.membre_id) qs.set('membre_id', String(params.membre_id)); + if (params?.categorie_id) qs.set('categorie_id', String(params.categorie_id)); + if (params?.type) qs.set('type', params.type); + if (params?.page) qs.set('page', String(params.page)); + if (params?.limit) qs.set('limit', String(params.limit)); + const q = qs.toString(); + return api.get<{ saisies: Saisie[]; total: number; page: number; limit: number }>(`/saisies${q ? '?' + q : ''}`); + }, + create: (data: SaisieFormData) => api.post<{ saisie: Saisie }>('/saisies', data).then(r => r.saisie), + batch: (saisiesData: SaisieFormData[]) => + api.post<{ ok: boolean; count: number }>('/saisies/batch', { saisiesData }), + update: (id: number, data: Partial) => + api.put<{ saisie: Saisie }>(`/saisies/${id}`, data).then(r => r.saisie), + delete: (id: number) => api.delete<{ ok: boolean }>(`/saisies/${id}`), +}; + +// ─── Dashboard ──────────────────────────────────────────────────────────────── +export const dashboardApi = { + stats: (params: { debut: string; fin: string; inclure_enfants?: boolean; categorie_id?: number }) => { + const qs = new URLSearchParams({ + debut: params.debut, + fin: params.fin, + inclure_enfants: String(params.inclure_enfants ?? false), + }); + if (params.categorie_id) qs.set('categorie_id', String(params.categorie_id)); + return api.get(`/saisies/stats?${qs.toString()}`); + }, + export: (format: 'json' | 'csv') => { + window.open(`/api/saisies/export?format=${format}`, '_blank'); + }, +}; diff --git a/frontend/src/components/ui/Layout.tsx b/frontend/src/components/ui/Layout.tsx new file mode 100644 index 0000000..fc961d4 --- /dev/null +++ b/frontend/src/components/ui/Layout.tsx @@ -0,0 +1,96 @@ +// src/components/ui/Layout.tsx +// Layout principal avec nav bottom (mobile) et nav top (desktop) + +import React from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { useApp } from '../../context/AppContext'; +import { useOfflineSync } from '../../hooks/useOfflineSync'; + +const NAV_ITEMS = [ + { path: '/saisie', label: 'Saisie', icon: '✏️' }, + { path: '/dashboard', label: 'Dashboard', icon: '📊' }, + { path: '/parametres', label: 'Réglages', icon: '⚙️' }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + const { membreActif, setMembreActif, isOnline, queueCount } = useApp(); + const navigate = useNavigate(); + useOfflineSync(); + + return ( +
+ {/* Header */} +
+
+ ⚖️ + EquiTask +
+
+ {/* Indicateur offline */} + {!isOnline && ( + + 📵 Offline + {queueCount > 0 && ({queueCount})} + + )} + {/* Membre actif */} + {membreActif && ( + + )} +
+
+ + {/* Contenu */} +
+ {children} +
+ + {/* Nav bottom (mobile) */} + + + {/* Nav top desktop (en supplément) */} + +
+ ); +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..7b74f62 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,39 @@ +// src/components/ui/Modal.tsx +// Composant modal réutilisable + +import React, { useEffect } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + maxWidth?: string; +} + +export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }: ModalProps) { + useEffect(() => { + if (isOpen) document.body.style.overflow = 'hidden'; + else document.body.style.overflow = ''; + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ {/* Contenu */} +
+ {title && ( +
+

{title}

+ +
+ )} + {children} +
+
+ ); +} diff --git a/frontend/src/components/ui/PenibiliteSelector.tsx b/frontend/src/components/ui/PenibiliteSelector.tsx new file mode 100644 index 0000000..01fe66d --- /dev/null +++ b/frontend/src/components/ui/PenibiliteSelector.tsx @@ -0,0 +1,36 @@ +// src/components/ui/PenibiliteSelector.tsx +// Sélecteur de coefficient de pénibilité (1-5) + +import React from 'react'; + +interface Props { + value: number; + onChange: (v: number) => void; + disabled?: boolean; +} + +const LABELS = ['', 'Facile', 'Léger', 'Modéré', 'Pénible', 'Épuisant']; +const COLORS = ['', 'bg-green-600', 'bg-lime-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600']; + +export function PenibiliteSelector({ value, onChange, disabled }: Props) { + return ( +
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/ui/ScoreBadge.tsx b/frontend/src/components/ui/ScoreBadge.tsx new file mode 100644 index 0000000..43b21ad --- /dev/null +++ b/frontend/src/components/ui/ScoreBadge.tsx @@ -0,0 +1,23 @@ +// src/components/ui/ScoreBadge.tsx +// Badge affichant un score avec mise en forme + +import React from 'react'; + +interface ScoreBadgeProps { + score: number; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function ScoreBadge({ score, size = 'md', className = '' }: ScoreBadgeProps) { + const sizes = { sm: 'text-xs px-2 py-0.5', md: 'text-sm px-3 py-1', lg: 'text-base px-4 py-1.5' }; + const color = score >= 200 ? 'bg-red-900/50 text-red-300 border-red-700' + : score >= 100 ? 'bg-orange-900/50 text-orange-300 border-orange-700' + : 'bg-indigo-900/50 text-indigo-300 border-indigo-700'; + + return ( + + ⚡ {score} pts + + ); +} diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 0000000..71f54b9 --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,39 @@ +// src/components/ui/Toast.tsx +// Composant toast notification + +import React from 'react'; +import type { Toast as ToastType } from '../../hooks/useToast'; + +interface ToastContainerProps { + toasts: ToastType[]; + onDismiss: (id: string) => void; +} + +const ICONS = { success: '✅', error: '❌', info: 'ℹ️' }; +const COLORS = { + success: 'bg-emerald-900 border-emerald-500 text-emerald-100', + error: 'bg-red-900 border-red-500 text-red-100', + info: 'bg-indigo-900 border-indigo-500 text-indigo-100', +}; + +export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { + return ( +
+ {toasts.map(toast => ( +
onDismiss(toast.id)} + > + {ICONS[toast.type]} +
+

{toast.message}

+ {toast.score !== undefined && ( +

+{toast.score} points

+ )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx new file mode 100644 index 0000000..e728d94 --- /dev/null +++ b/frontend/src/context/AppContext.tsx @@ -0,0 +1,61 @@ +// src/context/AppContext.tsx +// Contexte global : membre actif, état de connexion, foyer + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { Membre, Foyer } from '../types'; + +interface AppContextType { + /** Foyer courant (null si non configuré) */ + foyer: Foyer | null; + setFoyer: (f: Foyer | null) => void; + /** Membre actuellement connecté pour la saisie */ + membreActif: Membre | null; + setMembreActif: (m: Membre | null) => void; + /** Indicateur offline */ + isOnline: boolean; + /** Nombre de saisies en queue offline */ + queueCount: number; + setQueueCount: (n: number) => void; +} + +const AppContext = createContext(null); + +export function AppProvider({ children }: { children: React.ReactNode }) { + const [foyer, setFoyer] = useState(null); + const [membreActif, setMembreActifState] = useState(() => { + // Restaurer le membre actif depuis sessionStorage + const saved = sessionStorage.getItem('equitask_membre_actif'); + return saved ? JSON.parse(saved) : null; + }); + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [queueCount, setQueueCount] = useState(0); + + const setMembreActif = useCallback((m: Membre | null) => { + setMembreActifState(m); + if (m) sessionStorage.setItem('equitask_membre_actif', JSON.stringify(m)); + else sessionStorage.removeItem('equitask_membre_actif'); + }, []); + + useEffect(() => { + const onOnline = () => setIsOnline(true); + const onOffline = () => setIsOnline(false); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + return () => { + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useApp() { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('useApp doit être utilisé dans '); + return ctx; +} diff --git a/frontend/src/hooks/useOfflineSync.ts b/frontend/src/hooks/useOfflineSync.ts new file mode 100644 index 0000000..5ecb71e --- /dev/null +++ b/frontend/src/hooks/useOfflineSync.ts @@ -0,0 +1,58 @@ +// src/hooks/useOfflineSync.ts +// Déclenche la synchronisation des saisies offline dès que la connexion revient + +import { useEffect, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueue, dequeue, countQueue } from '../offline/queue'; +import { saisiesApi } from '../api'; +import { useApp } from '../context/AppContext'; + +export function useOfflineSync() { + const { isOnline, setQueueCount } = useApp(); + const queryClient = useQueryClient(); + + /** Synchronise la queue et met à jour le compteur */ + const syncQueue = useCallback(async () => { + const queue = await getQueue(); + setQueueCount(queue.length); + + if (!isOnline || queue.length === 0) return; + + console.log(`📡 Synchronisation de ${queue.length} saisie(s) offline...`); + let synced = 0; + + for (const item of queue) { + try { + const { offline_id, created_at, ...saisieData } = item; + await saisiesApi.create(saisieData); + await dequeue(offline_id); + synced++; + } catch (err) { + console.warn('Échec sync saisie:', err); + break; // Arrêter si erreur réseau + } + } + + if (synced > 0) { + console.log(`✅ ${synced} saisie(s) synchronisée(s)`); + queryClient.invalidateQueries({ queryKey: ['saisies'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + const remaining = await countQueue(); + setQueueCount(remaining); + } + }, [isOnline, setQueueCount, queryClient]); + + // Synchroniser quand la connexion revient + useEffect(() => { + if (isOnline) { + syncQueue(); + } + }, [isOnline, syncQueue]); + + // Mettre à jour le compteur au démarrage + useEffect(() => { + countQueue().then(setQueueCount); + }, [setQueueCount]); + + return { syncQueue }; +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..c6412cf --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,38 @@ +// src/hooks/useToast.ts +// Hook pour afficher des notifications toast + +import { useState, useCallback, useRef } from 'react'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'info'; + score?: number; +} + +export function useToast() { + const [toasts, setToasts] = useState([]); + const timerRef = useRef>>(new Map()); + + const showToast = useCallback((message: string, type: Toast['type'] = 'success', score?: number) => { + const id = Date.now().toString(36); + const toast: Toast = { id, message, type, score }; + + setToasts(prev => [...prev, toast]); + + // Suppression automatique après 3s + const timer = setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + timerRef.current.delete(id); + }, 3000); + timerRef.current.set(id, timer); + }, []); + + const dismissToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + const timer = timerRef.current.get(id); + if (timer) { clearTimeout(timer); timerRef.current.delete(id); } + }, []); + + return { toasts, showToast, dismissToast }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5c03761 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Masquer la scrollbar mais garder le scroll */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Smooth scroll */ +html { + scroll-behavior: smooth; +} + +/* Input color styling */ +input[type="color"] { + -webkit-appearance: none; + border: none; + padding: 0; +} +input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} +input[type="color"]::-webkit-color-swatch { + border: 2px solid rgba(255,255,255,0.2); + border-radius: 8px; +} + +/* Sélection datetime */ +::-webkit-calendar-picker-indicator { + filter: invert(1); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c1b7444 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +// src/main.tsx +// Point d'entrée de l'application React + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/offline/queue.ts b/frontend/src/offline/queue.ts new file mode 100644 index 0000000..38a3b41 --- /dev/null +++ b/frontend/src/offline/queue.ts @@ -0,0 +1,63 @@ +// src/offline/queue.ts +// Gestion de la queue offline via IndexedDB +// Les saisies créées sans connexion sont stockées ici et synchronisées dès que possible + +import { openDB, IDBPDatabase } from 'idb'; +import type { OfflineSaisie, SaisieFormData } from '../types'; + +const DB_NAME = 'equitask-offline'; +const STORE_NAME = 'saisies-queue'; +const DB_VERSION = 1; + +let dbInstance: IDBPDatabase | null = null; + +/** Ouvre la base IndexedDB */ +async function getDb(): Promise { + if (dbInstance) return dbInstance; + dbInstance = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'offline_id' }); + store.createIndex('created_at', 'created_at'); + } + }, + }); + return dbInstance; +} + +/** Génère un UUID simple */ +function uuid(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2); +} + +/** Ajoute une saisie à la queue offline */ +export async function enqueue(data: SaisieFormData): Promise { + const db = await getDb(); + const offline_id = uuid(); + await db.add(STORE_NAME, { ...data, offline_id, created_at: Date.now() } as OfflineSaisie); + return offline_id; +} + +/** Récupère toutes les saisies en attente */ +export async function getQueue(): Promise { + const db = await getDb(); + return db.getAll(STORE_NAME); +} + +/** Supprime une saisie de la queue (après sync réussie) */ +export async function dequeue(offline_id: string): Promise { + const db = await getDb(); + await db.delete(STORE_NAME, offline_id); +} + +/** Vide entièrement la queue */ +export async function clearQueue(): Promise { + const db = await getDb(); + await db.clear(STORE_NAME); +} + +/** Compte les saisies en attente */ +export async function countQueue(): Promise { + const db = await getDb(); + return db.count(STORE_NAME); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..d179518 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,416 @@ +// src/pages/Dashboard.tsx +// Dashboard de visualisation de la répartition des tâches + +import React, { useState, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, AreaChart, Area, Legend, +} from 'recharts'; +import { format, subDays, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from 'date-fns'; +import { fr } from 'date-fns/locale'; +import { dashboardApi, categoriesApi, membresApi, saisiesApi } from '../api'; +import type { PeriodSelection, PeriodKey, Saisie } from '../types'; + +// ─── Sélecteur de période ──────────────────────────────────────────────────── +function getPeriod(key: PeriodKey, custom?: { debut: string; fin: string }): PeriodSelection { + const today = new Date(); + const fmt = (d: Date) => format(d, 'yyyy-MM-dd'); + switch (key) { + case 'semaine': return { key, debut: fmt(startOfWeek(today, { weekStartsOn: 1 })), fin: fmt(endOfWeek(today, { weekStartsOn: 1 })) }; + case 'mois': return { key, debut: fmt(startOfMonth(today)), fin: fmt(endOfMonth(today)) }; + case '7jours': return { key, debut: fmt(subDays(today, 6)), fin: fmt(today) }; + case '30jours': return { key, debut: fmt(subDays(today, 29)), fin: fmt(today) }; + case 'personnalise': return { key, debut: custom?.debut || fmt(subDays(today, 29)), fin: custom?.fin || fmt(today) }; + default: return { key: '30jours', debut: fmt(subDays(today, 29)), fin: fmt(today) }; + } +} + +const PERIOD_LABELS: Record = { + semaine: 'Cette semaine', + mois: 'Ce mois', + '7jours': '7 derniers jours', + '30jours': '30 derniers jours', + personnalise: 'Personnalisé', +}; + +export function Dashboard() { + const [periodKey, setPeriodKey] = useState('30jours'); + const [customDebut, setCustomDebut] = useState(''); + const [customFin, setCustomFin] = useState(''); + const [inclureEnfants, setInclureEnfants] = useState(false); + const [categorieFiltre, setCategorieFiltre] = useState(); + const [histoPage, setHistoPage] = useState(1); + + const period = useMemo(() => getPeriod(periodKey, { debut: customDebut, fin: customFin }), [periodKey, customDebut, customFin]); + + // Charger les stats + const { data: stats, isLoading: loadingStats } = useQuery({ + queryKey: ['dashboard', period.debut, period.fin, inclureEnfants, categorieFiltre], + queryFn: () => dashboardApi.stats({ debut: period.debut, fin: period.fin, inclure_enfants: inclureEnfants, categorie_id: categorieFiltre }), + }); + + // Charger les catégories pour le filtre + const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list }); + + // Charger l'historique + const { data: historiqueData } = useQuery({ + queryKey: ['saisies-historique', period.debut, period.fin, histoPage, categorieFiltre], + queryFn: () => saisiesApi.list({ debut: period.debut, fin: period.fin, categorie_id: categorieFiltre, page: histoPage, limit: 20 }), + }); + + return ( +
+ + {/* ─── Filtres ─── */} +
+ {/* Sélecteur période */} +
+ {(Object.keys(PERIOD_LABELS) as PeriodKey[]).map(k => ( + + ))} +
+ + {/* Dates perso */} + {periodKey === 'personnalise' && ( +
+ setCustomDebut(e.target.value)} + className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-white text-xs focus:outline-none" /> + + setCustomFin(e.target.value)} + className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-white text-xs focus:outline-none" /> +
+ )} + + {/* Filtre catégorie */} + + + {/* Toggle enfants */} + + + {/* Export */} +
+ + +
+
+ + {loadingStats ? ( +
+ {[1, 2, 3, 4].map(i =>
)} +
+ ) : stats ? ( +
+ + {/* ─── Ligne 1 : Score total + Indicateur équilibre ─── */} +
+ + {/* Carte scores par membre */} +
+

Score cumulé

+ {stats.scores_par_membre.length === 0 ? ( +

Aucune donnée sur cette période

+ ) : ( +
+ {stats.scores_par_membre.map(m => ( +
+
+
+
+ {m.nom} + {m.role} +
+
+ {m.score_total.toLocaleString()} pts + {m.pourcentage}% +
+
+
+
+
+
+ ))} +
+ )} +
+ + {/* Indicateur d'équilibre */} +
+

Équilibre du couple

+ {!stats.indicateur_equilibre ? ( +

Nécessite 2 adultes

+ ) : ( + + )} +
+
+ + {/* ─── Répartition par catégorie ─── */} + {stats.scores_par_categorie.length > 0 && ( +
+

Répartition par catégorie

+
+ + ({ + name: `${cat.icone} ${cat.nom}`, + ...Object.fromEntries(cat.scores_membres.map(m => [m.nom, m.score])), + }))} + margin={{ top: 5, right: 10, left: 0, bottom: 60 }} + > + + + + + + {stats.scores_par_membre.map(m => ( + + ))} + + +
+
+ )} + + {/* ─── Évolution temporelle ─── */} + {stats.evolution_temporelle.length > 0 && ( +
+

Évolution sur la période

+
+ + ({ + date: format(new Date(pt.date), 'd MMM', { locale: fr }), + ...Object.fromEntries(pt.scores.map(s => [s.nom, s.score])), + }))} + margin={{ top: 5, right: 10, left: 0, bottom: 5 }} + > + + + + + + {stats.scores_par_membre.map((m, i) => ( + + ))} + + +
+
+ )} + + {/* ─── Tableau historique ─── */} +
+

Historique des saisies

+ +
+ +
+ ) : ( +
+

Aucune donnée disponible

+

Commencez par enregistrer des tâches dans l'onglet Saisie

+
+ )} +
+ ); +} + +// ─── Indicateur d'équilibre ───────────────────────────────────────────────── +function IndicateurEquilibre({ indicateur }: { indicateur: NonNullable }) { + const couleurs = { vert: '#22c55e', orange: '#f97316', rouge: '#ef4444' }; + const labels = { vert: '🟢 Équilibré', orange: '🟡 Léger déséquilibre', rouge: '🔴 Déséquilibre important' }; + const color = couleurs[indicateur.statut]; + + return ( +
+ {/* Statut */} +
+ {labels[indicateur.statut]} + {indicateur.ecart_pct}% d'écart +
+ + {/* Barre */} + {indicateur.adulte1 && indicateur.adulte2 && ( + <> +
+
+ {indicateur.adulte1.pourcentage > 15 && `${indicateur.adulte1.pourcentage}%`} +
+
+ {indicateur.adulte2.pourcentage > 15 && `${indicateur.adulte2.pourcentage}%`} +
+
+ {/* Légende */} +
+
+
+ {indicateur.adulte1.nom} + {indicateur.adulte1.score.toLocaleString()} pts +
+
+ {indicateur.adulte2.score.toLocaleString()} pts + {indicateur.adulte2.nom} +
+
+
+ + )} +
+ ); +} + +// ─── Tableau historique ────────────────────────────────────────────────────── +function TableauHistorique({ + saisies, total, page, limit, onPageChange, +}: { + saisies: Saisie[]; + total: number; + page: number; + limit: number; + onPageChange: (p: number) => void; +}) { + const [editId, setEditId] = useState(null); + const totalPages = Math.ceil(total / limit); + + if (saisies.length === 0) { + return

Aucune saisie sur cette période

; + } + + return ( +
+ {/* Table scroll */} +
+ + + + {['Date', 'Membre', 'Tâche', 'Catégorie', 'Durée', 'Péni.', 'Score', 'Notes', ''].map(h => ( + + ))} + + + + {saisies.map(s => ( + + + + + + + + + + + + ))} + +
{h}
+ {s.date_heure ? format(new Date(s.date_heure), 'd MMM HH:mm', { locale: fr }) : '—'} + +
+
+ {s.membre_nom} +
+
+ {s.tache_nom || s.nom_tache_oneshot} + {s.nom_tache_oneshot && ponct.} + + {s.categorie_icone} {s.categorie_nom} + + {s.duree_reelle_min || '—'} min + + + {s.coefficient_penibilite} + + + {s.score_final} + + {s.notes} + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ {total} saisies au total +
+ + {page} / {totalPages} + +
+
+ )} +
+ ); +} + +function SaisieActions({ saisieId }: { saisieId: number }) { + const queryClient = useQueryClient(); + const handleDelete = async () => { + if (!confirm('Supprimer cette saisie ?')) return; + await saisiesApi.delete(saisieId); + queryClient.invalidateQueries({ queryKey: ['saisies-historique'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }; + return ( + + ); +} diff --git a/frontend/src/pages/Parametres.tsx b/frontend/src/pages/Parametres.tsx new file mode 100644 index 0000000..f896712 --- /dev/null +++ b/frontend/src/pages/Parametres.tsx @@ -0,0 +1,383 @@ +// src/pages/Parametres.tsx +// Page de configuration : membres, catégories, tâches récurrentes, export + +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { membresApi, categoriesApi, tachesApi, dashboardApi } from '../api'; +import { PenibiliteSelector } from '../components/ui/PenibiliteSelector'; +import type { Membre, Categorie, TacheRecurrente } from '../types'; + +type Onglet = 'membres' | 'categories' | 'taches' | 'export'; + +export function Parametres() { + const [onglet, setOnglet] = useState('membres'); + + const ONGLETS: { id: Onglet; label: string; icon: string }[] = [ + { id: 'membres', label: 'Membres', icon: '👥' }, + { id: 'categories', label: 'Catégories', icon: '📂' }, + { id: 'taches', label: 'Tâches', icon: '📋' }, + { id: 'export', label: 'Export', icon: '↓' }, + ]; + + return ( +
+

Paramètres

+ + {/* Tabs */} +
+ {ONGLETS.map(o => ( + + ))} +
+ + {onglet === 'membres' && } + {onglet === 'categories' && } + {onglet === 'taches' && } + {onglet === 'export' && } +
+ ); +} + +// ─── Gestion des membres ────────────────────────────────────────────────────── +function GestionMembres() { + const queryClient = useQueryClient(); + const { data: membres = [] } = useQuery({ queryKey: ['membres'], queryFn: membresApi.list }); + const [editId, setEditId] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ nom: '', role: 'adulte' as 'adulte' | 'enfant', couleur: '#3b82f6' }); + + const mutation = useMutation({ + mutationFn: (data: { id?: number; nom: string; role: string; couleur: string }) => + data.id ? membresApi.update(data.id, data) : membresApi.create({ ...data, actif: 1, ordre: membres.length }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['membres'] }); setShowAdd(false); setEditId(null); }, + }); + + const deleteMutation = useMutation({ + mutationFn: membresApi.delete, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['membres'] }), + }); + + return ( +
+ {membres.map(m => ( +
+ {editId === m.id ? ( + mutation.mutate({ id: m.id, ...data })} + onCancel={() => setEditId(null)} + /> + ) : ( + <> +
+ {m.nom[0].toUpperCase()} +
+
+

{m.nom}

+

{m.role}

+
+ + + + )} +
+ ))} + + {showAdd ? ( +
+ mutation.mutate(data)} + onCancel={() => setShowAdd(false)} + /> +
+ ) : membres.length < 7 ? ( + + ) : null} +
+ ); +} + +function MemberForm({ initial, onSave, onCancel }: { + initial: { nom: string; role: string; couleur: string }; + onSave: (data: { nom: string; role: 'adulte' | 'enfant'; couleur: string }) => void; + onCancel: () => void; +}) { + const [nom, setNom] = useState(initial.nom); + const [role, setRole] = useState<'adulte' | 'enfant'>(initial.role as 'adulte' | 'enfant'); + const [couleur, setCouleur] = useState(initial.couleur); + + return ( +
+ setCouleur(e.target.value)} + className="w-10 h-10 rounded-lg cursor-pointer" /> + setNom(e.target.value)} placeholder="Prénom" + className="flex-1 min-w-32 bg-slate-800 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none" /> + + + +
+ ); +} + +// ─── Gestion des catégories ─────────────────────────────────────────────────── +function GestionCategories() { + const queryClient = useQueryClient(); + const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list }); + const [editId, setEditId] = useState(null); + const [showAdd, setShowAdd] = useState(false); + + const mutation = useMutation({ + mutationFn: (data: { id?: number; nom: string; icone: string; couleur: string }) => + data.id ? categoriesApi.update(data.id, data) : categoriesApi.create({ ...data, ordre: categories.length, actif: 1 }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); setShowAdd(false); setEditId(null); }, + }); + + const deleteMutation = useMutation({ + mutationFn: categoriesApi.delete, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['categories'] }), + }); + + return ( +
+ {categories.map(cat => ( +
+ {editId === cat.id ? ( + mutation.mutate({ id: cat.id, ...data })} + onCancel={() => setEditId(null)} + /> + ) : ( + <> + {cat.icone} +
+ {cat.nom} + + + + )} +
+ ))} + + {showAdd ? ( +
+ mutation.mutate(data)} + onCancel={() => setShowAdd(false)} /> +
+ ) : ( + + )} +
+ ); +} + +function CatForm({ initial, onSave, onCancel }: { + initial: { nom: string; icone: string; couleur: string }; + onSave: (data: { nom: string; icone: string; couleur: string }) => void; + onCancel: () => void; +}) { + const [nom, setNom] = useState(initial.nom); + const [icone, setIcone] = useState(initial.icone); + const [couleur, setCouleur] = useState(initial.couleur); + + return ( +
+ setIcone(e.target.value)} placeholder="📦" + className="w-12 bg-slate-800 rounded-lg px-2 py-2 text-center text-xl border border-slate-600 focus:outline-none" /> + setCouleur(e.target.value)} className="w-10 h-10 rounded-lg cursor-pointer" /> + setNom(e.target.value)} placeholder="Nom de la catégorie" + className="flex-1 min-w-32 bg-slate-800 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none" /> + + +
+ ); +} + +// ─── Gestion des tâches ─────────────────────────────────────────────────────── +function GestionTaches() { + const queryClient = useQueryClient(); + const { data: taches = [] } = useQuery({ queryKey: ['taches'], queryFn: tachesApi.list }); + const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list }); + const [editId, setEditId] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [filtreCategorie, setFiltreCategorie] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { id?: number; nom: string; categorie_id: number; duree_moyenne_min: number; coefficient_penibilite: number }) => + data.id + ? tachesApi.update(data.id, data) + : tachesApi.create({ ...data, actif: 1 }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['taches'] }); setShowAdd(false); setEditId(null); }, + }); + + const deleteMutation = useMutation({ + mutationFn: tachesApi.delete, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['taches'] }), + }); + + const tachesFiltrees = filtreCategorie ? taches.filter(t => t.categorie_id === filtreCategorie) : taches; + + return ( +
+ {/* Filtre catégorie */} +
+ + {categories.map(cat => ( + + ))} +
+ +
+ {tachesFiltrees.map(tache => ( +
+ {editId === tache.id ? ( + mutation.mutate({ id: tache.id, ...data })} + onCancel={() => setEditId(null)} + /> + ) : ( +
+
+ + {tache.categorie_icone} {tache.categorie_nom} + +

{tache.nom}

+
+
+ {tache.duree_moyenne_min} min + x{tache.coefficient_penibilite} + + {tache.score_calcule} pts + + + +
+
+ )} +
+ ))} + + {showAdd ? ( +
+ mutation.mutate(data)} + onCancel={() => setShowAdd(false)} + /> +
+ ) : ( + + )} +
+
+ ); +} + +function TacheForm({ initial, categories, onSave, onCancel }: { + initial: { nom: string; categorie_id: number; duree: number; coef: number }; + categories: Categorie[]; + onSave: (data: { nom: string; categorie_id: number; duree_moyenne_min: number; coefficient_penibilite: number }) => void; + onCancel: () => void; +}) { + const [nom, setNom] = useState(initial.nom); + const [categorieId, setCategorieId] = useState(initial.categorie_id); + const [duree, setDuree] = useState(initial.duree); + const [coef, setCoef] = useState(initial.coef); + + return ( +
+ setNom(e.target.value)} placeholder="Nom de la tâche *" + className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white text-sm focus:outline-none focus:border-indigo-500" /> +
+
+ + +
+
+ + setDuree(parseInt(e.target.value) || 1)} + className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-white text-sm focus:outline-none" /> +
+
+
+ + +
+
+ + +
+
+ ); +} + +// ─── Export ─────────────────────────────────────────────────────────────────── +function ExportSection() { + return ( +
+
+

Exporter les données

+

Téléchargez toutes les saisies de votre foyer.

+
+ + +
+

+ CSV recommandé pour Excel / Sheets · JSON pour usage programmatique +

+
+
+ ); +} diff --git a/frontend/src/pages/Saisie.tsx b/frontend/src/pages/Saisie.tsx new file mode 100644 index 0000000..4667117 --- /dev/null +++ b/frontend/src/pages/Saisie.tsx @@ -0,0 +1,490 @@ +// src/pages/Saisie.tsx +// Page principale de saisie des tâches effectuées + +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import { fr } from 'date-fns/locale'; +import { tachesApi, saisiesApi } from '../api'; +import { useApp } from '../context/AppContext'; +import { useToast } from '../hooks/useToast'; +import { enqueue, countQueue } from '../offline/queue'; +import { ToastContainer } from '../components/ui/Toast'; +import { Modal } from '../components/ui/Modal'; +import { PenibiliteSelector } from '../components/ui/PenibiliteSelector'; +import { ScoreBadge } from '../components/ui/ScoreBadge'; +import type { TacheRecurrente, SaisieFormData } from '../types'; + +export function Saisie() { + const navigate = useNavigate(); + const { membreActif, isOnline, setQueueCount } = useApp(); + const { toasts, showToast, dismissToast } = useToast(); + const queryClient = useQueryClient(); + + // Modal états + const [modalTache, setModalTache] = useState(null); + const [modalOneshot, setModalOneshot] = useState(false); + const [categorieActive, setCategorieActive] = useState(null); + + // Redirection si pas de membre actif + React.useEffect(() => { + if (!membreActif) navigate('/'); + }, [membreActif, navigate]); + + // Charger les tâches + const { data: taches = [], isLoading } = useQuery({ + queryKey: ['taches'], + queryFn: tachesApi.list, + }); + + // Grouper par catégorie + const tachesParCategorie = useMemo(() => { + const map = new Map(); + for (const t of taches) { + if (!map.has(t.categorie_id)) { + map.set(t.categorie_id, { + categorie_id: t.categorie_id, + nom: t.categorie_nom || '', + icone: t.categorie_icone || '📦', + couleur: t.categorie_couleur || '#6b7280', + taches: [], + }); + } + map.get(t.categorie_id)!.taches.push(t); + } + return Array.from(map.values()); + }, [taches]); + + // Catégorie active par défaut + React.useEffect(() => { + if (tachesParCategorie.length > 0 && categorieActive === null) { + setCategorieActive(tachesParCategorie[0].categorie_id); + } + }, [tachesParCategorie, categorieActive]); + + // Mutation création saisie + const mutation = useMutation({ + mutationFn: (data: SaisieFormData) => { + if (isOnline) return saisiesApi.create(data); + return enqueue(data).then(id => { + countQueue().then(n => setQueueCount(n)); + return { id: -1, ...data, cree_le: new Date().toISOString() } as any; + }); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['saisies'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + showToast( + isOnline ? 'Tâche enregistrée !' : 'Enregistré hors ligne', + 'success', + variables.score_final + ); + }, + onError: () => showToast('Erreur lors de l\'enregistrement', 'error'), + }); + + const soumettreSaisie = (data: SaisieFormData) => { + mutation.mutate(data); + setModalTache(null); + setModalOneshot(false); + }; + + if (!membreActif) return null; + + return ( +
+ + + {/* Header membre */} +
+
+
+
+ {membreActif.nom[0].toUpperCase()} +
+
+

{membreActif.nom}

+

{format(new Date(), 'EEEE d MMMM', { locale: fr })}

+
+
+ {/* Bouton tâche ponctuelle */} + +
+
+ +
+ {/* Tabs catégories */} +
+ {tachesParCategorie.map(cat => ( + + ))} +
+ + {/* Grille de tâches */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : ( + tachesParCategorie + .filter(cat => cat.categorie_id === categorieActive) + .map(cat => ( +
+ {cat.taches.map(tache => ( + setModalTache(tache)} + /> + ))} +
+ )) + )} +
+ + {/* Modal confirmation tâche récurrente */} + setModalTache(null)} title="Confirmer la saisie"> + {modalTache && ( + setModalTache(null)} + /> + )} + + + {/* Modal tâche ponctuelle */} + setModalOneshot(false)} title="Tâche ponctuelle"> + setModalOneshot(false)} + onAddToCatalogue={async (nom, cat_id, duree, coef) => { + await tachesApi.create({ nom, categorie_id: cat_id, duree_moyenne_min: duree, coefficient_penibilite: coef, actif: 1 }); + queryClient.invalidateQueries({ queryKey: ['taches'] }); + showToast('Ajouté au catalogue !', 'info'); + }} + /> + +
+ ); +} + +// ─── Carte tâche ───────────────────────────────────────────────────────────── +function TacheCard({ tache, couleurCat, onClick }: { tache: TacheRecurrente; couleurCat: string; onClick: () => void }) { + return ( + + ); +} + +// ─── Modal confirmation tâche récurrente ───────────────────────────────────── +function ModalConfirmTache({ + tache, membreId, onConfirm, onClose, +}: { + tache: TacheRecurrente; + membreId: number; + onConfirm: (data: SaisieFormData) => void; + onClose: () => void; +}) { + const [override, setOverride] = useState(false); + const [dureeOverride, setDureeOverride] = useState(tache.duree_moyenne_min); + const [coefOverride, setCoefOverride] = useState(tache.coefficient_penibilite); + const [notes, setNotes] = useState(''); + const [dateHeure, setDateHeure] = useState( + new Date().toISOString().slice(0, 16) + ); + + const dureeFinale = override ? dureeOverride : tache.duree_moyenne_min; + const coefFinal = override ? coefOverride : tache.coefficient_penibilite; + const scoreCalcule = dureeFinale * coefFinal; + + const handleConfirm = () => { + onConfirm({ + tache_recurrente_id: tache.id, + categorie_id: tache.categorie_id, + membre_id: membreId, + date_heure: dateHeure.replace('T', ' ') + ':00', + duree_reelle_min: override ? dureeOverride : undefined, + coefficient_penibilite: coefFinal, + score_final: scoreCalcule, + notes: notes || undefined, + }); + }; + + return ( +
+

{tache.nom}

+ + {/* Score preview */} +
+
+

Durée

+

{dureeFinale} min

+
+ × +
+

Pénibilité

+

{coefFinal}

+
+ = +
+

Score

+

{scoreCalcule} pts

+
+
+ + {/* Date/heure */} +
+ + setDateHeure(e.target.value)} + className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" + /> +
+ + {/* Override optionnel */} + + + {override && ( +
+
+ + setDureeOverride(parseInt(e.target.value) || 1)} + className="w-full bg-slate-700 rounded-lg px-3 py-2 text-white text-sm border border-slate-600 focus:outline-none focus:border-indigo-500" + /> +
+
+ + +
+
+ )} + + {/* Notes */} +