From 297dbc2ae503be798229c1f462eb4676eccf1424 Mon Sep 17 00:00:00 2001 From: Ouijdane IMER Date: Thu, 2 Oct 2025 12:55:40 +0200 Subject: [PATCH] GTFRH_V1 --- GTFRRH/docker-compose.yml | 30 + GTFRRH/project/DockerfileGTFRH.frontend | 42 ++ .../backend/config/DockerfileGTFRH.backend | 18 + GTFRRH/project/backend/config/package.json | 9 +- GTFRRH/project/backend/config/serv.js | 622 +++++++++++++----- GTFRRH/project/backend/images/Ensup.png | Bin 0 -> 29632 bytes GTFRRH/project/mailInstance.tsx | 11 - GTFRRH/project/src/.dockerignore | 27 + GTFRRH/project/{ => src}/AuthConfig.tsx | 2 +- .../project/src/components/CloturePeriode.tsx | 257 ++++++++ GTFRRH/project/src/components/Login.tsx | 211 ++---- GTFRRH/project/src/components/RHDashboard.tsx | 419 +++++++----- GTFRRH/project/src/context/AuthContext.tsx | 6 +- GTFRRH/project/vite.config.ts | 25 +- package.json | 4 +- 15 files changed, 1184 insertions(+), 499 deletions(-) create mode 100644 GTFRRH/docker-compose.yml create mode 100644 GTFRRH/project/DockerfileGTFRH.frontend create mode 100644 GTFRRH/project/backend/config/DockerfileGTFRH.backend create mode 100644 GTFRRH/project/backend/images/Ensup.png delete mode 100644 GTFRRH/project/mailInstance.tsx create mode 100644 GTFRRH/project/src/.dockerignore rename GTFRRH/project/{ => src}/AuthConfig.tsx (92%) create mode 100644 GTFRRH/project/src/components/CloturePeriode.tsx 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 0000000000000000000000000000000000000000..dc3b5d151527cd77ad21b1d320009d86471872c4 GIT binary patch literal 29632 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 literal 0 HcmV?d00001 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" + /> +
+
+ +
+ +