deploy: equitask — 2026-04-28 19:51:14
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user