diff --git a/GTFRRH/docker-compose.yml b/GTFRRH/docker-compose.yml new file mode 100644 index 0000000..08e3dda --- /dev/null +++ b/GTFRRH/docker-compose.yml @@ -0,0 +1,30 @@ +services: + backend: + build: + context: ./project/backend/config + dockerfile: DockerfileGTFRH.backend + ports: + - "8000:3001" + environment: + - DB_SERVER=192.168.0.3 + - DB_DATABASE=GTF + - DB_USER=gtf_app + - DB_PASSWORD=GTF2025!Secure + - DB_ENCRYPT=true + - DB_TRUST_SERVER_CERTIFICATE=true + - PORT=3000 + extra_hosts: + - "BONEMINE:192.168.0.3" + - "bonemine.ensup.local:192.168.0.3" + + frontend: + build: + context: ./project + dockerfile: DockerfileGTFRH.frontend + ports: + - "3002:3002" + environment: + - NODE_ENV=development + - VITE_API_BASE_URL=https://mygtf-rh.ensup-adm.net:8000 + depends_on: + - backend \ No newline at end of file diff --git a/GTFRRH/project/DockerfileGTFRH.frontend b/GTFRRH/project/DockerfileGTFRH.frontend new file mode 100644 index 0000000..7f3f0ba --- /dev/null +++ b/GTFRRH/project/DockerfileGTFRH.frontend @@ -0,0 +1,42 @@ +FROM node:18 + +WORKDIR /GTFRRH/project + +COPY package*.json ./ + +RUN rm -rf node_modules package-lock.json && \ + npm install --include=optional + +COPY . . + +# Override du vite.config.ts avec allowedHosts +RUN cat > vite.config.ts << 'EOF' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 3002, + strictPort: true, + allowedHosts: [ + 'mygtf-rh.ensup-adm.net', + 'localhost', + '127.0.0.1' + ], + proxy: { + '/api': { + target: 'http://backend:3000', + changeOrigin: true, + secure: false, + ws: true + } + } + } +}) +EOF + +EXPOSE 3002 + +CMD ["npm", "run", "dev"] diff --git a/GTFRRH/project/backend/config/DockerfileGTFRH.backend b/GTFRRH/project/backend/config/DockerfileGTFRH.backend new file mode 100644 index 0000000..9db55bb --- /dev/null +++ b/GTFRRH/project/backend/config/DockerfileGTFRH.backend @@ -0,0 +1,18 @@ +# DockerfileGTFRH.backend +FROM node:18-alpine + +WORKDIR /GTFRRH/project + +# Copy only package files first for caching +COPY package*.json ./ + +# Install backend dependencies inside container +RUN rm -rf node_modules package-lock.json && npm install + +# Copy backend source code +COPY . . + +EXPOSE 8001 + +CMD ["node", "serv.js"] + diff --git a/GTFRRH/project/backend/config/package.json b/GTFRRH/project/backend/config/package.json index 2d9be93..f3966a7 100644 --- a/GTFRRH/project/backend/config/package.json +++ b/GTFRRH/project/backend/config/package.json @@ -1,11 +1,11 @@ -{ +{ "name": "config", "version": "1.0.0", "description": "", - "main": "server.js", + "main": "serv.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js" + "start": "node serv.js" }, "keywords": [], "author": "", @@ -14,7 +14,8 @@ "cors": "^2.8.5", "dotenv": "^17.2.2", "express": "^5.1.0", - "mssql": "^11.0.1" + "mssql": "^11.0.1", + "axios": "^1.7.0" }, "devDependencies": { "@types/cors": "^2.8.19" diff --git a/GTFRRH/project/backend/config/serv.js b/GTFRRH/project/backend/config/serv.js index c9ece8b..f5abacf 100644 --- a/GTFRRH/project/backend/config/serv.js +++ b/GTFRRH/project/backend/config/serv.js @@ -5,7 +5,7 @@ const axios = require('axios'); require('dotenv').config(); const app = express(); -const PORT = 3002; +const PORT = process.env.PORT || 8000; // Configuration base de données const dbConfig = { @@ -23,16 +23,20 @@ const dbConfig = { // Configuration Microsoft OAuth const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943'; const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9'; -const REDIRECT_URI = 'http://localhost:5174'; +const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net'; const CLIENT_SECRET = 'F5G8Q~qWNzuMdghyIwTX20cAVjqAK4sz~1uEUaLB'; const GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0'; // Middleware app.use(cors({ - origin: ['http://localhost:5174', 'http://localhost:5173', 'http://localhost:3000'], - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] + origin: [ + 'http://localhost:3001', + 'http://localhost:8000', + 'http://127.0.0.1:3001', + 'https://mygtf-rh.ensup-adm.net:3001', + 'https://mygtf-rh.ensup-adm.net' + ], + credentials: true })); app.use(express.json({ limit: '10mb' })); @@ -85,23 +89,24 @@ async function checkSystemStatus() { `); systemStatus.hasFormateurEmailColumn = columnCheck.recordset[0].count > 0; - // 2. Vérifier si la vue Formateurs existe + // 2. Vérifier si la vue v_Formateurs_CD existe const viewCheck = await pool.request().query(` SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_NAME = 'Formateurs' + WHERE TABLE_SCHEMA = 'dbo' + AND TABLE_NAME = 'v_Formateurs_CD' `); systemStatus.hasFormateurView = viewCheck.recordset[0].count > 0; - // 3. Tester l'accès à la vue Formateurs si elle existe + // 3. Tester l'accès à la vue v_Formateurs_CD si elle existe if (systemStatus.hasFormateurView) { try { - await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [dbo].[Formateurs]`); + await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [GTF].[dbo].[v_Formateurs_CD]`); systemStatus.canAccessFormateurView = true; - console.log('✅ Accès à la vue Formateurs: OK (RH)'); + console.log('✅ Accès à la vue v_Formateurs_CD: OK (RH)'); } catch (error) { systemStatus.canAccessFormateurView = false; - console.log('❌ Accès à la vue Formateurs: ERREUR (RH) -', error.message); + console.log('❌ Accès à la vue v_Formateurs_CD: ERREUR (RH) -', error.message); } } @@ -128,8 +133,8 @@ async function checkSystemStatus() { console.log('📊 État du système RH:'); console.log(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`); - console.log(` - Vue Formateurs: ${systemStatus.hasFormateurView ? '✅' : '❌'}`); - console.log(` - Accès vue Formateurs: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`); + console.log(` - Vue v_Formateurs_CD: ${systemStatus.hasFormateurView ? '✅' : '❌'}`); + console.log(` - Accès vue v_Formateurs_CD: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`); console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`); console.log(` - Mode de fonctionnement RH: ${systemStatus.operatingMode}`); @@ -139,13 +144,33 @@ async function checkSystemStatus() { } } -// À ajouter dans votre serveur RH (server.js) +// Fonction utilitaire pour parser Nom_brut en nom et prénom +function parseNomBrut(nomBrut) { + if (!nomBrut) { + return { nom: '', prenom: '' }; + } + + const parts = nomBrut.trim().split(/\s+/); + if (parts.length > 1) { + return { + nom: parts[0], + prenom: parts.slice(1).join(' ') + }; + } else { + return { + nom: parts[0], + prenom: '' + }; + } +} + +// Route de diagnostic des campus app.get('/api/debug-campus', async (req, res) => { try { // Vérifier les campus distincts dans la vue const campusResult = await pool.request().query(` SELECT DISTINCT Campus, COUNT(*) as nb_formateurs - FROM [dbo].[Formateurs] + FROM [GTF].[dbo].[v_Formateurs_CD] GROUP BY Campus ORDER BY Campus `); @@ -154,19 +179,18 @@ app.get('/api/debug-campus', async (req, res) => { const sampleResult = await pool.request().query(` SELECT TOP 10 userPrincipalName, - displayName, - Campus, - surname, - givenname - FROM [dbo].[Formateurs] - ORDER BY Campus, displayName + Nom_brut, + Campus, + Contrat + FROM [GTF].[dbo].[v_Formateurs_CD] + ORDER BY Campus, Nom_brut `); res.json({ success: true, campus_distincts: campusResult.recordset, echantillon_formateurs: sampleResult.recordset, - message: 'Diagnostic des campus dans la vue Formateurs' + message: 'Diagnostic des campus dans la vue v_Formateurs_CD' }); } catch (error) { @@ -233,7 +257,6 @@ app.get('/api/debug-campus-local', async (req, res) => { } }); - // Fonction pour générer un hash reproductible depuis un email (mode legacy) function generateHashFromEmail(email) { let hash = 0; @@ -449,11 +472,11 @@ app.get('/api/diagnostic', async (req, res) => { break; case 'new_with_local': recommendations.push('⚠️ Fonctionne avec la table locale - pas d\'accès à la vue distante'); - recommendations.push('💡 Vérifier les permissions sur HP-TO-O365 pour utiliser la vue'); + recommendations.push('💡 Vérifier les permissions sur GTF pour utiliser la vue'); break; case 'new_email_only': recommendations.push('⚠️ Mode dégradé - sauvegarde par email mais pas de détails formateurs'); - recommendations.push('💡 Restaurer l\'accès à la vue Formateurs ou table formateurs_local'); + recommendations.push('💡 Restaurer l\'accès à la vue v_Formateurs_CD ou table formateurs_local'); break; case 'legacy_hash': recommendations.push('🔄 Mode compatibilité - utilise l\'ancien système de hash'); @@ -552,7 +575,7 @@ app.get('/api/db-test', async (req, res) => { let formateurCount = 0; try { if (systemStatus.canAccessFormateurView) { - const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [dbo].[Formateurs]'); + const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [GTF].[dbo].[v_Formateurs_CD]'); formateurCount = formateurResult.recordset[0].total; } else if (systemStatus.hasFormateurLocal) { const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM formateurs_local'); @@ -591,6 +614,136 @@ app.put('/api/declarations/:id/status', async (req, res) => { } }); +// Route pour supprimer une déclaration +app.delete('/api/declarations/:id', async (req, res) => { + try { + const { id } = req.params; + + console.log(`🗑️ Tentative de suppression de la déclaration ID: ${id}`); + + // Vérifier si la déclaration existe + const existing = await pool.request() + .input('id', sql.Int, id) + .query('SELECT id, formateur_email_fk FROM declarations WHERE id = @id'); + + if (existing.recordset.length === 0) { + console.log(`❌ Déclaration ${id} introuvable`); + return res.status(404).json({ + success: false, + message: 'Déclaration introuvable' + }); + } + + console.log(`✅ Déclaration ${id} trouvée, suppression en cours...`); + + // Supprimer la déclaration + await pool.request() + .input('id', sql.Int, id) + .query('DELETE FROM declarations WHERE id = @id'); + + console.log(`✅ Déclaration ${id} supprimée avec succès`); + + res.json({ + success: true, + message: 'Déclaration supprimée avec succès' + }); + } catch (error) { + console.error('❌ Erreur suppression déclaration:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Route pour modifier une déclaration +app.put('/api/declarations/:id', async (req, res) => { + try { + const { id } = req.params; + const { date, duree, heure_debut, heure_fin, description, type_demande_id } = req.body; + + console.log(`✏️ Tentative de modification de la déclaration ID: ${id}`); + console.log('Données reçues:', { date, duree, heure_debut, heure_fin, type_demande_id }); + + // Validation + if (!date || !duree || !type_demande_id) { + console.log('❌ Données manquantes'); + return res.status(400).json({ + success: false, + message: 'Données manquantes (date, durée, type requis)' + }); + } + + // Vérifier si la déclaration existe + const existing = await pool.request() + .input('id', sql.Int, id) + .query('SELECT id FROM declarations WHERE id = @id'); + + if (existing.recordset.length === 0) { + console.log(`❌ Déclaration ${id} introuvable`); + return res.status(404).json({ + success: false, + message: 'Déclaration introuvable' + }); + } + + console.log(`✅ Déclaration ${id} trouvée, mise à jour en cours...`); + + // Formater les heures correctement pour SQL Server (ajouter :00 pour les secondes) + const formatTimeForSql = (timeString) => { + if (!timeString) return null; + // Si le format est HH:MM, ajouter :00 pour les secondes + if (timeString.match(/^\d{2}:\d{2}$/)) { + return timeString + ':00'; + } + return timeString; + }; + + const heureDebutFormatted = formatTimeForSql(heure_debut); + const heureFinFormatted = formatTimeForSql(heure_fin); + + console.log('Heures formatées:', { + original_debut: heure_debut, + formatted_debut: heureDebutFormatted, + original_fin: heure_fin, + formatted_fin: heureFinFormatted + }); + + // Mettre à jour la déclaration + await pool.request() + .input('id', sql.Int, id) + .input('date', sql.Date, date) + .input('duree', sql.Float, duree) + .input('heure_debut', sql.VarChar(8), heureDebutFormatted) + .input('heure_fin', sql.VarChar(8), heureFinFormatted) + .input('description', sql.NVarChar, description || null) + .input('type_demande_id', sql.Int, type_demande_id) + .query(` + UPDATE declarations + SET date = @date, + duree = @duree, + heure_debut = ${heureDebutFormatted ? '@heure_debut' : 'NULL'}, + heure_fin = ${heureFinFormatted ? '@heure_fin' : 'NULL'}, + description = @description, + type_demande_id = @type_demande_id + WHERE id = @id + `); + + console.log(`✅ Déclaration ${id} modifiée avec succès`); + + res.json({ + success: true, + message: 'Déclaration modifiée avec succès' + }); + } catch (error) { + console.error('❌ Erreur modification déclaration:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + // Route pour récupérer les déclarations (ADAPTÉE AU NOUVEAU SYSTÈME) app.get('/api/get_declarations', async (req, res) => { try { @@ -600,17 +753,15 @@ app.get('/api/get_declarations', async (req, res) => { switch (systemStatus.operatingMode) { case 'new_with_view': - // Avec vue Formateurs + // Avec vue v_Formateurs_CD result = await pool.request().query(` SELECT d.id, d.utilisateur_id, d.formateur_email_fk as formateur_email, - f.displayName as formateur_nom_complet, - f.surname as nom, - f.givenname as prenom, + f.Nom_brut as formateur_nom_complet, f.Campus, - f.departement, + f.Contrat, td.id as type_demande_id, td.libelle as activityType, d.date, @@ -621,7 +772,7 @@ app.get('/api/get_declarations', async (req, res) => { d.status FROM declarations d INNER JOIN types_demandes td ON d.type_demande_id = td.id - LEFT JOIN [dbo].[Formateurs] f ON d.formateur_email_fk = f.userPrincipalName + LEFT JOIN [GTF].[dbo].[v_Formateurs_CD] f ON d.formateur_email_fk = f.userPrincipalName ORDER BY d.date DESC `); break; @@ -702,8 +853,32 @@ app.get('/api/get_declarations', async (req, res) => { // Traitement selon le mode let processedResults = []; - if (systemStatus.operatingMode.startsWith('new_')) { - // Nouveau système - données déjà enrichies par les jointures + if (systemStatus.operatingMode === 'new_with_view') { + // Nouveau système avec v_Formateurs_CD - parsing du Nom_brut + processedResults = result.recordset.map(row => { + const { nom, prenom } = parseNomBrut(row.formateur_nom_complet); + + return { + id: row.id, + utilisateur_id: row.utilisateur_id, + formateur_email: row.formateur_email, + type_demande_id: row.type_demande_id, + date: row.date, + duree: row.duree, + description: row.description, + heure_debut: formatSqlTime(row.heure_debut), + heure_fin: formatSqlTime(row.heure_fin), + status: row.status || 'pending', + activityType: row.activityType, + nom: nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'), + prenom: prenom, + campus: row.Campus || 'Non défini', + contrat: row.Contrat || '', + formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu' + }; + }); + } else if (systemStatus.operatingMode === 'new_with_local') { + // Table locale avec structure complète processedResults = result.recordset.map(row => ({ id: row.id, utilisateur_id: row.utilisateur_id, @@ -716,12 +891,30 @@ app.get('/api/get_declarations', async (req, res) => { heure_fin: formatSqlTime(row.heure_fin), status: row.status || 'pending', activityType: row.activityType, - // Informations formateur (peuvent être null si pas de jointure) nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'), prenom: row.prenom || '', campus: row.Campus || 'Non défini', formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu' })); + } else if (systemStatus.operatingMode === 'new_email_only') { + // Mode email seulement + processedResults = result.recordset.map(row => ({ + id: row.id, + utilisateur_id: row.utilisateur_id, + formateur_email: row.formateur_email, + type_demande_id: row.type_demande_id, + date: row.date, + duree: row.duree, + description: row.description, + heure_debut: formatSqlTime(row.heure_debut), + heure_fin: formatSqlTime(row.heure_fin), + status: row.status || 'pending', + activityType: row.activityType, + nom: row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu', + prenom: '', + campus: 'Non défini', + formateur_nom_complet: row.formateur_email || 'Utilisateur inconnu' + })); } else { // Ancien système - mapping manuel const knownMappings = { @@ -799,7 +992,7 @@ app.get('/api/debug-hash', (req, res) => { if (!email) { return res.json({ error: 'Email requis', - example: 'http://localhost:3002/api/debug-hash?email=oimer@ensup.eu' + example: '/api/debug-hash?email=oimer@ensup.eu' }); } @@ -812,7 +1005,7 @@ app.get('/api/debug-hash', (req, res) => { }); }); -// Nouvelle route pour les formateurs avec déclarations (ADAPTÉE) +// Nouvelle route pour les formateurs avec déclarations app.get('/api/formateurs-avec-declarations', async (req, res) => { try { let result; @@ -820,56 +1013,80 @@ app.get('/api/formateurs-avec-declarations', async (req, res) => { switch (systemStatus.operatingMode) { case 'new_with_view': result = await pool.request().query(` - SELECT DISTINCT - f.givenname, - f.surname, - f.displayName, - f.Campus, - f.userPrincipalName, - f.Jobtitle, - f.Contrat, - COUNT(d.id) as nb_declarations - FROM [dbo].[Formateurs] f - LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk - WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%') - AND d.id IS NOT NULL - GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat - ORDER BY f.surname, f.givenname - `); + SELECT DISTINCT + f.Nom_brut, + f.Campus, + f.userPrincipalName, + f.Contrat, + COUNT(d.id) as nb_declarations + FROM [GTF].[dbo].[v_Formateurs_CD] f + LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk + WHERE LOWER(f.Contrat) LIKE 'cd%' + AND d.id IS NOT NULL + GROUP BY f.Nom_brut, f.Campus, f.userPrincipalName, f.Contrat + ORDER BY f.Nom_brut + `); break; case 'new_with_local': result = await pool.request().query(` - SELECT DISTINCT - f.givenname, - f.surname, - f.displayName, - f.Campus, - f.userPrincipalName, - f.Jobtitle, - f.Contrat, - COUNT(d.id) as nb_declarations - FROM formateurs_local f - LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk - WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%') - AND d.id IS NOT NULL - GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat - ORDER BY f.surname, f.givenname - `); + SELECT DISTINCT + f.givenname, + f.surname, + f.displayName, + f.Campus, + f.userPrincipalName, + f.Jobtitle, + f.Contrat, + COUNT(d.id) as nb_declarations + FROM formateurs_local f + LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk + WHERE LOWER(f.Contrat) LIKE 'cd%' + AND d.id IS NOT NULL + GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat + ORDER BY f.surname, f.givenname + `); break; + default: + return res.json({ + success: false, + message: 'Cette fonctionnalité nécessite le nouveau système', + mode: systemStatus.operatingMode + }); } - const formateurs = result.recordset.map(f => ({ - userPrincipalName: f.userPrincipalName, - displayName: f.displayName, - nom: f.surname || '', - prenom: f.givenname || '', - campus: f.Campus || 'Non défini', - poste: f.Jobtitle || '', - contrat: f.Contrat || '', - nbDeclarations: f.nb_declarations, - displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim() - })); + let formateurs; + + if (systemStatus.operatingMode === 'new_with_view') { + // Parser le Nom_brut pour v_Formateurs_CD + formateurs = result.recordset.map(f => { + const { nom, prenom } = parseNomBrut(f.Nom_brut); + + return { + userPrincipalName: f.userPrincipalName, + displayName: f.Nom_brut, + nom: nom, + prenom: prenom, + campus: f.Campus || 'Non défini', + contrat: f.Contrat || '', + nbDeclarations: f.nb_declarations, + displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'}) - CDD`.trim() + }; + }); + } else { + // Structure normale pour formateurs_local + formateurs = result.recordset.map(f => ({ + userPrincipalName: f.userPrincipalName, + displayName: f.displayName, + nom: f.surname || '', + prenom: f.givenname || '', + campus: f.Campus || 'Non défini', + poste: f.Jobtitle || '', + contrat: f.Contrat || '', + nbDeclarations: f.nb_declarations, + displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim() + })); + } res.json({ success: true, @@ -893,21 +1110,20 @@ app.get('/api/formateurs-vue', async (req, res) => { console.log(`🔍 Récupération formateurs CDD seulement (mode: ${systemStatus.operatingMode})...`); let formateurs = []; - let result; // Déclarer result ici ! + let result; if (systemStatus.canAccessFormateurView) { - console.log('Utilisation de la vue Formateurs...'); + console.log('Utilisation de la vue v_Formateurs_CD...'); result = await pool.request().query(` SELECT userPrincipalName, - displayName, - surname, - givenname, + Nom_brut, Campus, Contrat - FROM [dbo].[Formateurs] - WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%' - ORDER BY surname, givenname + FROM [GTF].[dbo].[v_Formateurs_CD] + WHERE LOWER(Contrat) LIKE 'cd%' + + ORDER BY Nom_brut `); } else if (systemStatus.hasFormateurLocal) { console.log('Utilisation de formateurs_local...'); @@ -920,7 +1136,8 @@ app.get('/api/formateurs-vue', async (req, res) => { Campus, Contrat FROM formateurs_local - WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%' + WHERE LOWER(Contrat) LIKE 'cd%' + ORDER BY surname, givenname `); } else { @@ -939,9 +1156,18 @@ app.get('/api/formateurs-vue', async (req, res) => { if (result.recordset.length === 0) { console.log('Aucun formateur CDD trouvé, test sans filtre...'); - // Test sans le filtre CDD pour voir s'il y a des formateurs let testResult; - if (systemStatus.hasFormateurLocal) { + if (systemStatus.canAccessFormateurView) { + testResult = await pool.request().query(` + SELECT TOP 5 + userPrincipalName, + Nom_brut, + Campus, + Contrat + FROM [GTF].[dbo].[v_Formateurs_CD] + ORDER BY Nom_brut + `); + } else if (systemStatus.hasFormateurLocal) { testResult = await pool.request().query(` SELECT TOP 5 userPrincipalName, @@ -953,20 +1179,41 @@ app.get('/api/formateurs-vue', async (req, res) => { FROM formateurs_local ORDER BY surname, givenname `); + } + + if (testResult) { console.log('Test sans filtre:', testResult.recordset); console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]); } } - formateurs = result.recordset.map(f => ({ - userPrincipalName: f.userPrincipalName, - displayName: f.displayName, - nom: f.surname || '', - prenom: f.givenname || '', - campus: f.Campus || 'Non défini', - contrat: f.Contrat || '', - displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim() - })); + if (systemStatus.canAccessFormateurView) { + // Parser Nom_brut pour v_Formateurs_CD + formateurs = result.recordset.map(f => { + const { nom, prenom } = parseNomBrut(f.Nom_brut); + + return { + userPrincipalName: f.userPrincipalName, + displayName: f.Nom_brut, + nom: nom, + prenom: prenom, + campus: f.Campus || 'Non défini', + contrat: f.Contrat || '', + displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'})`.trim() + }; + }); + } else { + // Structure normale pour formateurs_local + formateurs = result.recordset.map(f => ({ + userPrincipalName: f.userPrincipalName, + displayName: f.displayName, + nom: f.surname || '', + prenom: f.givenname || '', + campus: f.Campus || 'Non défini', + contrat: f.Contrat || '', + displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim() + })); + } console.log(`✅ ${formateurs.length} formateurs CDD traités`); @@ -986,7 +1233,8 @@ app.get('/api/formateurs-vue', async (req, res) => { }); } }); -// ==================== ROUTES MICROSOFT GRAPH (INCHANGÉES) ==================== + +// ==================== ROUTES MICROSOFT GRAPH ==================== app.post('/api/auth', async (req, res) => { const { userPrincipalName } = req.body; @@ -1011,9 +1259,6 @@ app.post('/api/auth', async (req, res) => { } }); -/** - * Authentifier un utilisateur avec Microsoft Graph - */ async function authenticateUserWithGraph(userPrincipalName, accessToken) { try { const existingUser = await pool.request() @@ -1080,9 +1325,6 @@ async function authenticateUserWithGraph(userPrincipalName, accessToken) { } } -/** - * Route pour extraire 3 personnes du service administratif - */ app.get('/api/admin-users', async (req, res) => { console.log('=== Route /api/admin-users appelée (RH) ==='); @@ -1144,9 +1386,6 @@ app.get('/api/admin-users', async (req, res) => { } }); -/** - * Route pour obtenir tous les membres du groupe - */ app.get('/api/group-members', async (req, res) => { console.log('=== Route /api/group-members appelée (RH) ==='); @@ -1223,18 +1462,17 @@ app.get('/api/group-members', async (req, res) => { } }); -// Route de test à ajouter temporairement app.get('/api/test-permissions', async (req, res) => { try { - console.log('Test des permissions sur HP-TO-O365...'); + console.log('Test des permissions sur GTF...'); - // Test direct de la vue const result = await pool.request().query(` SELECT TOP 3 userPrincipalName, - displayName, - Campus - FROM [HP-TO-O365].[dbo].[V_Formateurs_Augmentees] + Nom_brut, + Campus, + Contrat + FROM [GTF].[dbo].[v_Formateurs_CD] `); res.json({ @@ -1637,9 +1875,6 @@ app.post('/api/login-hybrid', async (req, res) => { } }); -/** - * Route pour tester la table RH - */ app.get('/api/rh-test', async (req, res) => { try { if (!pool) { @@ -1660,9 +1895,117 @@ app.get('/api/rh-test', async (req, res) => { } }); -/** - * Route pour lister tous les utilisateurs de la table RH - */ +// Routes de clôture +app.get('/api/check-cloture', async (req, res) => { + try { + const { date, campus } = req.query; + + if (!date) { + return res.status(400).json({ error: 'Date requise' }); + } + + const moisAnnee = date.substring(0, 7); + + const result = await pool.request() + .input('mois_annee', sql.VarChar, moisAnnee) + .input('campus', sql.VarChar, campus || null) + .query(` + SELECT * FROM clotures_saisie + WHERE mois_annee = @mois_annee + AND (campus = @campus OR campus IS NULL OR @campus IS NULL) + `); + + res.json({ + cloture: result.recordset.length > 0, + details: result.recordset[0] || null + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/clotures', async (req, res) => { + try { + const result = await pool.request().query(` + SELECT * FROM clotures_saisie + ORDER BY date_cloture DESC + `); + + res.json({ + success: true, + clotures: result.recordset + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/cloturer-periode', async (req, res) => { + try { + const { mois_annee, date_debut, date_fin, campus, commentaire, email_rh } = req.body; + + if (!mois_annee || !date_debut || !date_fin) { + return res.status(400).json({ + success: false, + error: 'Données manquantes' + }); + } + + const existing = await pool.request() + .input('mois_annee', sql.VarChar, mois_annee) + .input('campus', sql.VarChar, campus || null) + .query(` + SELECT id FROM clotures_saisie + WHERE mois_annee = @mois_annee + AND (campus = @campus OR (campus IS NULL AND @campus IS NULL)) + `); + + if (existing.recordset.length > 0) { + return res.status(400).json({ + success: false, + error: 'Cette période est déjà clôturée' + }); + } + + await pool.request() + .input('mois_annee', sql.VarChar, mois_annee) + .input('date_debut', sql.Date, date_debut) + .input('date_fin', sql.Date, date_fin) + .input('campus', sql.VarChar, campus || null) + .input('cloture_par', sql.VarChar, email_rh) + .input('commentaire', sql.NVarChar, commentaire || null) + .query(` + INSERT INTO clotures_saisie + (mois_annee, date_debut, date_fin, campus, cloture_par, commentaire) + VALUES (@mois_annee, @date_debut, @date_fin, @campus, @cloture_par, @commentaire) + `); + + res.json({ + success: true, + message: 'Période clôturée avec succès' + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.delete('/api/rouvrir-periode/:id', async (req, res) => { + try { + const { id } = req.params; + + await pool.request() + .input('id', sql.Int, id) + .query('DELETE FROM clotures_saisie WHERE id = @id'); + + res.json({ + success: true, + message: 'Période réouverte avec succès' + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + app.get('/api/rh-users', async (req, res) => { try { const limit = req.query.limit ? parseInt(req.query.limit) : 50; @@ -1710,52 +2053,38 @@ async function startServer() { console.log(`📊 Mode de fonctionnement RH: ${systemStatus.operatingMode}`); console.log(''); console.log('Routes disponibles :'); - console.log('- GET /api/diagnostic (vérifier l\'état)'); - console.log('- POST /api/migrate (appliquer la migration)'); + console.log('- GET /api/diagnostic'); + console.log('- POST /api/migrate'); console.log('- GET /api/test'); console.log('- GET /api/db-test'); console.log('- GET /api/get_declarations'); console.log('- PUT /api/declarations/:id/status'); - console.log('- POST /api/exchange-token (Échange code Microsoft)'); - console.log('- POST /api/auth (Microsoft Graph - legacy)'); - console.log('- GET /api/admin-users (3 utilisateurs administratifs)'); - console.log('- GET /api/group-members (tous les membres du groupe)'); - console.log('- GET /api/formateurs-avec-declarations'); + console.log('- DELETE /api/declarations/:id'); + console.log('- PUT /api/declarations/:id'); + console.log('- POST /api/exchange-token'); console.log('- GET /api/formateurs-vue'); - console.log('- GET /api/rh-test (test table RH)'); - console.log('- GET /api/rh-users (liste utilisateurs RH)'); + console.log('- GET /api/clotures'); + console.log('- POST /api/cloturer-periode'); + console.log('- DELETE /api/rouvrir-periode/:id'); console.log(''); switch (systemStatus.operatingMode) { case 'new_with_view': - console.log('✅ Système optimal - utilise la vue Formateurs'); + console.log('✅ Système optimal - utilise la vue v_Formateurs_CD'); break; case 'new_with_local': console.log('⚠️ Mode dégradé - utilise la table formateurs_local'); - console.log('💡 Conseil: Vérifier les permissions sur HP-TO-O365'); break; case 'new_email_only': console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs'); break; case 'legacy_hash': console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash'); - console.log('💡 Conseil: Appliquer la migration avec POST /api/migrate'); break; } - - if (!CLIENT_SECRET) { - console.warn('⚠️ Variable d\'environnement manquante: CLIENT_SECRET'); - console.warn(' Ajoutez CLIENT_SECRET dans votre fichier .env'); - } else { - console.log('✅ Configuration Microsoft OAuth OK'); - console.log(` Client ID: ${CLIENT_ID}`); - console.log(` Tenant ID: ${TENANT_ID}`); - console.log(` Redirect URI: ${REDIRECT_URI}`); - } }); } -// Arrêt propre process.on('SIGINT', async () => { console.log('Arrêt du serveur RH...'); if (pool) { @@ -1764,5 +2093,4 @@ process.on('SIGINT', async () => { process.exit(0); }); -// Démarrer startServer(); \ No newline at end of file diff --git a/GTFRRH/project/backend/images/Ensup.png b/GTFRRH/project/backend/images/Ensup.png new file mode 100644 index 0000000..dc3b5d1 Binary files /dev/null and b/GTFRRH/project/backend/images/Ensup.png differ diff --git a/GTFRRH/project/mailInstance.tsx b/GTFRRH/project/mailInstance.tsx deleted file mode 100644 index 0b5e43a..0000000 --- a/GTFRRH/project/mailInstance.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// msalInstance.ts -import { PublicClientApplication } from "@azure/msal-browser"; -import { msalConfig } from "./Authconfig"; - -export const msalInstance = new PublicClientApplication(msalConfig); - -// Important : initialiser avant usage -(async () => { - await msalInstance.initialize(); -})(); - diff --git a/GTFRRH/project/src/.dockerignore b/GTFRRH/project/src/.dockerignore new file mode 100644 index 0000000..daf7cac --- /dev/null +++ b/GTFRRH/project/src/.dockerignore @@ -0,0 +1,27 @@ +# Dépendances +node_modules + +# Cache et build +.vite +dist +node_modules/.vite +*.log + +# Environnement +.env +.env.local +.env.*.local + +# IDE +.vscode +.idea +*.swp +*.swo + +# Git +.git +.gitignore + +# Autres +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/GTFRRH/project/AuthConfig.tsx b/GTFRRH/project/src/AuthConfig.tsx similarity index 92% rename from GTFRRH/project/AuthConfig.tsx rename to GTFRRH/project/src/AuthConfig.tsx index b1d14ba..73b6d4d 100644 --- a/GTFRRH/project/AuthConfig.tsx +++ b/GTFRRH/project/src/AuthConfig.tsx @@ -5,7 +5,7 @@ export const msalConfig = { auth: { clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID - redirectUri: "http://localhost:5174" + redirectUri: "https://mygtf-rh.ensup-adm.net" }, cache: { cacheLocation: "sessionStorage", diff --git a/GTFRRH/project/src/components/CloturePeriode.tsx b/GTFRRH/project/src/components/CloturePeriode.tsx new file mode 100644 index 0000000..1addf52 --- /dev/null +++ b/GTFRRH/project/src/components/CloturePeriode.tsx @@ -0,0 +1,257 @@ +import React, { useState, useEffect } from 'react'; +import { Lock, Unlock, Calendar, AlertCircle } from 'lucide-react'; + +interface Cloture { + id: number; + mois_annee: string; + date_debut: string; + date_fin: string; + campus: string | null; + cloture_par: string; + date_cloture: string; + commentaire: string | null; +} + +const ClotureManager: React.FC = () => { + const [clotures, setClotures] = useState([]); + const [selectedMonth, setSelectedMonth] = useState(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + }); + const [selectedCampus, setSelectedCampus] = useState('all'); + const [commentaire, setCommentaire] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille']; + + const loadClotures = async () => { + try { + const response = await fetch('/api/clotures'); + const data = await response.json(); + if (data.success) { + setClotures(data.clotures); + } + } catch (err) { + console.error('Erreur chargement clôtures:', err); + } + }; + + useEffect(() => { + loadClotures(); + }, []); + + const handleCloturer = async () => { + try { + setLoading(true); + setError(''); + + const [year, month] = selectedMonth.split('-'); + + const dateDebutStr = `${year}-${month}-01`; + const lastDay = new Date(parseInt(year), parseInt(month), 0).getDate(); + const dateFinStr = `${year}-${month}-${String(lastDay).padStart(2, '0')}`; + + const response = await fetch('/api/cloturer-periode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mois_annee: selectedMonth, + date_debut: dateDebutStr, + date_fin: dateFinStr, + campus: selectedCampus === 'all' ? null : selectedCampus, + commentaire + }) + }); + + const data = await response.json(); + + if (data.success) { + alert('Période clôturée avec succès'); + setCommentaire(''); + loadClotures(); + } else { + setError(data.error || 'Erreur lors de la clôture'); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleRouvrir = async (id: number) => { + if (!confirm('Voulez-vous vraiment rouvrir cette période ?')) return; + + try { + const response = await fetch(`/api/rouvrir-periode/${id}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + alert('Période réouverte avec succès'); + loadClotures(); + } + } catch (err) { + console.error('Erreur:', err); + } + }; + + const formatDateSQL = (dateString: string) => { + if (!dateString) return 'Date inconnue'; + + try { + let dateOnly = dateString; + + if (dateString.includes('T')) { + dateOnly = dateString.split('T')[0]; + } + + const [year, month, day] = dateOnly.split('-').map(num => parseInt(num, 10)); + const date = new Date(Date.UTC(year, month - 1, day)); + + if (isNaN(date.getTime())) { + return 'Date invalide'; + } + + return date.toLocaleDateString('fr-FR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC' + }); + } catch (error) { + console.error('Erreur formatage date:', dateString, error); + return 'Date invalide'; + } + }; + + return ( +
+
+ +

+ Gestion des clôtures de saisie +

+
+ + {error && ( +
+
+ + {error} +
+
+ )} + +
+

+ Clôturer une nouvelle période +

+ +
+
+ + setSelectedMonth(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-4 py-2" + /> +
+ +
+ + +
+ +
+ + setCommentaire(e.target.value)} + placeholder="Ex: Clôture mensuelle" + className="w-full border border-gray-300 rounded-lg px-4 py-2" + /> +
+
+ + +
+ +
+

+ Périodes clôturées ({clotures.length}) +

+ + {clotures.length === 0 ? ( +

+ Aucune période clôturée +

+ ) : ( +
+ {clotures.map(cloture => ( +
+
+
+ + + {formatDateSQL(cloture.date_debut)} + {' → '} + {formatDateSQL(cloture.date_fin)} + + + ({cloture.mois_annee}) + +
+
+ Campus: {cloture.campus || 'Tous'} • + Clôturé le {formatDateSQL(cloture.date_cloture)} + {cloture.commentaire && ` • ${cloture.commentaire}`} +
+
+ +
+ ))} +
+ )} +
+
+ ); +}; + +export default ClotureManager; \ No newline at end of file diff --git a/GTFRRH/project/src/components/Login.tsx b/GTFRRH/project/src/components/Login.tsx index dd28b91..a0e56c6 100644 --- a/GTFRRH/project/src/components/Login.tsx +++ b/GTFRRH/project/src/components/Login.tsx @@ -1,208 +1,99 @@ -import React, { useState, useEffect } from 'react'; -import { useAuth } from '../context/AuthContext'; -import { useNavigate } from 'react-router-dom'; -import { AlertTriangle, Users } from 'lucide-react'; +import React, { useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import { useNavigate } from "react-router-dom"; +import { AlertTriangle } from "lucide-react"; const Login = () => { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const navigate = useNavigate(); - const { loginWithO365, isAuthorized } = useAuth(); - - // Configuration du backend Node.js - const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3002'; - - // Redirection si déjà connecté - useEffect(() => { - if (isAuthorized) { - navigate('/dashboard'); - } - }, [isAuthorized, navigate]); + const { loginWithO365 } = useAuth(); const handleO365Login = async () => { setIsLoading(true); - setError(''); + setError(""); try { - // 1. Connexion Office 365 via votre contexte existant const success = await loginWithO365(); - if (!success) { - setError("Erreur lors de l'initialisation de la connexion Office 365"); - setIsLoading(false); - return; + setError("Erreur lors de la connexion Office 365"); + } else { + navigate("/dashboard"); } - - // Le reste sera géré par l'AuthContext lors du retour de Microsoft - // (exchangeCodeForToken + vérification backend) - } catch (err: any) { - console.error('Erreur O365:', err); setError(err.message || "Erreur lors de la connexion Office 365"); + } finally { setIsLoading(false); } }; - // Fonction pour traiter l'authentification complète (appelée après retour de Microsoft) - const completeAuthentication = async () => { - try { - // 2. Récupération du token et des infos utilisateur - const token = localStorage.getItem("o365_token"); - const userEmail = localStorage.getItem("user_email"); - - if (!token || !userEmail) { - setError("Token ou email utilisateur manquant"); - return; - } - - // 3. Vérification de l'autorisation via votre backend Node.js - const authResponse = await fetch(`${BACKEND_URL}/api/check-user-groups`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - userPrincipalName: userEmail - }) - }); - - if (!authResponse.ok) { - throw new Error(`Erreur serveur: ${authResponse.status}`); - } - - const authData = await authResponse.json(); - console.log("Résultat autorisation backend :", authData); - - if (!authData.authorized) { - setError(authData.message || "Utilisateur non autorisé"); - return; - } - - // 4. Stocker les informations utilisateur complémentaires - localStorage.setItem("user_data", JSON.stringify(authData.user)); - localStorage.setItem("user_role", authData.role); - localStorage.setItem("local_user_id", authData.localUserId.toString()); - - console.log("Utilisateur autorisé :", authData.user); - - // 5. Optionnel : Déclencher une synchronisation initiale si c'est le premier login - try { - const syncResponse = await fetch(`${BACKEND_URL}/api/initial-sync`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } - }); - - if (syncResponse.ok) { - const syncData = await syncResponse.json(); - console.log("Synchronisation terminée :", syncData); - } - } catch (syncError) { - console.warn("Erreur synchronisation (non bloquante):", syncError); - // Ne pas bloquer la connexion pour une erreur de sync - } - - // 6. Redirection vers dashboard - navigate('/dashboard'); - - } catch (err: any) { - console.error('Erreur lors de la finalisation de l\'authentification:', err); - - // Gestion des erreurs spécifiques - if (err.message?.includes('non autorisé') || err.message?.includes('Accès refusé')) { - setError('Accès refusé : Vous devez être membre du groupe autorisé dans votre organisation.'); - } else if (err.message?.includes('AADSTS')) { - setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.'); - } else if (err.message?.includes('fetch')) { - setError('Impossible de contacter le serveur. Vérifiez que le backend est démarré.'); - } else { - setError(err.message || "Erreur lors de la finalisation de la connexion"); - } - } - }; - - // Vérifier s'il faut finaliser l'authentification au chargement - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const code = urlParams.get('code'); - const authToken = localStorage.getItem('o365_token'); - - // Si on a un code ET un token (authentification en cours), finaliser - if (code && authToken && !isAuthorized) { - setIsLoading(true); - completeAuthentication().finally(() => setIsLoading(false)); - } - }, [isAuthorized]); - return ( -
-
-
+
+ {/* Colonne gauche avec image + overlay */} +
+ ENSUP +
+

Bienvenue chez ENSUP

+

+ Espace RH - Gestion et Connexion Sécurisée +

+
+
- {/* En-tête */} + {/* Colonne droite avec le login */} +
+
-
- -
-

GTF - Espace RH

-

- Connectez-vous avec votre compte Office 365 +

+ Connexion à l’espace RH +

+

+ Utilisez votre compte Office 365

- {/* Bouton de connexion Office 365 */} + {/* Bouton violet ENSUP */} {/* Message d'erreur */} {error && ( -
-
- -
-

- {error.includes('Accès refusé') ? 'Accès refusé' : - error.includes('serveur') ? 'Problème de connexion' : - 'Erreur de connexion'} -

-

{error}

- {error.includes('groupe autorisé') && ( -

- Contactez votre administrateur pour être ajouté au groupe GTF. -

- )} - {error.includes('serveur') && ( -

- Vérifiez que le serveur backend est démarré sur le port 3002. -

- )} -
+
+ +
+

+ Erreur de connexion +

+

{error}

)} + + {/* Footer */} +

+ © {new Date().getFullYear()} ENSUP / ENSITECH. Tous droits réservés. +

); }; -export default Login; \ No newline at end of file +export default Login; diff --git a/GTFRRH/project/src/components/RHDashboard.tsx b/GTFRRH/project/src/components/RHDashboard.tsx index be0f0f6..bfdb3e1 100644 --- a/GTFRRH/project/src/components/RHDashboard.tsx +++ b/GTFRRH/project/src/components/RHDashboard.tsx @@ -1,20 +1,22 @@ import React, { useState, useEffect } from 'react'; -import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react'; +import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, Lock, Edit2, Trash2, X, Save } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; +import ClotureManager from '../components/CloturePeriode'; interface TimeEntry { id: string; formateur: string; campus: string; date: string; - type: 'preparation' | 'correction'; + type: 'preparation'; hours: number; description: string; status: 'pending' | 'approved' | 'rejected'; heure_debut?: string; heure_fin?: string; formateur_numero?: number; - formateur_email?: string; // NOUVEAU CHAMP + formateur_email?: string; + type_demande_id?: number; } interface FormateurAvecDeclarations { @@ -42,7 +44,8 @@ const RHDashboard: React.FC = () => { const month = String(now.getMonth() + 1).padStart(2, '0'); return `${year}-${month}`; }); - const { logout } = useAuth(); + const [showClotureManager, setShowClotureManager] = useState(false); + const { logout, user } = useAuth(); const [selectedFormateur, setSelectedFormateur] = useState('all'); const [timeEntries, setTimeEntries] = useState([]); const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState([]); @@ -51,54 +54,34 @@ const RHDashboard: React.FC = () => { const [error, setError] = useState(''); const [systemStatus, setSystemStatus] = useState(null); - // Fonction pour normaliser/mapper les noms de campus + const [editingEntry, setEditingEntry] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [editForm, setEditForm] = useState({ + date: '', + hours: 0, + heure_debut: '', + heure_fin: '', + description: '', + type: 'preparation' as 'preparation' + }); + const normalizeCampus = (campus: string): string => { const mapping: { [key: string]: string } = { - // Codes courts vers noms longs - 'MRS': 'Marseille', - 'mrs': 'Marseille', - 'NTE': 'Nantes', - 'nte': 'Nantes', - 'CGY': 'Cergy', - 'cgy': 'Cergy', - 'SQY': 'SQY', - 'sqy': 'SQY', - 'ensqy': 'SQY', - 'SQY/CGY': 'SQY/CGY', // Campus multi-sites - 'sqy/cgy': 'SQY/CGY', - - // Noms complets (au cas où) - 'Marseille': 'Marseille', - 'Nantes': 'Nantes', - 'Cergy': 'Cergy', + 'MRS': 'Marseille', 'mrs': 'Marseille', + 'NTE': 'Nantes', 'nte': 'Nantes', + 'CGY': 'Cergy', 'cgy': 'Cergy', + 'SQY': 'SQY', 'sqy': 'SQY', 'ensqy': 'SQY', + 'SQY/CGY': 'SQY/CGY', 'sqy/cgy': 'SQY/CGY', + 'Marseille': 'Marseille', 'Nantes': 'Nantes', 'Cergy': 'Cergy', 'Saint-Quentin-en-Yvelines': 'SQY', - 'MARSEILLE': 'Marseille', - 'NANTES': 'Nantes', - 'CERGY': 'Cergy', - - // Fallback - 'Non défini': 'Non défini', - '': 'Non défini' + 'MARSEILLE': 'Marseille', 'NANTES': 'Nantes', 'CERGY': 'Cergy', + 'Non défini': 'Non défini', '': 'Non défini' }; return mapping[campus] || campus || 'Non défini'; }; - // Fonction inversée pour obtenir le code depuis le label - const getCampusCode = (label: string): string => { - const reverseMapping: { [key: string]: string } = { - 'Marseille': 'MRS', - 'Nantes': 'NTE', - 'Cergy': 'CGY', - 'SQY': 'SQY', - 'SQY/CGY': 'SQY/CGY' - }; - return reverseMapping[label] || label; - }; - - // Liste des campus pour l'interface (incluant SQY/CGY comme cas spécial) const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille', 'SQY/CGY']; - // Fonction pour générer le hash (même logique que le backend - pour compatibilité) const generateHashFromEmail = (email: string): number => { let hash = 0; for (let i = 0; i < email.length; i++) { @@ -109,10 +92,9 @@ const RHDashboard: React.FC = () => { return Math.abs(hash) % 10000 + 1000; }; - // Fonction pour récupérer le statut du système const loadSystemStatus = async () => { try { - const response = await fetch('http://localhost:3002/api/diagnostic'); + const response = await fetch('/api/diagnostic'); if (response.ok) { const data = await response.json(); setSystemStatus(data.systemStatus); @@ -123,11 +105,9 @@ const RHDashboard: React.FC = () => { } }; - // Fonction améliorée pour associer les déclarations avec les formateurs const getFormateurInfo = (declaration: any): { nom: string, prenom: string, campus: string, displayText: string } => { console.log('🔍 Recherche formateur pour:', declaration); - // NOUVEAU SYSTÈME : Si on a un email, chercher le formateur correspondant if (declaration.formateur_email || declaration.formateur_email_fk) { const email = declaration.formateur_email || declaration.formateur_email_fk; const formateurTrouve = formateursAvecDeclarations.find(f => @@ -139,13 +119,12 @@ const RHDashboard: React.FC = () => { return { nom: formateurTrouve.nom, prenom: formateurTrouve.prenom, - campus: formateurTrouve.campus, // Garder le campus original de la vue + campus: formateurTrouve.campus, displayText: formateurTrouve.displayText }; } } - // FALLBACK : Si on a les champs nom/prenom/campus directement dans la déclaration (nouveau système) if (declaration.nom && declaration.nom !== 'undefined') { console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`); return { @@ -156,11 +135,8 @@ const RHDashboard: React.FC = () => { }; } - // ANCIEN SYSTÈME : Si on a un formateur_numero, essayer de le correspondre if (declaration.formateur_numero && formateursAvecDeclarations.length > 0) { console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`); - - // Méthode 1: Chercher par hash d'email const formateurParHash = formateursAvecDeclarations.find(f => { if (f.userPrincipalName) { const hash = generateHashFromEmail(f.userPrincipalName); @@ -180,7 +156,6 @@ const RHDashboard: React.FC = () => { } } - // DERNIER RECOURS : Mapping connu pour les cas difficiles const knownMappings: { [key: number]: { nom: string, prenom: string, campus: string } } = { 122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' }, 999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' } @@ -197,7 +172,6 @@ const RHDashboard: React.FC = () => { }; } - // FALLBACK FINAL const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu'; console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`); return { @@ -208,16 +182,13 @@ const RHDashboard: React.FC = () => { }; }; - // Fonction pour charger TOUS les formateurs (avec et sans déclarations) const loadFormateursAvecDeclarations = async () => { try { setLoadingFormateurs(true); setError(''); - console.log('🔄 Chargement de tous les formateurs...'); - // Essayer d'abord de récupérer tous les formateurs depuis la vue - const response = await fetch('http://localhost:3002/api/formateurs-vue'); + const response = await fetch('/api/formateurs-vue'); if (response.ok) { const data = await response.json(); @@ -225,7 +196,6 @@ const RHDashboard: React.FC = () => { console.log(`✅ ${data.formateurs.length} formateurs chargés depuis la vue`); console.log('📊 Mode serveur:', data.mode); - // Convertir le format et initialiser à 0 déclarations const tousLesFormateurs = data.formateurs.map((f: any) => ({ ...f, nbDeclarations: 0, @@ -242,16 +212,15 @@ const RHDashboard: React.FC = () => { } catch (error: any) { console.error('❌ Erreur chargement formateurs depuis la vue:', error); - // Fallback: essayer avec formateurs-avec-declarations (ancienne méthode) try { console.log('🔄 Tentative de fallback avec formateurs-avec-declarations...'); - const fallbackResponse = await fetch('http://localhost:3002/api/formateurs-avec-declarations'); + const fallbackResponse = await fetch('/api/formateurs-avec-declarations'); if (fallbackResponse.ok) { const fallbackData = await fallbackResponse.json(); if (fallbackData.success) { setFormateursAvecDeclarations(fallbackData.formateurs); console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations'); - setError(''); // Effacer l'erreur si le fallback fonctionne + setError(''); } } } catch (fallbackError) { @@ -263,14 +232,13 @@ const RHDashboard: React.FC = () => { } }; - // Charger les déclarations depuis votre API const loadDeclarations = async () => { try { setLoading(true); setError(''); console.log('🔄 Chargement des déclarations...'); - const response = await fetch('http://localhost:3002/api/get_declarations'); + const response = await fetch('/api/get_declarations'); if (!response.ok) { throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`); @@ -280,30 +248,29 @@ const RHDashboard: React.FC = () => { console.log('📊 Données déclarations reçues:', data.length); console.log('📊 Exemple de déclaration:', data[0]); - // Convertir les données (avec normalisation des campus) const convertedEntries: TimeEntry[] = data.map((d: any) => { const formateurInfo = getFormateurInfo(d); return { id: d.id.toString(), formateur: formateurInfo.displayText, - campus: normalizeCampus(formateurInfo.campus), // Normaliser le campus + campus: normalizeCampus(formateurInfo.campus), date: d.date.split('T')[0], - type: d.activityType, + type: 'preparation', hours: d.duree, description: d.description || '', status: d.status || 'pending', heure_debut: d.heure_debut || null, heure_fin: d.heure_fin || null, formateur_numero: d.formateur_numero, - formateur_email: d.formateur_email || d.formateur_email_fk + formateur_email: d.formateur_email || d.formateur_email_fk, + type_demande_id: d.type_demande_id }; }); setTimeEntries(convertedEntries); console.log(`✅ ${convertedEntries.length} déclarations traitées`); - // Log des formateurs uniques pour debug const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))]; console.log(`📊 ${formateursUniques.length} formateurs uniques dans les déclarations:`, formateursUniques.slice(0, 5)); @@ -315,14 +282,12 @@ const RHDashboard: React.FC = () => { } }; - // Charger les données au démarrage useEffect(() => { const loadData = async () => { await loadSystemStatus(); await loadFormateursAvecDeclarations(); await loadDeclarations(); - // Debug: Afficher tous les campus uniques trouvés dans les données setTimeout(() => { const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean); const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean); @@ -335,12 +300,10 @@ const RHDashboard: React.FC = () => { loadData(); }, []); - // Re-traiter les déclarations quand les formateurs sont chargés ET calculer les nombres de déclarations useEffect(() => { if (formateursAvecDeclarations.length > 0) { console.log('🔄 Re-traitement des déclarations avec les nouveaux formateurs...'); - // Si on a déjà des déclarations, les retraiter if (timeEntries.length > 0) { const updatedEntries = timeEntries.map(entry => { const originalDeclaration = { @@ -359,21 +322,17 @@ const RHDashboard: React.FC = () => { setTimeEntries(updatedEntries); } - // Calculer le nombre de déclarations pour chaque formateur const formateursAvecCompte = formateursAvecDeclarations.map(formateur => { const nbDeclarations = timeEntries.filter(entry => { - // Correspondance par email if (entry.formateur_email === formateur.userPrincipalName) { return true; } - // Correspondance par hash if (entry.formateur_numero && formateur.userPrincipalName) { const hash = generateHashFromEmail(formateur.userPrincipalName); return hash === entry.formateur_numero; } - // Correspondance par nom affiché const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim(); return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText; }).length; @@ -386,19 +345,15 @@ const RHDashboard: React.FC = () => { setFormateursAvecDeclarations(formateursAvecCompte); } - }, [timeEntries.length]); // Déclenché quand le nombre de déclarations change + }, [timeEntries.length]); - // Réinitialiser le filtre formateur quand on change de campus useEffect(() => { - // Si un formateur est sélectionné et qu'on change de campus if (selectedFormateur !== 'all') { - // Vérifier si le formateur sélectionné existe encore dans le campus filtré const formateurExists = formateursAvecDeclarations.some(formateur => { const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus; return campusMatch && formateur.displayText === selectedFormateur; }); - // Si le formateur n'existe pas dans le nouveau campus, le réinitialiser if (!formateurExists) { console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`); setSelectedFormateur('all'); @@ -406,7 +361,6 @@ const RHDashboard: React.FC = () => { } }, [selectedCampus, formateursAvecDeclarations]); - // Fonction de déconnexion const handleLogout = () => { localStorage.removeItem('token'); localStorage.removeItem('o365_token'); @@ -415,14 +369,83 @@ const RHDashboard: React.FC = () => { window.location.href = '/login'; }; - // Fonction de rafraîchissement complète const handleRefresh = async () => { await loadSystemStatus(); await loadFormateursAvecDeclarations(); await loadDeclarations(); }; - // Filtrage des données (avec normalisation des campus) + const handleEdit = (entry: TimeEntry) => { + setEditingEntry(entry); + setEditForm({ + date: entry.date, + hours: entry.hours, + heure_debut: entry.heure_debut || '', + heure_fin: entry.heure_fin || '', + description: entry.description, + type: entry.type + }); + setShowEditModal(true); + }; + + const handleSaveEdit = async () => { + if (!editingEntry) return; + + try { + const type_demande_id = editingEntry.type_demande_id || 1; + + console.log('Mise à jour avec type_demande_id:', type_demande_id); + + const response = await fetch(`/api/declarations/${editingEntry.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + date: editForm.date, + duree: editForm.hours, + heure_debut: editForm.heure_debut || null, + heure_fin: editForm.heure_fin || null, + description: editForm.description, + type_demande_id: type_demande_id + }) + }); + + const result = await response.json(); + + if (result.success) { + setShowEditModal(false); + setEditingEntry(null); + await loadDeclarations(); + } else { + alert('Erreur: ' + result.message); + } + } catch (error: any) { + console.error('Erreur lors de la modification:', error); + alert('Erreur lors de la modification: ' + error.message); + } + }; + + const handleDelete = async (entry: TimeEntry) => { + if (!confirm(`Êtes-vous sûr de vouloir supprimer cette déclaration de ${entry.formateur} du ${new Date(entry.date).toLocaleDateString('fr-FR')} ?`)) { + return; + } + + try { + const response = await fetch(`/api/declarations/${entry.id}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + await loadDeclarations(); + } else { + alert('Erreur: ' + result.message); + } + } catch (error: any) { + alert('Erreur lors de la suppression: ' + error.message); + } + }; + const filteredEntries = timeEntries.filter(entry => { const entryNormalizedCampus = normalizeCampus(entry.campus); const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus; @@ -431,75 +454,60 @@ const RHDashboard: React.FC = () => { return campusMatch && formateurMatch && monthMatch; }); - // Générer la liste des formateurs filtrés par campus (avec déduplication) const formateursUniques = formateursAvecDeclarations .filter(formateur => { - // Si "tous les campus" est sélectionné, afficher tous les formateurs if (selectedCampus === 'all') return true; - // Normaliser le campus du formateur et comparer avec le campus sélectionné const formateurCampusNormalized = normalizeCampus(formateur.campus); return formateurCampusNormalized === selectedCampus; }) .reduce((acc: any[], formateur) => { - // Déduplicquer par email (userPrincipalName) const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName); if (!existing) { acc.push({ displayText: formateur.displayText, nbDeclarations: formateur.nbDeclarations || 0, userPrincipalName: formateur.userPrincipalName, - campus: normalizeCampus(formateur.campus) // Normaliser le campus pour l'affichage + campus: normalizeCampus(formateur.campus) }); } else { - // Si le formateur existe déjà, additionner les déclarations existing.nbDeclarations += (formateur.nbDeclarations || 0); } return acc; }, []) .sort((a, b) => a.displayText.localeCompare(b.displayText)); - // Statistiques par campus (avec normalisation) const getStatsForCampus = (campus: string) => { const campusEntries = timeEntries.filter(entry => { const entryNormalizedCampus = normalizeCampus(entry.campus); return entryNormalizedCampus === campus; }); const totalHours = campusEntries.reduce((sum, entry) => sum + entry.hours, 0); - const preparationHours = campusEntries - .filter(entry => entry.type === 'preparation') - .reduce((sum, entry) => sum + entry.hours, 0); - const correctionHours = campusEntries - .filter(entry => entry.type === 'correction') - .reduce((sum, entry) => sum + entry.hours, 0); - return { totalHours, preparationHours, correctionHours }; + return { totalHours }; }; const handleExport = () => { - const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Heure Début', 'Heure Fin', 'Description']; + const csvHeaders = ['Date', 'Formateur', 'Campus', 'Heures', 'Heure Début', 'Heure Fin', 'Description']; const csvData = filteredEntries.map(entry => [ new Date(entry.date).toLocaleDateString('fr-FR'), entry.formateur || 'Non défini', entry.campus || 'Non défini', - entry.type === 'preparation' ? 'Préparation' : 'Correction', entry.hours.toString(), entry.heure_debut || 'Non défini', entry.heure_fin || 'Non défini', (entry.description || '').replace(/[\r\n]+/g, ' ').trim() ]); - // Fonction pour nettoyer les valeurs (enlever guillemets et point-virgules problématiques) - const cleanCsvValue = (value) => { + const cleanCsvValue = (value: any) => { return String(value || '') - .replace(/"/g, '""') // Doubler les guillemets - .replace(/;/g, ','); // Remplacer ; par , dans les données + .replace(/"/g, '""') + .replace(/;/g, ','); }; - // Utiliser le point-virgule comme séparateur pour Excel français const csvContent = '\uFEFF' + [csvHeaders, ...csvData] - .map(row => row.map(cell => cleanCsvValue(cell)).join(';')) // Point-virgule ici ! - .join('\r\n'); // Utiliser \r\n pour Windows + .map(row => row.map(cell => cleanCsvValue(cell)).join(';')) + .join('\r\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' @@ -563,17 +571,23 @@ const RHDashboard: React.FC = () => { return (
- {/* Header */}

Vue RH - GTF -

Gestion et suivi des déclarations des formateurs

+ {user && ( +
+
+ + Connecté en tant que {user.nom} {user.prenom} + +
+ )}
@@ -596,7 +610,6 @@ const RHDashboard: React.FC = () => {
- {/* Message d'erreur */} {error && (
@@ -607,7 +620,6 @@ const RHDashboard: React.FC = () => {
)} - {/* Indicateur de chargement */} {(loading || loadingFormateurs) && (
@@ -619,13 +631,23 @@ const RHDashboard: React.FC = () => { {!loading && !loadingFormateurs && ( <> - {/* Filtres */}

Filtres

+
+ +
+ {showClotureManager && } +
- {/* Statistiques par campus */}
{campuses.map(campus => { const stats = getStatsForCampus(campus); @@ -703,21 +724,12 @@ const RHDashboard: React.FC = () => { Total: {stats.totalHours}h
-
- Préparation: - {stats.preparationHours}h -
-
- Correction: - {stats.correctionHours}h -
); })}
- {/* Résumé des résultats */}
@@ -737,7 +749,6 @@ const RHDashboard: React.FC = () => {
- {/* Tableau des déclarations */}

@@ -750,30 +761,14 @@ const RHDashboard: React.FC = () => { - - - - - - - - + + + + + + + + @@ -798,21 +793,6 @@ const RHDashboard: React.FC = () => { - @@ -831,6 +811,24 @@ const RHDashboard: React.FC = () => { + )) )} @@ -838,6 +836,101 @@ const RHDashboard: React.FC = () => {
- Date - - Formateur - - Campus - - Type - - Heures - - Heure Début - - Heure Fin - - Description - DateFormateurCampusHeuresHeure DébutHeure FinDescriptionActions
{entry.campus} - -
- {entry.type === 'preparation' ? ( - - ) : ( - - )} - {entry.type === 'preparation' ? 'Préparation' : 'Correction'} -
-
-
{entry.hours}h {entry.description} +
+ + +
+

+ + {showEditModal && editingEntry && ( +
+
+
+

Modifier la déclaration

+ +
+ +
+
+ + +
+ +
+ + setEditForm({ ...editForm, date: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setEditForm({ ...editForm, hours: parseFloat(e.target.value) || 0 })} + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+ +
+
+ + setEditForm({ ...editForm, heure_debut: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setEditForm({ ...editForm, heure_fin: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ +