deploy: equitask — 2026-04-28 19:51:14
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
*/node_modules
|
||||||
|
*/dist
|
||||||
|
*/.vite
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
+32
@@ -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/
|
||||||
+67
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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é');
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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;
|
||||||
@@ -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;
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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;
|
||||||
@@ -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<string, string>;
|
||||||
|
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<number>`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<string, unknown> = {};
|
||||||
|
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<string, string>;
|
||||||
|
|
||||||
|
// 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<number>`COALESCE(SUM(${saisies.score_final}), 0)`,
|
||||||
|
nb_saisies: sql<number>`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<number>`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<number, any>();
|
||||||
|
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<string>`date(${saisies.date_heure})`,
|
||||||
|
membre_id: saisies.membre_id,
|
||||||
|
score_jour: sql<number>`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<string, Map<number, number>>();
|
||||||
|
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<string>`COALESCE(${tachesRecurrentes.nom}, ${saisies.nom_tache_oneshot})`,
|
||||||
|
categorie: categories.nom,
|
||||||
|
type: sql<string>`CASE WHEN ${saisies.tache_recurrente_id} IS NOT NULL THEN 'recurrente' ELSE 'oneshot' END`,
|
||||||
|
duree: sql<number>`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;
|
||||||
@@ -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<string, unknown> = {};
|
||||||
|
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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#1e1b4b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="description" content="Mesurez la répartition des tâches domestiques dans votre foyer" />
|
||||||
|
<title>EquiTask</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
��1�Y�
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-4">⚖️</div>
|
||||||
|
<div className="text-slate-400 animate-pulse">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas de foyer → setup
|
||||||
|
if (foyer === null) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<Setup />} />
|
||||||
|
<Route path="*" element={<Setup />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<SelectionProfil />} />
|
||||||
|
<Route path="/saisie" element={<Layout><Saisie /></Layout>} />
|
||||||
|
<Route path="/dashboard" element={<Layout><Dashboard /></Layout>} />
|
||||||
|
<Route path="/parametres" element={<Layout><Parametres /></Layout>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AppProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AppProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string) => fetchApi<T>(url),
|
||||||
|
post: <T>(url: string, data: unknown) => fetchApi<T>(url, {
|
||||||
|
method: 'POST', body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
put: <T>(url: string, data: unknown) => fetchApi<T>(url, {
|
||||||
|
method: 'PUT', body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
delete: <T>(url: string) => fetchApi<T>(url, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
@@ -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<Membre, 'id' | 'cree_le'>) =>
|
||||||
|
api.post<{ membre: Membre }>('/membres', data).then(r => r.membre),
|
||||||
|
update: (id: number, data: Partial<Membre>) =>
|
||||||
|
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<Categorie, 'id'>) =>
|
||||||
|
api.post<{ categorie: Categorie }>('/categories', data).then(r => r.categorie),
|
||||||
|
update: (id: number, data: Partial<Categorie>) =>
|
||||||
|
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<TacheRecurrente, 'id' | 'score_calcule'>) =>
|
||||||
|
api.post<{ tache: TacheRecurrente }>('/taches', data).then(r => r.tache),
|
||||||
|
update: (id: number, data: Partial<TacheRecurrente>) =>
|
||||||
|
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<Saisie>) =>
|
||||||
|
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<DashboardStats>(`/saisies/stats?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
export: (format: 'json' | 'csv') => {
|
||||||
|
window.open(`/api/saisies/export?format=${format}`, '_blank');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-white flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-slate-900 border-b border-slate-800 px-4 py-3 flex items-center justify-between sticky top-0 z-30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">⚖️</span>
|
||||||
|
<span className="font-bold text-white tracking-tight">EquiTask</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Indicateur offline */}
|
||||||
|
{!isOnline && (
|
||||||
|
<span className="flex items-center gap-1 text-xs bg-amber-900/50 text-amber-300 border border-amber-700 px-2 py-1 rounded-full">
|
||||||
|
📵 Offline
|
||||||
|
{queueCount > 0 && <span className="font-bold">({queueCount})</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Membre actif */}
|
||||||
|
{membreActif && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMembreActif(null); navigate('/'); }}
|
||||||
|
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 rounded-full px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{ backgroundColor: membreActif.couleur }}
|
||||||
|
>
|
||||||
|
{membreActif.nom[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-300 hidden sm:block">{membreActif.nom}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<main className="flex-1 pb-20 sm:pb-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Nav bottom (mobile) */}
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 bg-slate-900 border-t border-slate-800 flex sm:hidden z-30">
|
||||||
|
{NAV_ITEMS.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex-1 flex flex-col items-center py-2.5 gap-0.5 text-xs transition-colors ${
|
||||||
|
isActive ? 'text-indigo-400' : 'text-slate-500 hover:text-slate-300'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Nav top desktop (en supplément) */}
|
||||||
|
<nav className="hidden sm:flex fixed top-[57px] left-0 right-0 bg-slate-900/80 backdrop-blur border-b border-slate-800 justify-center gap-1 px-4 z-20">
|
||||||
|
{NAV_ITEMS.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 px-4 py-2 text-sm rounded-lg m-1 transition-colors ${
|
||||||
|
isActive ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className={`relative w-full ${maxWidth} bg-slate-900 rounded-t-2xl sm:rounded-2xl shadow-2xl border border-slate-700 animate-slide-up max-h-[90vh] overflow-y-auto`}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700">
|
||||||
|
<h2 className="font-semibold text-white text-lg">{title}</h2>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-white text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[1, 2, 3, 4, 5].map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(n)}
|
||||||
|
title={LABELS[n]}
|
||||||
|
className={`flex-1 h-10 rounded-lg font-bold text-sm transition-all ${
|
||||||
|
value === n
|
||||||
|
? `${COLORS[n]} text-white scale-105 shadow-lg`
|
||||||
|
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span className={`inline-flex items-center rounded-full border font-bold ${sizes[size]} ${color} ${className}`}>
|
||||||
|
⚡ {score} pts
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-xl pointer-events-auto animate-slide-up ${COLORS[toast.type]}`}
|
||||||
|
onClick={() => onDismiss(toast.id)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{ICONS[toast.type]}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{toast.message}</p>
|
||||||
|
{toast.score !== undefined && (
|
||||||
|
<p className="text-xs opacity-75 mt-0.5">+{toast.score} points</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AppContextType | null>(null);
|
||||||
|
|
||||||
|
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [foyer, setFoyer] = useState<Foyer | null>(null);
|
||||||
|
const [membreActif, setMembreActifState] = useState<Membre | null>(() => {
|
||||||
|
// 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 (
|
||||||
|
<AppContext.Provider value={{ foyer, setFoyer, membreActif, setMembreActif, isOnline, queueCount, setQueueCount }}>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApp() {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
if (!ctx) throw new Error('useApp doit être utilisé dans <AppProvider>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<Toast[]>([]);
|
||||||
|
const timerRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -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<IDBPDatabase> {
|
||||||
|
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<string> {
|
||||||
|
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<OfflineSaisie[]> {
|
||||||
|
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<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.delete(STORE_NAME, offline_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vide entièrement la queue */
|
||||||
|
export async function clearQueue(): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.clear(STORE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte les saisies en attente */
|
||||||
|
export async function countQueue(): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.count(STORE_NAME);
|
||||||
|
}
|
||||||
@@ -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<PeriodKey, string> = {
|
||||||
|
semaine: 'Cette semaine',
|
||||||
|
mois: 'Ce mois',
|
||||||
|
'7jours': '7 derniers jours',
|
||||||
|
'30jours': '30 derniers jours',
|
||||||
|
personnalise: 'Personnalisé',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [periodKey, setPeriodKey] = useState<PeriodKey>('30jours');
|
||||||
|
const [customDebut, setCustomDebut] = useState('');
|
||||||
|
const [customFin, setCustomFin] = useState('');
|
||||||
|
const [inclureEnfants, setInclureEnfants] = useState(false);
|
||||||
|
const [categorieFiltre, setCategorieFiltre] = useState<number | undefined>();
|
||||||
|
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 (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-6 sm:pt-16">
|
||||||
|
|
||||||
|
{/* ─── Filtres ─── */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
|
{/* Sélecteur période */}
|
||||||
|
<div className="flex gap-1 bg-slate-800 rounded-xl p-1 flex-wrap">
|
||||||
|
{(Object.keys(PERIOD_LABELS) as PeriodKey[]).map(k => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setPeriodKey(k)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
periodKey === k ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PERIOD_LABELS[k]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates perso */}
|
||||||
|
{periodKey === 'personnalise' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="date" value={customDebut} onChange={e => 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" />
|
||||||
|
<span className="text-slate-500 text-xs">→</span>
|
||||||
|
<input type="date" value={customFin} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filtre catégorie */}
|
||||||
|
<select
|
||||||
|
value={categorieFiltre ?? ''}
|
||||||
|
onChange={e => setCategorieFiltre(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-sm text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Toutes catégories</option>
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.icone} {c.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Toggle enfants */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer bg-slate-800 rounded-xl px-3 py-2 text-sm text-slate-400">
|
||||||
|
<input type="checkbox" checked={inclureEnfants} onChange={e => setInclureEnfants(e.target.checked)}
|
||||||
|
className="accent-indigo-500" />
|
||||||
|
Inclure enfants
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
<button onClick={() => dashboardApi.export('csv')}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white text-xs px-3 py-2 rounded-xl transition-colors">
|
||||||
|
↓ CSV
|
||||||
|
</button>
|
||||||
|
<button onClick={() => dashboardApi.export('json')}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white text-xs px-3 py-2 rounded-xl transition-colors">
|
||||||
|
↓ JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingStats ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => <div key={i} className="h-48 bg-slate-800 rounded-2xl animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* ─── Ligne 1 : Score total + Indicateur équilibre ─── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* Carte scores par membre */}
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||||
|
<h2 className="font-semibold text-white mb-4">Score cumulé</h2>
|
||||||
|
{stats.scores_par_membre.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-4">Aucune donnée sur cette période</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.scores_par_membre.map(m => (
|
||||||
|
<div key={m.membre_id}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: m.couleur }} />
|
||||||
|
<span className="text-sm text-white font-medium">{m.nom}</span>
|
||||||
|
<span className="text-xs text-slate-500">{m.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-sm text-white font-bold">{m.score_total.toLocaleString()} pts</span>
|
||||||
|
<span className="text-xs text-slate-400 ml-2">{m.pourcentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-700 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className="h-2.5 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${m.pourcentage}%`, backgroundColor: m.couleur }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicateur d'équilibre */}
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||||
|
<h2 className="font-semibold text-white mb-4">Équilibre du couple</h2>
|
||||||
|
{!stats.indicateur_equilibre ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-4">Nécessite 2 adultes</p>
|
||||||
|
) : (
|
||||||
|
<IndicateurEquilibre indicateur={stats.indicateur_equilibre} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Répartition par catégorie ─── */}
|
||||||
|
{stats.scores_par_categorie.length > 0 && (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||||
|
<h2 className="font-semibold text-white mb-4">Répartition par catégorie</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={stats.scores_par_categorie.map(cat => ({
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} angle={-35} textAnchor="end" />
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||||
|
<Legend wrapperStyle={{ paddingTop: 8, color: '#94a3b8' }} />
|
||||||
|
{stats.scores_par_membre.map(m => (
|
||||||
|
<Bar key={m.membre_id} dataKey={m.nom} fill={m.couleur} radius={[4, 4, 0, 0]} />
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Évolution temporelle ─── */}
|
||||||
|
{stats.evolution_temporelle.length > 0 && (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||||
|
<h2 className="font-semibold text-white mb-4">Évolution sur la période</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={stats.evolution_temporelle.map(pt => ({
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||||
|
<Legend wrapperStyle={{ color: '#94a3b8' }} />
|
||||||
|
{stats.scores_par_membre.map((m, i) => (
|
||||||
|
<Area
|
||||||
|
key={m.membre_id}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={m.nom}
|
||||||
|
stroke={m.couleur}
|
||||||
|
fill={m.couleur}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Tableau historique ─── */}
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-5">
|
||||||
|
<h2 className="font-semibold text-white mb-4">Historique des saisies</h2>
|
||||||
|
<TableauHistorique
|
||||||
|
saisies={historiqueData?.saisies || []}
|
||||||
|
total={historiqueData?.total || 0}
|
||||||
|
page={histoPage}
|
||||||
|
limit={20}
|
||||||
|
onPageChange={setHistoPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-slate-500">
|
||||||
|
<p className="text-lg">Aucune donnée disponible</p>
|
||||||
|
<p className="text-sm mt-1">Commencez par enregistrer des tâches dans l'onglet Saisie</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Indicateur d'équilibre ─────────────────────────────────────────────────
|
||||||
|
function IndicateurEquilibre({ indicateur }: { indicateur: NonNullable<import('../types').DashboardStats['indicateur_equilibre']> }) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Statut */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{labels[indicateur.statut]}</span>
|
||||||
|
<span className="font-bold text-xl" style={{ color }}>{indicateur.ecart_pct}% d'écart</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre */}
|
||||||
|
{indicateur.adulte1 && indicateur.adulte2 && (
|
||||||
|
<>
|
||||||
|
<div className="flex rounded-full overflow-hidden h-6">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-xs font-bold text-white transition-all"
|
||||||
|
style={{ width: `${indicateur.adulte1.pourcentage}%`, backgroundColor: indicateur.adulte1.couleur }}
|
||||||
|
>
|
||||||
|
{indicateur.adulte1.pourcentage > 15 && `${indicateur.adulte1.pourcentage}%`}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-xs font-bold text-white transition-all"
|
||||||
|
style={{ width: `${indicateur.adulte2.pourcentage}%`, backgroundColor: indicateur.adulte2.couleur }}
|
||||||
|
>
|
||||||
|
{indicateur.adulte2.pourcentage > 15 && `${indicateur.adulte2.pourcentage}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Légende */}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: indicateur.adulte1.couleur }} />
|
||||||
|
<span className="text-slate-300">{indicateur.adulte1.nom}</span>
|
||||||
|
<span className="text-slate-500">{indicateur.adulte1.score.toLocaleString()} pts</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-500">{indicateur.adulte2.score.toLocaleString()} pts</span>
|
||||||
|
<span className="text-slate-300">{indicateur.adulte2.nom}</span>
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: indicateur.adulte2.couleur }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<number | null>(null);
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
if (saisies.length === 0) {
|
||||||
|
return <p className="text-slate-500 text-sm text-center py-6">Aucune saisie sur cette période</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Table scroll */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700">
|
||||||
|
{['Date', 'Membre', 'Tâche', 'Catégorie', 'Durée', 'Péni.', 'Score', 'Notes', ''].map(h => (
|
||||||
|
<th key={h} className="text-left py-2 px-2 text-xs text-slate-400 font-medium whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{saisies.map(s => (
|
||||||
|
<tr key={s.id} className="border-b border-slate-800 hover:bg-slate-800/50">
|
||||||
|
<td className="py-2 px-2 text-slate-400 whitespace-nowrap text-xs">
|
||||||
|
{s.date_heure ? format(new Date(s.date_heure), 'd MMM HH:mm', { locale: fr }) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: s.membre_couleur || '#6b7280' }} />
|
||||||
|
<span className="text-white font-medium whitespace-nowrap">{s.membre_nom}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-slate-300 max-w-32 truncate" title={s.tache_nom || s.nom_tache_oneshot || ''}>
|
||||||
|
{s.tache_nom || s.nom_tache_oneshot}
|
||||||
|
{s.nom_tache_oneshot && <span className="ml-1 text-xs text-amber-500">ponct.</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 whitespace-nowrap">
|
||||||
|
<span className="text-xs">{s.categorie_icone} {s.categorie_nom}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-slate-400 whitespace-nowrap">
|
||||||
|
{s.duree_reelle_min || '—'} min
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-center">
|
||||||
|
<span className="w-6 h-6 rounded bg-slate-700 text-xs font-bold text-slate-300 inline-flex items-center justify-center">
|
||||||
|
{s.coefficient_penibilite}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className="font-bold text-indigo-400">{s.score_final}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-slate-500 text-xs max-w-24 truncate" title={s.notes || ''}>
|
||||||
|
{s.notes}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<SaisieActions saisieId={s.id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-slate-700">
|
||||||
|
<span className="text-xs text-slate-500">{total} saisies au total</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-slate-800 hover:bg-slate-700 disabled:opacity-40 rounded-lg transition-colors">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1.5 text-xs text-slate-400">{page} / {totalPages}</span>
|
||||||
|
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-slate-800 hover:bg-slate-700 disabled:opacity-40 rounded-lg transition-colors">
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button onClick={handleDelete} className="text-slate-600 hover:text-red-400 transition-colors text-xs px-1">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Onglet>('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 (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 sm:pt-20">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-6">Paramètres</h1>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-slate-800 rounded-xl p-1 mb-6">
|
||||||
|
{ONGLETS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => setOnglet(o.id)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
onglet === o.id ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{o.icon}</span>
|
||||||
|
<span>{o.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onglet === 'membres' && <GestionMembres />}
|
||||||
|
{onglet === 'categories' && <GestionCategories />}
|
||||||
|
{onglet === 'taches' && <GestionTaches />}
|
||||||
|
{onglet === 'export' && <ExportSection />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gestion des membres ──────────────────────────────────────────────────────
|
||||||
|
function GestionMembres() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: membres = [] } = useQuery({ queryKey: ['membres'], queryFn: membresApi.list });
|
||||||
|
const [editId, setEditId] = useState<number | null>(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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{membres.map(m => (
|
||||||
|
<div key={m.id} className="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
{editId === m.id ? (
|
||||||
|
<MemberForm
|
||||||
|
initial={{ nom: m.nom, role: m.role, couleur: m.couleur }}
|
||||||
|
onSave={(data) => mutation.mutate({ id: m.id, ...data })}
|
||||||
|
onCancel={() => setEditId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-lg flex-shrink-0"
|
||||||
|
style={{ backgroundColor: m.couleur }}>
|
||||||
|
{m.nom[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white">{m.nom}</p>
|
||||||
|
<p className="text-xs text-slate-400 capitalize">{m.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setEditId(m.id)} className="text-slate-500 hover:text-white text-sm px-2 py-1 rounded-lg hover:bg-slate-700 transition-colors">✏️</button>
|
||||||
|
<button onClick={() => { if (confirm(`Désactiver ${m.nom} ?`)) deleteMutation.mutate(m.id); }}
|
||||||
|
className="text-slate-500 hover:text-red-400 text-sm px-2 py-1 rounded-lg hover:bg-slate-700 transition-colors">🗑</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAdd ? (
|
||||||
|
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||||
|
<MemberForm
|
||||||
|
initial={{ nom: '', role: 'adulte', couleur: '#3b82f6' }}
|
||||||
|
onSave={(data) => mutation.mutate(data)}
|
||||||
|
onCancel={() => setShowAdd(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : membres.length < 7 ? (
|
||||||
|
<button onClick={() => setShowAdd(true)}
|
||||||
|
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||||
|
+ Ajouter un membre
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center w-full">
|
||||||
|
<input type="color" value={couleur} onChange={e => setCouleur(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded-lg cursor-pointer" />
|
||||||
|
<input value={nom} onChange={e => 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" />
|
||||||
|
<select value={role} onChange={e => setRole(e.target.value as 'adulte' | 'enfant')}
|
||||||
|
className="bg-slate-800 text-slate-300 text-sm rounded-lg px-2 py-2 border border-slate-600 focus:outline-none">
|
||||||
|
<option value="adulte">Adulte</option>
|
||||||
|
<option value="enfant">Enfant</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), role, couleur })}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors">✓</button>
|
||||||
|
<button onClick={onCancel} className="bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm px-3 py-2 rounded-lg transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gestion des catégories ───────────────────────────────────────────────────
|
||||||
|
function GestionCategories() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: categories = [] } = useQuery({ queryKey: ['categories'], queryFn: categoriesApi.list });
|
||||||
|
const [editId, setEditId] = useState<number | null>(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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div key={cat.id} className="bg-slate-900 border border-slate-700 rounded-xl p-3 flex items-center gap-3">
|
||||||
|
{editId === cat.id ? (
|
||||||
|
<CatForm
|
||||||
|
initial={{ nom: cat.nom, icone: cat.icone, couleur: cat.couleur }}
|
||||||
|
onSave={(data) => mutation.mutate({ id: cat.id, ...data })}
|
||||||
|
onCancel={() => setEditId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl">{cat.icone}</span>
|
||||||
|
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: cat.couleur }} />
|
||||||
|
<span className="flex-1 text-white font-medium">{cat.nom}</span>
|
||||||
|
<button onClick={() => setEditId(cat.id)} className="text-slate-500 hover:text-white text-sm px-2 py-1 rounded hover:bg-slate-700 transition-colors">✏️</button>
|
||||||
|
<button onClick={() => { if (confirm(`Désactiver "${cat.nom}" ?`)) deleteMutation.mutate(cat.id); }}
|
||||||
|
className="text-slate-500 hover:text-red-400 text-sm px-2 py-1 rounded hover:bg-slate-700 transition-colors">🗑</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAdd ? (
|
||||||
|
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||||
|
<CatForm initial={{ nom: '', icone: '📦', couleur: '#6b7280' }}
|
||||||
|
onSave={(data) => mutation.mutate(data)}
|
||||||
|
onCancel={() => setShowAdd(false)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowAdd(true)}
|
||||||
|
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||||
|
+ Ajouter une catégorie
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<input value={icone} onChange={e => 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" />
|
||||||
|
<input type="color" value={couleur} onChange={e => setCouleur(e.target.value)} className="w-10 h-10 rounded-lg cursor-pointer" />
|
||||||
|
<input value={nom} onChange={e => 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" />
|
||||||
|
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), icone, couleur })}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors">✓</button>
|
||||||
|
<button onClick={onCancel} className="bg-slate-700 text-slate-300 text-sm px-3 py-2 rounded-lg transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<number | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [filtreCategorie, setFiltreCategorie] = useState<number | null>(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 (
|
||||||
|
<div>
|
||||||
|
{/* Filtre catégorie */}
|
||||||
|
<div className="flex gap-1 flex-wrap mb-4">
|
||||||
|
<button onClick={() => setFiltreCategorie(null)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${!filtreCategorie ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400'}`}>
|
||||||
|
Toutes
|
||||||
|
</button>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button key={cat.id} onClick={() => setFiltreCategorie(cat.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${filtreCategorie === cat.id ? 'text-white' : 'bg-slate-800 text-slate-400'}`}
|
||||||
|
style={filtreCategorie === cat.id ? { backgroundColor: cat.couleur } : {}}>
|
||||||
|
{cat.icone} {cat.nom}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tachesFiltrees.map(tache => (
|
||||||
|
<div key={tache.id} className="bg-slate-900 border border-slate-700 rounded-xl p-3">
|
||||||
|
{editId === tache.id ? (
|
||||||
|
<TacheForm
|
||||||
|
initial={{ nom: tache.nom, categorie_id: tache.categorie_id, duree: tache.duree_moyenne_min, coef: tache.coefficient_penibilite }}
|
||||||
|
categories={categories}
|
||||||
|
onSave={(data) => mutation.mutate({ id: tache.id, ...data })}
|
||||||
|
onCancel={() => setEditId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{tache.categorie_icone} {tache.categorie_nom}
|
||||||
|
</span>
|
||||||
|
<p className="text-white text-sm font-medium">{tache.nom}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-400">{tache.duree_moyenne_min} min</span>
|
||||||
|
<span className="text-xs text-slate-400">x{tache.coefficient_penibilite}</span>
|
||||||
|
<span className="text-xs font-bold text-indigo-400 bg-indigo-900/40 px-2 py-0.5 rounded-full">
|
||||||
|
{tache.score_calcule} pts
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setEditId(tache.id)} className="text-slate-500 hover:text-white text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition-colors">✏️</button>
|
||||||
|
<button onClick={() => { if (confirm(`Archiver "${tache.nom}" ?`)) deleteMutation.mutate(tache.id); }}
|
||||||
|
className="text-slate-500 hover:text-red-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition-colors">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAdd ? (
|
||||||
|
<div className="bg-slate-900 border border-indigo-700 rounded-xl p-4">
|
||||||
|
<TacheForm
|
||||||
|
initial={{ nom: '', categorie_id: categories[0]?.id || 0, duree: 30, coef: 2 }}
|
||||||
|
categories={categories}
|
||||||
|
onSave={(data) => mutation.mutate(data)}
|
||||||
|
onCancel={() => setShowAdd(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowAdd(true)}
|
||||||
|
className="w-full py-3 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors">
|
||||||
|
+ Ajouter une tâche
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input value={nom} onChange={e => 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" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Catégorie</label>
|
||||||
|
<select value={categorieId} onChange={e => setCategorieId(parseInt(e.target.value))}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-white text-sm focus:outline-none">
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.icone} {c.nom}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Durée (min)</label>
|
||||||
|
<input type="number" min={1} max={480} value={duree} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Pénibilité — Score : {duree * coef} pts</label>
|
||||||
|
<PenibiliteSelector value={coef} onChange={setCoef} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => nom.trim() && onSave({ nom: nom.trim(), categorie_id: categorieId, duree_moyenne_min: duree, coefficient_penibilite: coef })}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-sm py-2.5 rounded-xl transition-colors">
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="bg-slate-700 text-slate-300 text-sm px-4 py-2.5 rounded-xl transition-colors">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||||
|
function ExportSection() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6">
|
||||||
|
<h2 className="font-semibold text-white mb-2">Exporter les données</h2>
|
||||||
|
<p className="text-slate-400 text-sm mb-5">Téléchargez toutes les saisies de votre foyer.</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => dashboardApi.export('csv')}
|
||||||
|
className="flex-1 bg-emerald-700 hover:bg-emerald-600 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||||
|
<span>↓</span> Export CSV
|
||||||
|
</button>
|
||||||
|
<button onClick={() => dashboardApi.export('json')}
|
||||||
|
className="flex-1 bg-indigo-700 hover:bg-indigo-600 text-white font-medium py-3 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||||
|
<span>↓</span> Export JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-xs mt-3 text-center">
|
||||||
|
CSV recommandé pour Excel / Sheets · JSON pour usage programmatique
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<TacheRecurrente | null>(null);
|
||||||
|
const [modalOneshot, setModalOneshot] = useState(false);
|
||||||
|
const [categorieActive, setCategorieActive] = useState<number | null>(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<number, { categorie_id: number; nom: string; icone: string; couleur: string; taches: TacheRecurrente[] }>();
|
||||||
|
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 (
|
||||||
|
<div className="pb-4">
|
||||||
|
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||||
|
|
||||||
|
{/* Header membre */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-4 border-b border-slate-800"
|
||||||
|
style={{ borderTopColor: membreActif.couleur, borderTopWidth: 3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-lg"
|
||||||
|
style={{ backgroundColor: membreActif.couleur }}
|
||||||
|
>
|
||||||
|
{membreActif.nom[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">{membreActif.nom}</p>
|
||||||
|
<p className="text-slate-500 text-xs">{format(new Date(), 'EEEE d MMMM', { locale: fr })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bouton tâche ponctuelle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOneshot(true)}
|
||||||
|
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium px-3 py-2 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<span>+</span>
|
||||||
|
<span className="hidden sm:block">Tâche ponctuelle</span>
|
||||||
|
<span className="sm:hidden">Ponctuelle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
|
{/* Tabs catégories */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto py-3 scrollbar-hide -mx-4 px-4">
|
||||||
|
{tachesParCategorie.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.categorie_id}
|
||||||
|
onClick={() => setCategorieActive(cat.categorie_id)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all flex-shrink-0 ${
|
||||||
|
categorieActive === cat.categorie_id
|
||||||
|
? 'text-white shadow-lg'
|
||||||
|
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
style={categorieActive === cat.categorie_id ? { backgroundColor: cat.couleur } : {}}
|
||||||
|
>
|
||||||
|
<span>{cat.icone}</span>
|
||||||
|
<span>{cat.nom}</span>
|
||||||
|
<span className="text-xs opacity-60">({cat.taches.length})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grille de tâches */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-24 bg-slate-800 rounded-xl animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tachesParCategorie
|
||||||
|
.filter(cat => cat.categorie_id === categorieActive)
|
||||||
|
.map(cat => (
|
||||||
|
<div key={cat.categorie_id} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-2">
|
||||||
|
{cat.taches.map(tache => (
|
||||||
|
<TacheCard
|
||||||
|
key={tache.id}
|
||||||
|
tache={tache}
|
||||||
|
couleurCat={cat.couleur}
|
||||||
|
onClick={() => setModalTache(tache)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal confirmation tâche récurrente */}
|
||||||
|
<Modal isOpen={!!modalTache} onClose={() => setModalTache(null)} title="Confirmer la saisie">
|
||||||
|
{modalTache && (
|
||||||
|
<ModalConfirmTache
|
||||||
|
tache={modalTache}
|
||||||
|
membreId={membreActif.id}
|
||||||
|
onConfirm={soumettreSaisie}
|
||||||
|
onClose={() => setModalTache(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal tâche ponctuelle */}
|
||||||
|
<Modal isOpen={modalOneshot} onClose={() => setModalOneshot(false)} title="Tâche ponctuelle">
|
||||||
|
<ModalOneShot
|
||||||
|
membreId={membreActif.id}
|
||||||
|
tachesParCategorie={tachesParCategorie}
|
||||||
|
onConfirm={soumettreSaisie}
|
||||||
|
onClose={() => 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');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Carte tâche ─────────────────────────────────────────────────────────────
|
||||||
|
function TacheCard({ tache, couleurCat, onClick }: { tache: TacheRecurrente; couleurCat: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 active:scale-95 rounded-2xl p-4 text-left transition-all duration-150 flex flex-col gap-2 border border-slate-700 hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-white text-sm leading-tight line-clamp-2">{tache.nom}</p>
|
||||||
|
<div className="flex items-center justify-between mt-auto">
|
||||||
|
<span className="text-slate-400 text-xs">{tache.duree_moyenne_min} min</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold px-1.5 py-0.5 rounded-md"
|
||||||
|
style={{ backgroundColor: couleurCat + '30', color: couleurCat }}
|
||||||
|
>
|
||||||
|
×{tache.coefficient_penibilite}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-indigo-400 font-bold">{tache.score_calcule}pt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">{tache.nom}</h3>
|
||||||
|
|
||||||
|
{/* Score preview */}
|
||||||
|
<div className="flex items-center gap-3 bg-slate-800 rounded-xl p-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-slate-400 text-xs">Durée</p>
|
||||||
|
<p className="text-white font-bold">{dureeFinale} min</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-600">×</span>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-slate-400 text-xs">Pénibilité</p>
|
||||||
|
<p className="text-white font-bold">{coefFinal}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-600">=</span>
|
||||||
|
<div className="text-center flex-1">
|
||||||
|
<p className="text-slate-400 text-xs">Score</p>
|
||||||
|
<p className="text-indigo-400 font-bold text-xl">{scoreCalcule} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/heure */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Date et heure</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dateHeure}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Override optionnel */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOverride(!override)}
|
||||||
|
className="text-slate-400 hover:text-white text-sm underline mb-3 transition-colors"
|
||||||
|
>
|
||||||
|
{override ? '← Utiliser les valeurs par défaut' : '✏️ Modifier durée / pénibilité'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{override && (
|
||||||
|
<div className="space-y-3 mb-3 p-3 bg-slate-800 rounded-xl border border-slate-700">
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Durée réelle (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={480}
|
||||||
|
value={dureeOverride}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Pénibilité</label>
|
||||||
|
<PenibiliteSelector value={coefOverride} onChange={setCoefOverride} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder="Notes (optionnel)…"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="flex-1 py-3 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-xl transition-colors">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Valider +{scoreCalcule}pts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal tâche one-shot ─────────────────────────────────────────────────────
|
||||||
|
function ModalOneShot({
|
||||||
|
membreId, tachesParCategorie, onConfirm, onClose, onAddToCatalogue,
|
||||||
|
}: {
|
||||||
|
membreId: number;
|
||||||
|
tachesParCategorie: { categorie_id: number; nom: string; icone: string; taches: TacheRecurrente[] }[];
|
||||||
|
onConfirm: (data: SaisieFormData) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddToCatalogue: (nom: string, cat_id: number, duree: number, coef: number) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [nom, setNom] = useState('');
|
||||||
|
const [categorieId, setCategorieId] = useState(tachesParCategorie[0]?.categorie_id || 0);
|
||||||
|
const [duree, setDuree] = useState(30);
|
||||||
|
const [coef, setCoef] = useState(2);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [dateHeure, setDateHeure] = useState(new Date().toISOString().slice(0, 16));
|
||||||
|
const [ajouterCatalogue, setAjouterCatalogue] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const score = duree * coef;
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!nom.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (ajouterCatalogue) {
|
||||||
|
await onAddToCatalogue(nom.trim(), categorieId, duree, coef);
|
||||||
|
}
|
||||||
|
onConfirm({
|
||||||
|
nom_tache_oneshot: nom.trim(),
|
||||||
|
categorie_id: categorieId,
|
||||||
|
membre_id: membreId,
|
||||||
|
date_heure: dateHeure.replace('T', ' ') + ':00',
|
||||||
|
duree_reelle_min: duree,
|
||||||
|
coefficient_penibilite: coef,
|
||||||
|
score_final: score,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Nom */}
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Nom de la tâche *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nom}
|
||||||
|
onChange={e => setNom(e.target.value)}
|
||||||
|
placeholder="Ex: Nettoyage garage..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catégorie */}
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Catégorie</label>
|
||||||
|
<select
|
||||||
|
value={categorieId}
|
||||||
|
onChange={e => setCategorieId(parseInt(e.target.value))}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{tachesParCategorie.map(cat => (
|
||||||
|
<option key={cat.categorie_id} value={cat.categorie_id}>
|
||||||
|
{cat.icone} {cat.nom}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Durée */}
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Durée (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={480}
|
||||||
|
value={duree}
|
||||||
|
onChange={e => setDuree(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pénibilité */}
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Pénibilité</label>
|
||||||
|
<PenibiliteSelector value={coef} onChange={setCoef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score preview */}
|
||||||
|
<div className="flex items-center justify-center bg-slate-800 rounded-xl py-3">
|
||||||
|
<ScoreBadge score={score} size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs mb-1 block">Date et heure</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dateHeure}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder="Notes (optionnel)…"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-3 py-2 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ajouter au catalogue */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ajouterCatalogue}
|
||||||
|
onChange={e => setAjouterCatalogue(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 text-sm">Ajouter au catalogue des tâches récurrentes</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Boutons */}
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button onClick={onClose} className="flex-1 py-3 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-xl transition-colors">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!nom.trim() || loading}
|
||||||
|
className="flex-1 py-3 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '…' : `Valider +${score}pts`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// src/pages/SelectionProfil.tsx
|
||||||
|
// Écran de sélection du profil actif (style Netflix)
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { membresApi } from '../api';
|
||||||
|
import { useApp } from '../context/AppContext';
|
||||||
|
import type { Membre } from '../types';
|
||||||
|
|
||||||
|
export function SelectionProfil() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setMembreActif } = useApp();
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: membres = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['membres'],
|
||||||
|
queryFn: membresApi.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const choisirMembre = async (membre: Membre) => {
|
||||||
|
setSelected(membre.id);
|
||||||
|
// Courte animation avant navigation
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
setMembreActif(membre);
|
||||||
|
navigate('/saisie');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
|
||||||
|
<div className="text-slate-400 animate-pulse text-lg">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adultes = membres.filter(m => m.role === 'adulte');
|
||||||
|
const enfants = membres.filter(m => m.role === 'enfant');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-950 to-slate-900 flex flex-col items-center justify-center p-6">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<div className="text-5xl mb-3">⚖️</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white tracking-tight">EquiTask</h1>
|
||||||
|
<p className="text-slate-400 mt-2">Qui êtes-vous ?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adultes */}
|
||||||
|
{adultes.length > 0 && (
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
{enfants.length > 0 && (
|
||||||
|
<p className="text-slate-500 text-xs uppercase tracking-widest mb-4 text-center">Adultes</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap justify-center gap-6">
|
||||||
|
{adultes.map(membre => (
|
||||||
|
<ProfileCard
|
||||||
|
key={membre.id}
|
||||||
|
membre={membre}
|
||||||
|
isSelected={selected === membre.id}
|
||||||
|
onClick={() => choisirMembre(membre)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enfants */}
|
||||||
|
{enfants.length > 0 && (
|
||||||
|
<div className="w-full max-w-2xl mt-8">
|
||||||
|
<p className="text-slate-500 text-xs uppercase tracking-widest mb-4 text-center">Enfants</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
{enfants.map(membre => (
|
||||||
|
<ProfileCard
|
||||||
|
key={membre.id}
|
||||||
|
membre={membre}
|
||||||
|
isSelected={selected === membre.id}
|
||||||
|
onClick={() => choisirMembre(membre)}
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liens rapides */}
|
||||||
|
<div className="mt-12 flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="text-slate-500 hover:text-slate-300 text-sm transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
📊 Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/parametres')}
|
||||||
|
className="text-slate-500 hover:text-slate-300 text-sm transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
⚙️ Réglages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carte profil style Netflix */
|
||||||
|
function ProfileCard({
|
||||||
|
membre, isSelected, onClick, small = false,
|
||||||
|
}: {
|
||||||
|
membre: Membre; isSelected: boolean; onClick: () => void; small?: boolean;
|
||||||
|
}) {
|
||||||
|
const size = small ? 'w-24' : 'w-32';
|
||||||
|
const avatarSize = small ? 'w-24 h-24 text-4xl' : 'w-32 h-32 text-5xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex flex-col items-center gap-3 group transition-all duration-200 ${
|
||||||
|
isSelected ? 'scale-110' : 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={`${avatarSize} rounded-2xl flex items-center justify-center font-bold text-white shadow-xl transition-all duration-200 ${
|
||||||
|
isSelected ? 'ring-4 ring-white scale-105' : 'group-hover:ring-2 group-hover:ring-white/40'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: membre.couleur }}
|
||||||
|
>
|
||||||
|
<span>{membre.nom[0].toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{/* Nom */}
|
||||||
|
<span className={`font-semibold text-center transition-colors ${
|
||||||
|
isSelected ? 'text-white' : 'text-slate-400 group-hover:text-white'
|
||||||
|
} ${small ? 'text-sm' : 'text-base'}`}>
|
||||||
|
{membre.nom}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// src/pages/Setup.tsx
|
||||||
|
// Page de premier lancement : configuration du foyer et ajout des membres
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { foyerApi, membresApi } from '../api';
|
||||||
|
|
||||||
|
const COULEURS_PRESET = [
|
||||||
|
'#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MembreTemp {
|
||||||
|
nom: string;
|
||||||
|
role: 'adulte' | 'enfant';
|
||||||
|
couleur: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Setup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [etape, setEtape] = useState<'foyer' | 'membres'>('foyer');
|
||||||
|
const [nomFoyer, setNomFoyer] = useState('');
|
||||||
|
const [membres, setMembres] = useState<MembreTemp[]>([
|
||||||
|
{ nom: '', role: 'adulte', couleur: '#3b82f6' },
|
||||||
|
{ nom: '', role: 'adulte', couleur: '#ec4899' },
|
||||||
|
]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [erreur, setErreur] = useState('');
|
||||||
|
|
||||||
|
const ajouterMembre = () => {
|
||||||
|
if (membres.length >= 7) return;
|
||||||
|
const couleur = COULEURS_PRESET[membres.length % COULEURS_PRESET.length];
|
||||||
|
setMembres([...membres, { nom: '', role: 'enfant', couleur }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerMembre = (i: number) => {
|
||||||
|
if (membres.length <= 1) return;
|
||||||
|
setMembres(membres.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validerFoyer = () => {
|
||||||
|
if (!nomFoyer.trim()) { setErreur('Entrez un nom pour le foyer'); return; }
|
||||||
|
setErreur('');
|
||||||
|
setEtape('membres');
|
||||||
|
};
|
||||||
|
|
||||||
|
const validerMembres = async () => {
|
||||||
|
const membresFiltres = membres.filter(m => m.nom.trim());
|
||||||
|
if (membresFiltres.length === 0) { setErreur('Ajoutez au moins un membre'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setErreur('');
|
||||||
|
try {
|
||||||
|
await foyerApi.create(nomFoyer.trim());
|
||||||
|
for (let i = 0; i < membresFiltres.length; i++) {
|
||||||
|
const m = membresFiltres[i];
|
||||||
|
await membresApi.create({ nom: m.nom.trim(), role: m.role, couleur: m.couleur, actif: 1, ordre: i });
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setErreur(err instanceof Error ? err.message : 'Erreur lors de la création');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="text-6xl mb-3">⚖️</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">EquiTask</h1>
|
||||||
|
<p className="text-slate-400 mt-2">Mesurez la répartition des tâches dans votre foyer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 rounded-2xl p-6 border border-slate-700 shadow-2xl">
|
||||||
|
{etape === 'foyer' ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-1">Créer votre foyer</h2>
|
||||||
|
<p className="text-slate-400 text-sm mb-6">Comment s'appelle votre foyer ?</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nomFoyer}
|
||||||
|
onChange={e => setNomFoyer(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && validerFoyer()}
|
||||||
|
placeholder="Ex: Famille Martin, Notre maison…"
|
||||||
|
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 text-lg"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{erreur && <p className="text-red-400 text-sm mt-2">{erreur}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={validerFoyer}
|
||||||
|
className="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-3 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Continuer →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button onClick={() => setEtape('foyer')} className="text-slate-400 hover:text-white">←</button>
|
||||||
|
<h2 className="text-xl font-semibold text-white">Ajouter les membres</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm mb-5">Qui vit dans <strong className="text-white">{nomFoyer}</strong> ?</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{membres.map((m, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center bg-slate-800 rounded-xl p-3">
|
||||||
|
{/* Sélecteur couleur */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={m.couleur}
|
||||||
|
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, couleur: e.target.value } : x))}
|
||||||
|
className="w-10 h-10 rounded-lg cursor-pointer border-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Nom */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={m.nom}
|
||||||
|
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, nom: e.target.value } : x))}
|
||||||
|
placeholder={`Prénom ${i + 1}`}
|
||||||
|
className="flex-1 bg-transparent text-white placeholder-slate-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{/* Rôle */}
|
||||||
|
<select
|
||||||
|
value={m.role}
|
||||||
|
onChange={e => setMembres(prev => prev.map((x, j) => j === i ? { ...x, role: e.target.value as 'adulte' | 'enfant' } : x))}
|
||||||
|
className="bg-slate-700 text-slate-300 text-xs rounded-lg px-2 py-1 border-0 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="adulte">Adulte</option>
|
||||||
|
<option value="enfant">Enfant</option>
|
||||||
|
</select>
|
||||||
|
{/* Supprimer */}
|
||||||
|
{membres.length > 1 && (
|
||||||
|
<button onClick={() => supprimerMembre(i)} className="text-slate-500 hover:text-red-400 text-lg">×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{membres.length < 7 && (
|
||||||
|
<button
|
||||||
|
onClick={ajouterMembre}
|
||||||
|
className="w-full py-2 border border-dashed border-slate-600 text-slate-400 hover:text-white hover:border-slate-400 rounded-xl text-sm transition-colors mb-4"
|
||||||
|
>
|
||||||
|
+ Ajouter un membre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{erreur && <p className="text-red-400 text-sm mb-3">{erreur}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={validerMembres}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Création…' : 'Créer le foyer 🎉'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// src/types/index.ts
|
||||||
|
// Types TypeScript partagés pour EquiTask
|
||||||
|
|
||||||
|
export interface Foyer {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
cree_le: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Membre {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
role: 'adulte' | 'enfant';
|
||||||
|
couleur: string;
|
||||||
|
actif: number;
|
||||||
|
ordre: number;
|
||||||
|
cree_le?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Categorie {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
icone: string;
|
||||||
|
couleur: string;
|
||||||
|
ordre: number;
|
||||||
|
actif: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TacheRecurrente {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
categorie_id: number;
|
||||||
|
duree_moyenne_min: number;
|
||||||
|
coefficient_penibilite: number;
|
||||||
|
score_calcule: number;
|
||||||
|
actif: number;
|
||||||
|
// Jointures optionnelles
|
||||||
|
categorie_nom?: string;
|
||||||
|
categorie_icone?: string;
|
||||||
|
categorie_couleur?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Saisie {
|
||||||
|
id: number;
|
||||||
|
tache_recurrente_id: number | null;
|
||||||
|
nom_tache_oneshot: string | null;
|
||||||
|
categorie_id: number;
|
||||||
|
membre_id: number;
|
||||||
|
date_heure: string;
|
||||||
|
duree_reelle_min: number | null;
|
||||||
|
coefficient_penibilite: number;
|
||||||
|
score_final: number;
|
||||||
|
notes: string | null;
|
||||||
|
synced: number;
|
||||||
|
cree_le: string;
|
||||||
|
// Jointures
|
||||||
|
membre_nom?: string;
|
||||||
|
membre_couleur?: string;
|
||||||
|
membre_role?: string;
|
||||||
|
categorie_nom?: string;
|
||||||
|
categorie_icone?: string;
|
||||||
|
categorie_couleur?: string;
|
||||||
|
tache_nom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaisieFormData {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export interface ScoreMembre {
|
||||||
|
membre_id: number;
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
role: string;
|
||||||
|
score_total: number;
|
||||||
|
pourcentage: number;
|
||||||
|
nb_saisies: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreCategorie {
|
||||||
|
categorie_id: number;
|
||||||
|
nom: string;
|
||||||
|
icone: string;
|
||||||
|
couleur: string;
|
||||||
|
scores_membres: { membre_id: number; nom: string; couleur: string; score: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvolutionPoint {
|
||||||
|
date: string;
|
||||||
|
scores: { membre_id: number; nom: string; couleur: string; score: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndicateurEquilibre {
|
||||||
|
adulte1: { membre_id: number; nom: string; couleur: string; score: number; pourcentage: number } | null;
|
||||||
|
adulte2: { membre_id: number; nom: string; couleur: string; score: number; pourcentage: number } | null;
|
||||||
|
ecart_pct: number;
|
||||||
|
statut: 'vert' | 'orange' | 'rouge';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
scores_par_membre: ScoreMembre[];
|
||||||
|
scores_par_categorie: ScoreCategorie[];
|
||||||
|
evolution_temporelle: EvolutionPoint[];
|
||||||
|
indicateur_equilibre: IndicateurEquilibre | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue offline
|
||||||
|
export interface OfflineSaisie extends SaisieFormData {
|
||||||
|
offline_id: string; // UUID local
|
||||||
|
created_at: number; // timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Périodes du dashboard
|
||||||
|
export type PeriodKey = 'semaine' | 'mois' | '7jours' | '30jours' | 'personnalise';
|
||||||
|
|
||||||
|
export interface PeriodSelection {
|
||||||
|
key: PeriodKey;
|
||||||
|
debut: string; // YYYY-MM-DD
|
||||||
|
fin: string; // YYYY-MM-DD
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'bounce-in': 'bounceIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
|
||||||
|
slideUp: { from: { transform: 'translateY(20px)', opacity: '0' }, to: { transform: 'translateY(0)', opacity: '1' } },
|
||||||
|
bounceIn: { from: { transform: 'scale(0.8)', opacity: '0' }, to: { transform: 'scale(1)', opacity: '1' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'icons/*.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'EquiTask - Répartition tâches',
|
||||||
|
short_name: 'EquiTask',
|
||||||
|
description: 'Mesurez la répartition des tâches domestiques dans votre foyer',
|
||||||
|
theme_color: '#1e1b4b',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
|
||||||
|
networkTimeoutSeconds: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user