From 11f374538e33728151b01a2df52633f98852a586 Mon Sep 17 00:00:00 2001 From: Imer ouijdane Date: Tue, 19 May 2026 15:17:34 +0200 Subject: [PATCH] =?UTF-8?q?version=5FR=C3=B4le=5FPresident=20Version=5FCha?= =?UTF-8?q?tbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndf/public/backend/server.js | 1011 +++++++++++++++++++++++-- ndf/public/img/emma-avatar.jpg | Bin 0 -> 30914 bytes ndf/src/context/AuthContext.tsx | 8 +- ndf/src/pages/Dashboard.tsx | 304 +++++++- ndf/src/pages/NdfChatbot.tsx | 39 + ndf/src/pages/NouvelleNote.tsx | 129 +++- ndf/src/pages/PresidentValidation.tsx | 693 +++++++++++++++++ 7 files changed, 2036 insertions(+), 148 deletions(-) create mode 100644 ndf/public/img/emma-avatar.jpg create mode 100644 ndf/src/pages/PresidentValidation.tsx diff --git a/ndf/public/backend/server.js b/ndf/public/backend/server.js index bf438cc..bd0666d 100644 --- a/ndf/public/backend/server.js +++ b/ndf/public/backend/server.js @@ -757,12 +757,21 @@ app.get('/api/verificateur/notes', authenticateToken, async (req, res) => { c.nom + ' ' + c.prenom AS collaborateur, c.email AS collaborateurEmail, c.departement, c.campus, c.societe, +<<<<<<< HEAD v1.nom + ' ' + v1.prenom AS nomN1, v2.nom + ' ' + v2.prenom AS nomN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id +======= + v1.nom + ' ' + v1.prenom AS nomN1 + + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id + +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.statut = 'approuve' ${campusWhere} ORDER BY n.DateCreation DESC @@ -1016,7 +1025,13 @@ app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, r const existingFolder = fichiersExistants[0]?.folderPath; const nomDossier = existingFolder ? existingFolder.split('/')[1] +<<<<<<< HEAD : `${nd.collabPrenom}_${nd.collabNom}`.replace(/[^a-zA-Z0-9]/g, '_'); +======= + : `${nd.collabNom}_${nd.collabPrenom}` + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_'); +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const moisDossier = existingFolder ? existingFolder.split('/')[2] : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; @@ -1237,12 +1252,21 @@ app.post('/api/verificateur/notes/:id/refuser', authenticateToken, async (req, r SELECT n.id, n.reference, n.libelle, n.montant, n.statut, n.collaborateurId, n.lignesJson, c.prenom, c.nom, c.email, c.campus, +<<<<<<< HEAD v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1, v2.id AS n2Id, v2.email AS emailN2, v2.prenom AS prenomN2, v2.nom AS nomN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id +======= + v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1 + + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id + +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.id = @id AND n.statut = 'approuve' `); @@ -1722,16 +1746,15 @@ app.put('/api/profile/adresse', authenticateToken, async (req, res) => { // ================================================ async function genererReference(campus, nom, prenom) { const now = new Date(); - const annee = now.getFullYear(); + const jour = String(now.getDate()).padStart(2, '0'); const mois = String(now.getMonth() + 1).padStart(2, '0'); + const annee = now.getFullYear(); - // Normaliser le campus const campusCode = normalizeCampus(campus) || 'XXX'; - // Construire la partie nom : NOM.P (première lettre du prénom) const nomClean = (nom || '').toUpperCase() - .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // supprimer accents - .replace(/[^A-Z]/g, ''); // garder uniquement lettres + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Z]/g, ''); const prenomInitiale = (prenom || '').charAt(0).toUpperCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^A-Z]/g, ''); @@ -1746,14 +1769,19 @@ async function genererReference(campus, nom, prenom) { INSERT INTO NDFSequence (annee, compteur) VALUES (${annee}, 0) `); const result = await new sql.Request(tx).query(` - UPDATE NDFSequence SET compteur = compteur + 1 OUTPUT INSERTED.compteur WHERE annee = ${annee} + UPDATE NDFSequence SET compteur = compteur + 1 + OUTPUT INSERTED.compteur + WHERE annee = ${annee} `); await tx.commit(); - const num = String(result.recordset[0].compteur).padStart(3, '0'); - return `NDF-${annee}-${mois}-${campusCode}-${nomPart}`; - } catch (e) { await tx.rollback(); throw e; } + const num = String(result.recordset[0].compteur).padStart(2, '0'); + // Format : NDF01-SQY-IMER.O-05-05-2026 + return `NDF${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`; + } catch (e) { + await tx.rollback(); + throw e; + } } - async function uploadToSharePoint(file, ndfReference, collaborateurNomPrenom) { const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); @@ -1820,17 +1848,27 @@ async function downloadFromSharePoint(webUrl) { async function uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier) { const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); + const safeName = (file.originalname || 'fichier').replace(/[^a-zA-Z0-9._\-]/g, '_'); const fileName = safeName.startsWith(noteRef) ? safeName : `${noteRef}_${safeName}`; - const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${moisDossier}/${noteRef}`; + + // moisDossier format "2026-05" → annee="2026", mois="05" + const [annee, mois] = (moisDossier || '').split('-'); + + // Structure : Notes de Frais / Nom_Prenom / 2026 / 05 / NDF01-SQY-IMER.O-05-05-2026 / + const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${annee}/${mois}/${noteRef}`; const uploadPath = `${folderPath}/${fileName}`; const res = await axios.put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`, file.buffer, { - headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype || 'application/octet-stream' }, - maxBodyLength: Infinity, maxContentLength: Infinity + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': file.mimetype || 'application/octet-stream' + }, + maxBodyLength: Infinity, + maxContentLength: Infinity } ); return { fileName, uploadUrl: res.data.webUrl, folderPath }; @@ -1991,7 +2029,11 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0); const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple'; +<<<<<<< HEAD // ── Collaborateur + hiérarchie (2 requêtes SQL, inchangé) ──────── +======= + // ── Collaborateur + hiérarchie ──────────────────────────────────── +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const collabResult = await pool.request() .input('id', sql.Int, req.user.id) .query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`); @@ -2007,6 +2049,7 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { const hierarchie = await pool.request() .input('collabId', sql.Int, req.user.id) .query(` +<<<<<<< HEAD SELECT h.SuperieurId, h.[SuperieurIdn+2], s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1, s2.email AS emailN2 @@ -2017,13 +2060,30 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { `); const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null; +======= + SELECT h.SuperieurId, + s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1 + FROM HierarchieValidationNDF h + LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId + WHERE h.CollaborateurId = @collabId + `); + const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null; const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null; const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null; +<<<<<<< HEAD const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); const nomDossier = `${collaborateur.prenom}_${collaborateur.nom}`.replace(/\s+/g, '_'); const now = new Date(); +======= + const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); + const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}` + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_'); + const now = new Date(); +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; // ── Collecte des fichiers (QR global) ──────────────────────────── @@ -2034,7 +2094,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); if (qrToken.recordset.length && qrToken.recordset[0].fichiers) { const qrFichiers = JSON.parse(qrToken.recordset[0].fichiers); +<<<<<<< HEAD // ✅ Téléchargements QR globaux en parallèle +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const qrDownloads = await Promise.all( qrFichiers.map(f => downloadFromSharePoint(f.uploadUrl) @@ -2067,7 +2130,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers); if (!ligne.qrFiles) ligne.qrFiles = []; +<<<<<<< HEAD // téléchargement + upload SharePoint en parallèle pour chaque fichier de la ligne +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all( qrFichiers.map(async f => { try { @@ -2097,7 +2163,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { }) ); +<<<<<<< HEAD // ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const lignesJsonFinal = JSON.stringify(lignesParsed); // ── Upload justificatifs en parallèle ──────────────────────────── @@ -2108,7 +2177,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { ) )).filter(Boolean); +<<<<<<< HEAD // ── Préparer noteDataPDF (utilisé en sync ET en async) ─────────── +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const noteDataPDF = { reference, nomPrenom, @@ -2140,7 +2212,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) .input('statut', sql.NVarChar, 'enattente') .input('validateurN1Id', sql.Int, n1Id) +<<<<<<< HEAD .input('validateurN2Id', sql.Int, n2Id) +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) .input('montantHT', sql.Decimal, null) .input('tauxTVA', sql.Decimal, null) .input('montantTVA21', sql.Decimal, null) @@ -2154,21 +2229,33 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { INSERT INTO NoteDeFrais (reference, collaborateurId, libelle, montant, date, categorie, description, participants, nombreParticipants, sharepointUrl, fichiers, +<<<<<<< HEAD statut, validateurN1Id, validateurN2Id, +======= + statut, validateurN1Id, +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) montantHT, tauxTVA, montantTVA21, montantTVA55, montantTVA10, montantTVA20, km, indemniteKm, lignesJson) OUTPUT INSERTED.id, INSERTED.reference VALUES (@reference, @collaborateurId, @libelle, @montant, @date, @categorie, @description, @participants, @nombreParticipants, @sharepointUrl, @fichiers, +<<<<<<< HEAD @statut, @validateurN1Id, @validateurN2Id, +======= + @statut, @validateurN1Id, +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) @montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20, @km, @indemniteKm, @lignesJson) `); const noteCreee = insertResult.recordset[0]; +<<<<<<< HEAD // ── Insérer les lignes (séquentiel, rapide car SQL local) ──────── +======= + // ── Insérer les lignes ──────────────────────────────────────────── +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) for (let i = 0; i < lignesParsed.length; i++) { const l = lignesParsed[i]; const pdf = lignesPDF[i]; @@ -2203,12 +2290,16 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`); +<<<<<<< HEAD // ✅ Répondre immédiatement — le client n'attend plus le PDF +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) res.status(201).json({ success: true, id: noteCreee.id, reference: noteCreee.reference, fichiers: fichiersUploades, +<<<<<<< HEAD recapUrl: null, // sera mis à jour en BDD en arrière-plan pending: true, }); @@ -2216,11 +2307,23 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { // ── Traitement lourd en arrière-plan (non bloquant) ────────────── setImmediate(async () => { const fichiersAsync = [...fichiersUploades]; // copie locale pour l'async +======= + recapUrl: null, + pending: true, + }); + + // ── Traitement lourd en arrière-plan ───────────────────────────── + setImmediate(async () => { + const fichiersAsync = [...fichiersUploades]; +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) try { console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`); const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; +<<<<<<< HEAD // Fiche PDF soumission +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) let ficheResult = null; try { const fichePDF = await generateFicheSignee( @@ -2235,7 +2338,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { console.log(`✅ [ASYNC] Fiche soumission: ${ficheResult.fileName}`); } catch (e) { console.error('❌ [ASYNC] Fiche PDF:', e.message); } +<<<<<<< HEAD // Récap PDF complet +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) let recapUrl = null; try { const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles); @@ -2248,7 +2354,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { console.log(`✅ [ASYNC] Récap PDF: ${recapResult.fileName}`); } catch (e) { console.error('❌ [ASYNC] Récap PDF:', e.message); } +<<<<<<< HEAD // Mettre à jour BDD avec PDF final +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) try { await pool.request() .input('id', sql.Int, noteCreee.id) @@ -2263,7 +2372,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { `); } catch (e) { console.error('❌ [ASYNC] UPDATE BDD fichiers:', e.message); } +<<<<<<< HEAD // Notifications BDD (parallèle) +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all([ creerNotification({ destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission', @@ -2280,7 +2392,10 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { }).catch(e => console.error('❌ [ASYNC] Notif BDD N1:', e.message)) : Promise.resolve(), ]); +<<<<<<< HEAD // Emails (parallèle) +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all([ sendMailGraph( collaborateur.email, @@ -2400,10 +2515,12 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { const dateObj = new Date(date); const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); - const nomDossier = `${collaborateur.prenom}_${collaborateur.nom}`.replace(/\s+/g, '_'); - const now = new Date(); - const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - const tarifKmVal = await getTarifKm(); + const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}` + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_'); + const now = new Date(); + const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const tarifKmVal = await getTarifKm(); // ════════════════════════════════════════════════════════════════════ // CAS 1 — Note REFUSÉE ou NON_CONFORME_VERIF → créer une NOUVELLE note @@ -2493,16 +2610,16 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { const hierarchie = await pool.request() .input('collabId', sql.Int, userId) .query(` - SELECT h.SuperieurId, h.[SuperieurIdn+2], - s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1, - s2.email AS emailN2 + SELECT h.SuperieurId, + s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1 + FROM HierarchieValidationNDF h LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId - LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2] + WHERE h.CollaborateurId = @collabId `); const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; - const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null; + const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null; const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null; const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null; @@ -2521,7 +2638,7 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) .input('statut', sql.NVarChar, 'enattente') .input('validateurN1Id', sql.Int, n1Id) - .input('validateurN2Id', sql.Int, n2Id) + .input('km', sql.Decimal, kmTotal || null) .input('indemniteKm', sql.Decimal, indemKm || null) .input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed)) @@ -2530,14 +2647,14 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { INSERT INTO NoteDeFrais (reference, collaborateurId, libelle, montant, date, categorie, description, participants, nombreParticipants, - fichiers, statut, validateurN1Id, validateurN2Id, + fichiers, statut, validateurN1Id, km, indemniteKm, lignesJson, noteRefuseeId, DateCreation, DateModification) OUTPUT INSERTED.id, INSERTED.reference VALUES (@reference, @collaborateurId, @libelle, @montant, @date, @categorie, @description, @participants, @nombreParticipants, - @fichiers, @statut, @validateurN1Id, @validateurN2Id, + @fichiers, @statut, @validateurN1Id, @km, @indemniteKm, @lignesJson, @noteRefuseeId, GETDATE(), GETDATE()) `); @@ -2809,11 +2926,11 @@ app.get('/api/notes', authenticateToken, async (req, res) => { const result = await request.query(` SELECT n.*, - v1.nom + ' ' + v1.prenom as nomValidateurN1, - v2.nom + ' ' + v2.prenom as nomValidateurN2 + v1.nom + ' ' + v1.prenom as nomValidateurN1 + FROM NoteDeFrais n LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id - LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id + ${where} ORDER BY n.DateCreation DESC `); @@ -3005,7 +3122,6 @@ app.get('/api/notes/pending', authenticateToken, async (req, res) => { JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId WHERE (n.validateurN1Id = @userId AND n.statut = 'enattente') - OR (n.validateurN2Id = @userId AND n.statut = 'validen1') ORDER BY n.date DESC `); @@ -3111,7 +3227,7 @@ app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => { AND ( n.collaborateurId = ${userId} OR n.validateurN1Id = ${userId} - OR n.validateurN2Id = ${userId} + OR EXISTS ( SELECT 1 FROM UtilisateurRoles r WHERE r.collaborateur_id = ${userId} @@ -3152,26 +3268,17 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { const note = noteResult.recordset[0]; const n1Id = Number(note.validateurN1Id); - const n2Id = Number(note.validateurN2Id); const statutNote = note.statut?.trim(); let nouveauStatut = null, niveauValidation = null; if (n1Id === userId && statutNote === 'enattente') { niveauValidation = 'N1'; - nouveauStatut = action === 'valider' - ? (note.validateurN2Id && n2Id !== userId ? 'validen1' : 'approuve') - : 'refuse'; - } else if (n2Id === userId && statutNote === 'validen1') { - niveauValidation = 'N2'; nouveauStatut = action === 'valider' ? 'approuve' : 'refuse'; } else { return res.status(403).json({ error: 'Non autorisé à valider cette note' }); } - const dateField = niveauValidation === 'N1' ? 'dateValidationN1' : 'dateValidationN2'; - const commentaireField = niveauValidation === 'N1' ? 'commentaireN1' : 'commentaireN2'; - await pool.request() .input('id', sql.Int, id) .input('statut', sql.NVarChar, nouveauStatut) @@ -3180,8 +3287,8 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { .query(` UPDATE NoteDeFrais SET statut = @statut, - ${dateField} = GETDATE(), - ${commentaireField} = @commentaire, + dateValidationN1 = GETDATE(), + commentaireN1 = @commentaire, motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END, DateModification = GETDATE() WHERE id = @id @@ -3190,7 +3297,7 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { await pool.request() .input('noteId', sql.Int, id) .input('validateurId', sql.Int, userId) - .input('niveau', sql.NVarChar, niveauValidation) + .input('niveau', sql.NVarChar, 'N1') .input('action', sql.NVarChar, action) .input('commentaire', sql.NVarChar, commentaire ?? null) .input('motifRefus', sql.NVarChar, motifRefus ?? null) @@ -3202,7 +3309,11 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { (@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE()) `); +<<<<<<< HEAD res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation }); +======= + res.json({ success: true, statut: nouveauStatut, niveau: 'N1' }); +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) setImmediate(async () => { try { @@ -3223,6 +3334,7 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { .query(` SELECT n.reference, n.libelle, n.montant, n.date, n.categorie, n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson, +<<<<<<< HEAD n.commentaireN1, n.commentaireN2, n.dateValidationN1, n.dateValidationN2, c.prenom + ' ' + c.nom AS nomPrenom, c.prenom AS collabPrenom, c.nom AS collabNom, c.departement, @@ -3232,6 +3344,15 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id +======= + n.commentaireN1, n.dateValidationN1, + c.prenom + ' ' + c.nom AS nomPrenom, + c.prenom AS collabPrenom, c.nom AS collabNom, c.departement, + v1.prenom + ' ' + v1.nom AS nomValidateurN1 + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.id = @id `) ]); @@ -3243,6 +3364,7 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { if (!c || !nd) { console.error(`❌ [ASYNC] Données manquantes pour note ${id}`); return; +<<<<<<< HEAD } const nomValidateurActuel = v @@ -3259,8 +3381,20 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { if (nd.nomValidateurN1 && nd.dateValidationN1) signatures.push({ niveau: 'N1', nomPrenom: nd.nomValidateurN1, date: nd.dateValidationN1, action: 'valider', commentaire: nd.commentaireN1 ?? null }); signatures.push({ niveau: 'N2', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }); +======= +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) } + const nomValidateurActuel = v + ? `${v.prenom} ${v.nom}`.trim() + : `${req.user.prenom} ${req.user.nom}`.trim(); + + // ── Construire les signatures ────────────────────────────── + const signatures = [ + { niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null }, + { niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null } + ]; + const moisStr = (() => { if (!nd.date) return ''; const d = new Date(nd.date); @@ -3282,19 +3416,35 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { } const existingFolder = fichiersExistants[0]?.folderPath; +<<<<<<< HEAD const nomDossier = existingFolder ? existingFolder.split('/')[1] : `${nd.collabPrenom}_${nd.collabNom}`.replace(/[^a-zA-Z0-9]/g, '_'); const moisDossier = existingFolder ? existingFolder.split('/')[2] : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; +======= + // APRÈS + let nomDossier = existingFolder + ? existingFolder.split('/')[1] + : `${nd.collabNom}_${nd.collabPrenom}` + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_'); + let moisDossier = existingFolder + ? existingFolder.split('/')[2] + : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) // ── Génération PDF signé (fiche seule) ──────────────────── try { const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); +<<<<<<< HEAD const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : nouveauStatut === 'refuse' ? 'signe-refuse' : `signe-${nouveauStatut}`; +======= + const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse'; +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const signedResult = await uploadToSharePointHierarchique( { buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length }, @@ -3311,6 +3461,7 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { } catch (pdfError) { console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message); } +<<<<<<< HEAD // ── Régénérer le recap complet (fiche + justifs + signatures à jour) ── try { @@ -3482,6 +3633,140 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { } }); +======= + + // ── Régénérer le recap complet ──────────────────────────── + try { + const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap']; + const justifFiles = []; + + for (const f of fichiersExistants) { + const fname = (f.fileName || '').toLowerCase(); + if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue; + try { + const buf = await downloadFromSharePoint(f.uploadUrl); + const mimetype = fname.endsWith('.pdf') ? 'application/pdf' + : fname.endsWith('.png') ? 'image/png' : 'image/jpeg'; + justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); + } catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); } + } + + const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); + const recapResult = await uploadToSharePointHierarchique( + { + buffer: recapBuffer, + originalname: `${nd.reference}_recap.pdf`, + mimetype: 'application/pdf', + size: recapBuffer.length + }, + nd.reference, nomDossier, moisDossier + ); + + const fichiersAvecRecap = fichiersExistants.filter(f => { + const fname = (f.fileName || '').toLowerCase(); + return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement'); + }); + fichiersAvecRecap.push(recapResult); + + await pool.request() + .input('id', sql.Int, id) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap)) + .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); + + console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s): ${recapResult.fileName}`); + } catch (recapError) { + console.error('❌ [ASYNC] Régénération recap:', recapError.message); + } + + // ── Notifications + emails ──────────────────────────────── + const isApprouve = nouveauStatut === 'approuve'; + const isRefus = nouveauStatut === 'refuse'; + const titreCollab = isApprouve + ? `Note ${note.reference} approuvée` + : `Note ${note.reference} refusée`; + const msgCollab = isApprouve + ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.` + : `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`; + + const motifAffiche = motifRefus || commentaire || 'Non précisé'; + + const emailCollabHtml = isRefus + ? `
+
+

❌ Votre note de frais a été refusée

+

Une action de votre part est nécessaire

+
+
+

Bonjour ${c.prenom} ${c.nom},

+

Votre note ${note.reference} a été refusée par ${nomValidateurActuel}.

+
+
Motif du refus
+
${motifAffiche}
+
+
+ + + + + + +
Référence${note.reference}
Libellé${note.libelle}
Montant${montantFormate} €
Refusé par${nomValidateurActuel}
Date${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
+
+
+
📝 Que faire maintenant ?
+
    +
  1. Connectez-vous à la plateforme NDF
  2. +
  3. Rendez-vous dans Mes notes
  4. +
  5. Cliquez sur la note ${note.reference}
  6. +
  7. Corrigez les informations demandées
  8. +
  9. Resoumettez la note
  10. +
+
+
+ + ✏️ Modifier ma note → + +
+
+
` + : `
+
+

${titreCollab}

+
+
+

Bonjour ${c.prenom} ${c.nom},

+

${msgCollab}

+
+ Voir mes notes +
+
+
`; + + await Promise.all([ + creerNotification({ + destinataireId: c.id, + destinataireEmail: c.email, + type: isRefus ? 'refus' : 'validation', + titre: titreCollab, + message: msgCollab, + noteId: parseInt(id) + }).catch(e => console.error('❌ [ASYNC] Notif collab:', e.message)), + + sendMailGraph( + c.email, + isRefus ? `❌ Note refusée — action requise : ${note.reference}` : titreCollab, + emailCollabHtml + ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)), + ]); + + console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`); + + } catch (e) { + console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message); + } + }); + +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) } catch (error) { console.error('Erreur validation:', error.message); res.status(500).json({ error: error.message }); @@ -3535,11 +3820,11 @@ app.get('/api/notes/all', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' }); const result = await pool.request().query(` SELECT n.*, c.nom + ' ' + c.prenom as collaborateur, c.departement, c.campus, - v1.nom + ' ' + v1.prenom as nomN1, v2.nom + ' ' + v2.prenom as nomN2 + v1.nom + ' ' + v1.prenom as nomN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id - LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id + ORDER BY n.DateCreation DESC `); res.json(result.recordset); @@ -3967,7 +4252,7 @@ app.get('/api/notes-all', authenticateToken, async (req, res) => { } const statutFilter = hasAnyRole(req.user, 'ValidateurFinance') && !hasAnyRole(req.user, 'Finance') - ? `AND LOWER(n.statut) IN ('verifie', 'paiementenattente', 'payee')` + ? `AND LOWER(n.statut) IN ('verifie', 'en_attente_president', 'paiementenattente', 'payee')` : `AND LOWER(REPLACE(n.statut COLLATE Latin1_General_CI_AI, ' ', '')) IN ( 'approuve', 'approuv', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee' )`; @@ -3977,13 +4262,13 @@ app.get('/api/notes-all', authenticateToken, async (req, res) => { c.nom + ' ' + c.prenom AS collaborateur, c.departement, c.campus, c.societe, v1.nom + ' ' + v1.prenom AS nomN1, - v2.nom + ' ' + v2.prenom AS nomN2, + vf.nom + ' ' + vf.prenom AS nomVerificateur, n.dateVerification, n.commentaireVerification FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id - LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id + LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId WHERE 1=1 ${statutFilter} ${campusWhere} ORDER BY n.DateCreation DESC @@ -4322,55 +4607,53 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { }); // GET /api/paiements/xml-historique — liste les XML générés app.get('/api/paiements/xml-historique', authenticateToken, async (req, res) => { - if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) + if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur', 'President')) return res.status(403).json({ error: 'Accès réservé Finance' }); - + try { const { annee, mois } = req.query; - + const request = pool.request(); let where = `WHERE n.dateXml IS NOT NULL AND n.statut IN ('paiementenattente', 'payee')`; - - if (annee) { - request.input('annee', sql.Int, parseInt(annee)); - where += ` AND YEAR(n.dateXml) = @annee`; - } - if (mois) { - request.input('mois', sql.Int, parseInt(mois)); - where += ` AND MONTH(n.dateXml) = @mois`; - } - + + if (annee) { request.input('annee', sql.Int, parseInt(annee)); where += ` AND YEAR(n.dateXml) = @annee`; } + if (mois) { request.input('mois', sql.Int, parseInt(mois)); where += ` AND MONTH(n.dateXml) = @mois`; } + let campusWhere = ''; - if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { + if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur', 'President')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusWhere = `AND c.campus LIKE @campus`; } } - + const result = await request.query(` - SELECT - CAST(n.dateXml AS DATE) AS dateXmlJour, - MIN(n.dateXml) AS dateXmlExacte, - COUNT(*) AS nbNotes, - SUM(n.montant) AS totalMontant, - STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, - STRING_AGG(n.reference, ', ') AS listeReferences + SELECT + CONVERT(NVARCHAR(16), n.dateXml, 120) AS dateXmlJour, -- "2026-05-22 14:32" + MIN(n.dateXml) AS dateXmlExacte, + COUNT(*) AS nbNotes, + SUM(n.montant) AS totalMontant, + STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, + STRING_AGG(n.reference, ', ') AS listeReferences, + MAX(CASE WHEN n.presidentId IS NOT NULL + THEN p.prenom + ' ' + p.nom ELSE NULL END) AS presidentNom, + MAX(n.dateValidationPresident) AS dateValidationPresident, + MAX(n.commentairePresident) AS commentairePresident FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD p ON p.id = n.presidentId ${where} ${campusWhere} - GROUP BY CAST(n.dateXml AS DATE) - ORDER BY CAST(n.dateXml AS DATE) DESC + GROUP BY CONVERT(NVARCHAR(16), n.dateXml, 120) + ORDER BY CONVERT(NVARCHAR(16), n.dateXml, 120) DESC `); - + res.json(result.recordset); } catch (error) { console.error('GET /api/paiements/xml-historique:', error.message); res.status(500).json({ error: error.message }); } }); - // POST /api/paiements/regenerer-xml — régénère le XML pour un batch app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) @@ -5015,15 +5298,15 @@ app.post('/api/profil/documents/:type', authenticateToken, upload.single('file') // Notifier les Finance du même campus const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' }; const campusNorm = normalizeCampus(campus); - const financeResult = await pool.request() - .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') - .query(` - SELECT c.id, c.email, c.prenom, c.nom - FROM CollaborateurAD c - JOIN UtilisateurRoles r ON r.collaborateur_id = c.id - WHERE r.role = 'Finance' AND r.actif = 1 - AND c.campus LIKE @campus AND c.Actif = 1 - `); + const financeResult = await pool.request() + .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') + .query(` +SELECT DISTINCT c.id, c.email, c.prenom, c.nom +FROM CollaborateurAD c +JOIN UtilisateurRoles r ON r.collaborateur_id = c.id +WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1 + AND c.campus LIKE @campus AND c.Actif = 1 +`); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; for (const finance of financeResult.recordset) { @@ -5085,7 +5368,7 @@ app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => // GET /api/finance/documents-a-valider — Finance voit les docs en_attente de son campus app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => { - if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) + if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const request = pool.request(); @@ -5115,7 +5398,8 @@ app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) // PUT /api/finance/documents/:id/valider — Finance valide ou refuse un document // PUT /api/finance/documents/:id/valider app.put('/api/finance/documents/:id/valider', authenticateToken, async (req, res) => { - if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); + if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé Finance' }); try { const docId = parseInt(req.params.id); const { action, commentaire } = req.body; @@ -5661,6 +5945,565 @@ app.delete('/api/profil/vehicule', authenticateToken, async (req, res) => { } }); + +app.post('/api/paiements/soumettre-president', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé Finance / ValidateurFinance' }); + + const { noteIds } = req.body; + if (!Array.isArray(noteIds) || noteIds.length === 0) + return res.status(400).json({ error: 'Aucune note sélectionnée' }); + + try { + const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); + + // Vérifier que toutes les notes sont bien au statut 'verifie' + const notes = await pool.request().query(` + SELECT n.id, n.reference, n.montant, n.libelle, n.statut, + c.prenom, c.nom, c.email, c.campus, c.id AS collabId + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + WHERE n.id IN (${idList}) + AND n.statut IN ('approuve', 'verifie') + `); + + if (!notes.recordset.length) + return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' }); + + // Passer en 'en_attente_president' + await pool.request().query(` + UPDATE NoteDeFrais + SET presidentValidation = 'en_attente_president', + statut = 'en_attente_president', + DateModification = GETDATE() + WHERE id IN (${idList}) + AND statut IN ('approuve', 'verifie') + `); + + // Historique + for (const note of notes.recordset) { + try { + await pool.request() + .input('noteId', sql.Int, note.id) + .input('validateurId', sql.Int, req.user.id) + .input('commentaire', sql.NVarChar, `Soumis au Président par ${req.user.prenom} ${req.user.nom} (${notes.recordset.length} note(s))`) + .input('statut', sql.NVarChar, 'en_attente_president') + .query(` + INSERT INTO HistoriqueValidation + (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) + VALUES + (@noteId, @validateurId, 'PRESIDENT', 'soumettre', @commentaire, @statut, GETDATE()) + `); + } catch (e) { console.error('Histo President soumettre:', e.message); } + } + + // Trouver le(s) Président(s) → notifier + const presidents = await pool.request().query(` + SELECT c.id, c.email, c.prenom, c.nom + FROM CollaborateurAD c + JOIN UtilisateurRoles r ON r.collaborateur_id = c.id + WHERE r.role = 'President' AND r.actif = 1 AND c.Actif = 1 + `); + + const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2); + const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; + const validateurNom = `${req.user.prenom} ${req.user.nom}`; + + for (const president of presidents.recordset) { + try { + await creerNotification({ + destinataireId: president.id, + destinataireEmail: president.email, + type: 'validation', + titre: `💼 ${notes.recordset.length} note(s) en attente de votre validation`, + message: `${validateurNom} vous soumet ${notes.recordset.length} note(s) de frais pour un total de ${total} € en attente de votre validation avant virement.`, + noteId: null + }); + } catch (e) { console.error('Notif President BDD:', e.message); } + + try { + const lignesNotes = notes.recordset.map(n => + ` + ${n.reference} + ${n.prenom} ${n.nom} + ${parseFloat(n.montant).toFixed(2)} € + ` + ).join(''); + + await sendMailGraph( + president.email, + `💼 ${notes.recordset.length} note(s) de frais en attente de votre validation`, + `
+
+

💼 Notes de frais — Validation Président

+

${notes.recordset.length} note(s) soumises par ${validateurNom}

+
+
+

Bonjour ${president.prenom} ${president.nom},

+

${validateurNom} vous soumet les notes de frais suivantes pour validation avant génération du virement bancaire :

+
+ + + + + + + + + ${lignesNotes} + + + + + + +
RéférenceCollaborateurMontant
Total à virer${total} €
+
+
+ + ✅ Valider et générer le virement → + +
+

+ Cette validation est définitive. Le fichier XML de virement sera généré automatiquement. +

+
+
` + ); + } catch (e) { console.error('Email President:', e.message); } + } + + res.json({ + success: true, + nbNotes: notes.recordset.length, + total: parseFloat(total), + presidentsNotifies: presidents.recordset.length + }); + + } catch (error) { + console.error('Erreur POST /api/paiements/soumettre-president:', error.message); + res.status(500).json({ error: error.message }); + } +}); + + // ══════════════════════════════════════════════════════════════════════════ + // 2. Président → Récupérer les notes en attente de sa validation + // GET /api/president/notes + // ══════════════════════════════════════════════════════════════════════════ + app.get('/api/president/notes', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé au Président' }); + + try { + const result = await pool.request().query(` + SELECT n.*, + c.nom + ' ' + c.prenom AS collaborateur, + c.email AS collaborateurEmail, + c.departement, c.campus, c.societe, + v1.nom + ' ' + v1.prenom AS nomN1, + vf.nom + ' ' + vf.prenom AS nomVerificateur, + n.dateVerification, n.commentaireVerification + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id + LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId + WHERE n.statut = 'en_attente_president' + AND n.presidentValidation = 'en_attente_president' + ORDER BY n.DateModification DESC + `); + + res.json(result.recordset); + } catch (error) { + console.error('GET /api/president/notes:', error.message); + res.status(500).json({ error: error.message }); + } + }); + + // ══════════════════════════════════════════════════════════════════════════ + // 3. Président → Générer le XML + valider les notes + // POST /api/president/generer-xml + // Body: { noteIds: number[], commentaire?: string } + // ══════════════════════════════════════════════════════════════════════════ + app.post('/api/president/generer-xml', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé au Président' }); + + const { noteIds, commentaire } = req.body; + if (!Array.isArray(noteIds) || noteIds.length === 0) + return res.status(400).json({ error: 'Aucune note sélectionnée' }); + + try { + const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); + + const notes = await pool.request().query(` + SELECT n.id, n.reference, n.montant, n.libelle, n.date, + n.fichiers, n.lignesJson, n.categorie, n.DateCreation, + c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe, + c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays, + c.id AS collabId, c.email + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + WHERE n.id IN (${idList}) + AND n.statut = 'en_attente_president' + `); + + if (!notes.recordset.length) + return res.status(404).json({ error: 'Aucune note en attente de validation Président trouvée' }); + + // ── Validation IBAN / adresse ───────────────────────────────────── + const erreurs = []; + for (const n of notes.recordset) { + if (!n.iban) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`); + if (!n.bic) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`); + if (!n.adresse_rue || !n.adresse_cp || !n.adresse_ville || !n.adresse_pays) + erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`); + } + if (erreurs.length > 0) + return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs }); + + // ── Construction XML PAIN.001 ───────────────────────────────────── + const now = new Date(); + const annee = now.getFullYear(); + const mois = String(now.getMonth() + 1).padStart(2, '0'); + const todayISO = now.toISOString().split('T')[0]; + const creDtTm = now.toISOString().slice(0, 19); + const presidentNom = `${req.user.prenom} ${req.user.nom}`; + const msgId = `NDF-PRES-${annee}${mois}-${Date.now().toString().slice(-7)}`; + const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); + const totalFormate = total.toFixed(2); + + // Campus dominant pour le compte débiteur + const campusDominant = (() => { + const campusCounts = {}; + for (const n of notes.recordset) { + const code = normalizeCampus(n.campus || '') || n.campus || ''; + if (code) campusCounts[code] = (campusCounts[code] || 0) + 1; + } + return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null; + })(); + + const cfg = await getConfigDebiteur(campusDominant); + const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic, + companyAddress: dbtrAdrLine, companyCp: dbtrCp, + companyVille: dbtrVille, companyPays: dbtrPays } = cfg; + + let transactions = ''; + for (const n of notes.recordset) { + let ibanClair = 'FR0000000000000000000000000'; + try { + if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban); + else if (n.iban) ibanClair = n.iban; + } catch (e) { + console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message); + } + + const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`; + const adrLine = (n.adresse_rue || '').toUpperCase(); + const cp = n.adresse_cp || ''; + const ville = (n.adresse_ville || '').toUpperCase(); + const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase(); + const benefBicBlock = n.bic + ? `${n.bic}` + : `NOTPROVIDED`; + + transactions += ` + + + VIREMENT NUM:${n.reference} + ${n.reference} + + + ${parseFloat(n.montant).toFixed(2)} + + + ${benefBicBlock} + + + ${benefNom}${adrLine || cp || ville ? ` + ${cp ? ` + ${cp}` : ''}${ville ? ` + ${ville}` : ''} + ${pays}${adrLine ? ` + ${adrLine}` : ''} + ` : ''} + ${pays} + + + + ${ibanClair} + + + `; + } + + // Mention Président dans le message du fichier + const xml = ` + + + + + ${msgId} + ${creDtTm} + ${notes.recordset.length} + ${totalFormate} + + ${dbtrNom} + + + + ${msgId} + TRF + true + ${notes.recordset.length} + ${totalFormate} + + + SEPA + + + ${todayISO} + + ${dbtrNom} + + ${dbtrCp} + ${dbtrVille} + ${dbtrPays} + ${dbtrAdrLine} + + + + + ${dbtrIban} + + + + + + NOTPROVIDED + + + + SLEV${transactions} + + +`; + + // ── Mettre à jour les notes : 'paiementenattente' + validation Président ── + await pool.request() + .input('presidentId', sql.Int, req.user.id) + .input('commentaire', sql.NVarChar, commentaire || null) + .input('dateXml', sql.DateTime, now) + .query(` + UPDATE NoteDeFrais + SET statut = 'paiementenattente', + presidentValidation = 'valide_president', + presidentId = @presidentId, + dateValidationPresident = GETDATE(), + commentairePresident = @commentaire, + dateXml = @dateXml, + DateModification = GETDATE() + WHERE id IN (${idList}) + AND statut = 'en_attente_president' + `); + + // ── Historique ──────────────────────────────────────────────────── + for (const note of notes.recordset) { + try { + await pool.request() + .input('noteId', sql.Int, note.id) + .input('presidentId', sql.Int, req.user.id) + .input('commentaire', sql.NVarChar, `Validé par le Président ${presidentNom}${commentaire ? ' — ' + commentaire : ''}`) + .input('statut', sql.NVarChar, 'paiementenattente') + .query(` + INSERT INTO HistoriqueValidation + (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) + VALUES + (@noteId, @presidentId, 'PRESIDENT', 'valider_xml', @commentaire, @statut, GETDATE()) + `); + } catch (e) { console.error('Histo President valider:', e.message); } + } + + // ── Envoyer le XML immédiatement ────────────────────────────────── + const xmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${todayISO}.xml`; + res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1'); + res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`); + res.send(xml); + + // ── Traitement asynchrone : SharePoint + emails ValidateurFinance ── + setImmediate(async () => { + console.log(`🔄 [ASYNC PRESIDENT] Post-XML pour ${notes.recordset.length} note(s)...`); + + // Upload XML sur SharePoint + try { + const spXmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`; + const xmlUploadPath = `Virements/President/${annee}/${mois}/${spXmlFileName}`; + const accessToken = await getGraphToken(); + if (accessToken) { + await require('axios').put( + `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`, + Buffer.from(xml, 'utf-8'), + { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml' }, maxBodyLength: Infinity } + ); + console.log(`✅ [ASYNC PRESIDENT] XML uploadé : ${xmlUploadPath}`); + } + } catch (e) { + console.error('⚠️ [ASYNC PRESIDENT] Upload XML SharePoint:', e.message); + } + + // ── Retrouver le ValidateurFinance qui a soumis au président ────────── + try { + // On prend la première note du batch pour retrouver qui a soumis + // APRÈS (cherche sur TOUTES les notes du batch) + const allNoteIds = notes.recordset.map(n => n.id).join(','); + const histResult = await pool.request() + .query(` + SELECT TOP 1 h.ValidateurId, c.email, c.prenom, c.nom + FROM HistoriqueValidation h + JOIN CollaborateurAD c ON c.id = h.ValidateurId + WHERE h.NoteDeFraisId IN (${allNoteIds}) + AND h.Niveau = 'PRESIDENT' + AND h.Action = 'soumettre' + ORDER BY h.DateAction DESC + `); + + const soumetteur = histResult.recordset[0]; + + if (soumetteur) { + const dateLabel = now.toLocaleDateString('fr-FR', { + weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' + }); + const heureLabel = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + + const lignesNotes = notes.recordset.map(n => + ` + ${n.reference} + ${n.prenom} ${n.nom} + ${parseFloat(n.montant).toFixed(2)} € + ` + ).join(''); + + // Notification BDD + await creerNotification({ + destinataireId: soumetteur.ValidateurId, + destinataireEmail: soumetteur.email, + type: 'paiement', + titre: `✅ XML virement validé par le Président — ${notes.recordset.length} note(s)`, + message: `Le Président ${presidentNom} a validé et généré le XML de virement le ${dateLabel} à ${heureLabel} pour ${notes.recordset.length} note(s) — ${totalFormate} €.`, + noteId: null + }); + + // Email + await sendMailGraph( + soumetteur.email, + `✅ XML virement validé par le Président ${presidentNom}`, + `
+
+

✅ Virement validé par le Président

+

Le fichier XML a été généré et est prêt pour votre banque

+
+
+

Bonjour ${soumetteur.prenom} ${soumetteur.nom},

+

Le Président ${presidentNom} a validé et généré le fichier XML de virement bancaire pour les notes que vous lui avez soumises.

+ +
+
+ Date : ${dateLabel} à ${heureLabel}
+ Validé par : ${presidentNom}
+ Nombre de virements : ${notes.recordset.length}
+ Montant total : ${totalFormate} € +
+ ${commentaire ? `
💬 ${commentaire}
` : ''} +
+ +
+
+ + Détail des virements (${notes.recordset.length}) + +
+ + + + + + + + + ${lignesNotes} + + + + + + +
RéférenceCollaborateurMontant
Total${totalFormate} €
+
+ +

+ Vous pouvez re-télécharger ce fichier XML depuis la rubrique "XML virements" de la plateforme. +

+
+ + 🏦 Voir les XML virements → + +
+
+
` + ); + + console.log(`✅ [ASYNC PRESIDENT] ValidateurFinance notifié : ${soumetteur.email}`); + } else { + console.warn('⚠️ [ASYNC PRESIDENT] Soumetteur introuvable dans HistoriqueValidation'); + } + } catch (e) { + console.error('❌ [ASYNC PRESIDENT] Notification soumetteur:', e.message); + } + + console.log(`✅ [ASYNC PRESIDENT] Traitement terminé pour ${notes.recordset.length} note(s)`); + }); + + } catch (error) { + console.error('Erreur POST /api/president/generer-xml:', error.message); + res.status(500).json({ error: error.message }); + } + }); + + // ══════════════════════════════════════════════════════════════════════════ + // 4. Président → Historique de ses validations + // GET /api/president/historique + // ══════════════════════════════════════════════════════════════════════════ + app.get('/api/president/historique', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé au Président' }); + + try { + const result = await pool.request() + .input('presidentId', sql.Int, req.user.id) + .query(` + SELECT + CAST(n.dateXml AS DATE) AS dateXmlJour, + MIN(n.dateXml) AS dateXmlExacte, + n.dateValidationPresident, + n.commentairePresident, + COUNT(*) AS nbNotes, + SUM(n.montant) AS totalMontant, + STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, + STRING_AGG(n.reference, ', ') AS listeReferences + FROM NoteDeFrais n + WHERE n.presidentId = @presidentId + AND n.presidentValidation = 'valide_president' + GROUP BY CAST(n.dateXml AS DATE), n.dateValidationPresident, n.commentairePresident + ORDER BY CAST(n.dateXml AS DATE) DESC + `); + + res.json(result.recordset); + } catch (error) { + console.error('GET /api/president/historique:', error.message); + res.status(500).json({ error: error.message }); + } + }); + + // ================================================ // GESTION DES ERREURS // ================================================ diff --git a/ndf/public/img/emma-avatar.jpg b/ndf/public/img/emma-avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..342e8742e9cfda87453b9b8d528f9b5f38662868 GIT binary patch literal 30914 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 literal 0 HcmV?d00001 diff --git a/ndf/src/context/AuthContext.tsx b/ndf/src/context/AuthContext.tsx index 847dc7a..72cb902 100644 --- a/ndf/src/context/AuthContext.tsx +++ b/ndf/src/context/AuthContext.tsx @@ -28,6 +28,7 @@ interface AuthContextType { isSuperUser: boolean; isVerificateurFinance: boolean; isValidateurFinance: boolean; + isPresident: boolean; } const AuthContext = createContext(undefined); @@ -38,7 +39,7 @@ const VALID_ROLES = [ 'Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Finance', 'VerificateurFinance', 'ValidateurFinance', - 'superUtilisateur' + 'superUtilisateur','President' ]; function parseRoles(input: string | string[]): string[] { @@ -199,6 +200,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const isSuperUser = hasRole('superUtilisateur'); const isVerificateurFinance = hasRole('VerificateurFinance'); const isValidateurFinance = hasRole('ValidateurFinance'); + const isPresident = hasRole('President'); + return ( { isFinance, isSuperUser, isVerificateurFinance, - isValidateurFinance + isValidateurFinance, + isPresident }}> {children} diff --git a/ndf/src/pages/Dashboard.tsx b/ndf/src/pages/Dashboard.tsx index 340e4af..743028e 100644 --- a/ndf/src/pages/Dashboard.tsx +++ b/ndf/src/pages/Dashboard.tsx @@ -5,9 +5,17 @@ import { ThemeToggleButton } from '../context/ThemeContext'; import NouvelleNote from './NouvelleNote'; import VerificateurFinanceLight from './VerificateurFinanceLight'; import NDFChatbot from './NdfChatbot'; +<<<<<<< HEAD import QRCode from 'react-qr-code'; +======= +import PresidentValidation from './PresidentValidation'; +import QRCode from 'react-qr-code'; + + + +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) import { LayoutDashboard, PlusCircle, FileText, CheckSquare, History, CreditCard, User, LogOut, Receipt, Upload, Send, @@ -64,15 +72,15 @@ interface Note { participants?: string; nombreParticipants?: number; validateurN1Id?: number; - validateurN2Id?: number; + nomValidateurN1?: string; - nomValidateurN2?: string; + nomVerificateur?: string; datePaiement?: string; moisPaiement?: number; anneePaiement?: number; commentaireN1?: string; - commentaireN2?: string; + commentaireVerification?: string; dateVerification?: string; motifRefus?: string; @@ -329,9 +337,7 @@ const tagStatut = (statut: string) => { 'validn1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' }, 'validen1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' }, 'valide_n1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' }, - 'validn2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' }, - 'validen2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' }, - 'valide_n2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' }, + 'approuve': { bg: '#dcfce7', color: '#15803d', label: 'Approuve' }, 'verifie': { bg: '#ede9fe', color: '#7c3aed', label: 'Vérifié' }, 'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' }, @@ -367,8 +373,8 @@ const StatutStepper = ({ statut }: { statut: string }) => { const steps = [ { key: 'enattente', label: 'Soumise', icon: '📋' }, - { key: 'validen1', label: 'Validée N1', icon: '✅' }, - { key: 'validen2', label: 'Validée N2', icon: '✅' }, + { key: 'validen1', label: 'Validation', icon: '✅' }, + { key: 'approuve', label: 'Approuvée', icon: '🎉' }, { key: 'verifie', label: 'Vérifiée', icon: '🔍' }, { key: 'paiementenattente', label: 'En att. paiement', icon: '⏳' }, @@ -382,7 +388,7 @@ const StatutStepper = ({ statut }: { statut: string }) => { if (s === 'paiementenattente' || s === 'paiement_en_attente') return 5; if (s === 'verifie') return 4; if (s === 'approuve') return 3; - if (['validen2', 'valide_n2', 'validn2'].includes(s)) return 2; + if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1; if (s === 'non_conforme_verif') return -1; return 0; @@ -1318,6 +1324,7 @@ const InlinePreviewViewer = ({ item }: { item: { url: string; name: string } }) // ══════════════════════════════════════════════════════ const Dashboard = (): JSX.Element => { const { user, logout, isValidateur, isFinance, isSuperUser, isVerificateurFinance, isValidateurFinance } = useAuth(); + const isPresident = user?.roles?.includes('President') ?? false; // ── Rôles nouveaux ────────────────────────────────── @@ -1329,6 +1336,10 @@ const Dashboard = (): JSX.Element => { const [pending, setPending] = useState([]); const [profile, setProfile] = useState(null); const [inlinePreview, setInlinePreview] = useState<{ url: string; name: string } | null>(null); +<<<<<<< HEAD +======= + const [notesEnAttentePresident, setNotesEnAttentePresident] = useState([]); +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const [adresseForm, setAdresseForm] = useState({ adresse_rue: '', adresse_cp: '', adresse_ville: '', adresse_pays: 'France', societe: '' @@ -1721,6 +1732,12 @@ const Dashboard = (): JSX.Element => { .then(d => Array.isArray(d) && setNotesAVerifier(d)) .catch(() => { }); } + if (section === 'accueil' && (isFinance || isVerificateurFinance)) { + fetch(`${API}/api/finance/documents-a-valider`, { headers: hdrs }) + .then(r => r.ok ? r.json() : []) + .then(d => Array.isArray(d) && setDocsAValider(d)) + .catch(() => { }); + } // ✅ Charger pending aussi sur l'accueil si validateur if (section === 'validation' || (section === 'accueil' && canValidate)) { fetch(`${API}/api/notes/pending`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setPending(d)).catch(() => { }); @@ -1787,7 +1804,12 @@ const Dashboard = (): JSX.Element => { .then(d => setFiltresDisponibles(d)) .catch(() => { }); } - + if ((section === 'president' || section === 'accueil') && isPresident) { + fetch(`${API}/api/president/notes`, { headers: hdrs }) + .then(r => r.ok ? r.json() : []) + .then(d => Array.isArray(d) && setNotesEnAttentePresident(d)) + .catch(() => { }); + } // ── NOUVEAU : Vérificateur Finance ── if (section === 'verification' && isVerificateurFinance) { @@ -1950,6 +1972,21 @@ const Dashboard = (): JSX.Element => { showToast('⛔ Veuillez renseigner votre adresse complète dans "Mon profil" avant de soumettre une note.', 'error'); return; } + // Vérification documents obligatoires pour notes kilométriques + const hasKmLine = lignesExterne.some(l => l.categorie?.toLowerCase().includes('kilom')); + if (hasKmLine) { + const docs = await fetch(`${API}/api/profil/documents`, { headers: hdrs as HeadersInit }) + .then(r => r.ok ? r.json() : null).catch(() => null); + + if (!docs?.carte_grise || docs.carte_grise.statut === 'refuse') { + showToast('⛔ Votre carte grise doit être soumise dans "Mon profil" avant une note kilométrique.', 'error'); + return; + } + if (!docs?.permis || docs.permis.statut === 'refuse') { + showToast('⛔ Votre permis de conduire doit être soumis dans "Mon profil" avant une note kilométrique.', 'error'); + return; + } + } const lignesPayload = lignesExterne.map(l => { const isKm = l.categorie.toLowerCase().includes('kilom'); @@ -2050,6 +2087,7 @@ const Dashboard = (): JSX.Element => { docsavalider: 'Documents à valider', historiquepaiements: 'Historique des paiements', xmlgenerés: 'XML virements générés', + president: 'Validation Président — Virements bancaires', }; const menuItems = [ @@ -2062,8 +2100,14 @@ const Dashboard = (): JSX.Element => { // ── Nouveaux rôles ── ...(isVerificateurFinance ? [{ id: 'verification', icon: , label: 'Notes à vérifier', badge: notesAVerifier.length || undefined }] : []), ...(isValidateurFinance ? [{ id: 'paiements', icon: , label: 'Valider paiements', badge: notesVerifiees.length || undefined }] : []), + ...(isPresident ? [{ + id: 'president', + icon: , + label: 'Validation Président', + badge: notesEnAttentePresident.length || undefined + }] : []), // ── Rôles Finance classiques ── - ...(isFinance ? [{ id: 'docsavalider', icon: , label: 'Documents à valider', badge: docsAValider.filter((d: any) => d.statut === 'en_attente').length || undefined }] : []), + ...((isFinance || isVerificateurFinance) ? [{ id: 'docsavalider', icon: , label: 'Documents à valider', badge: docsAValider.filter((d: any) => d.statut === 'en_attente').length || undefined }] : []), ...(isRHAdmin ? [{ id: 'paiements', icon: , label: 'Paiements', badge: exceptions.length || undefined }] : []), ...((isRHAdmin || isValidateurFinance) ? [{ id: 'historiquepaiements', icon: , label: 'Historique paiements' }] : []), ...(isSuperUser ? [{ id: 'supervision', icon: , label: 'Supervision' }] : []), @@ -2482,7 +2526,7 @@ const Dashboard = (): JSX.Element => { ))} -
+
{/* ── SECTION 1 : MES NOTES ── */}
{
)} + {isVerificateurFinance && docsAValider.length > 0 && ( +
+
+
+
📄
+
+
+ Documents à valider + {docsAValider.filter((d: any) => d.statut === 'en_attente').length} +
+
+ RIB, cartes grises et permis à contrôler +
+
+
+ +
+
+ )} {isValidateurFinance && (
{ ); })()} +<<<<<<< HEAD {notesVerifiees.length > 0 && ( +======= + +
+
+ )} + + {/* ── SECTION : VIREMENTS À VALIDER (Président) ── */} + {isPresident && ( +
+
+
+
💼
+
+
+ Virements à valider + {notesEnAttentePresident.length > 0 && ( + {notesEnAttentePresident.length} + )} +
+
+ {notesEnAttentePresident.length === 0 + ? 'Aucun virement en attente' + : `${notesEnAttentePresident.length} virement(s) requièrent votre validation` + } +
+
+
+ +
+ + {/* Mini stat */} +
+
+ {notesEnAttentePresident.length} +
+
+ En attente de validation Président +
+
+ +
+ {notesEnAttentePresident.length === 0 ? ( +
+ + Aucun virement à valider ! +
+ ) : notesEnAttentePresident.slice(0, 4).map(note => ( +
nav('president')} + style={{ + display: 'flex', justifyContent: 'space-between', alignItems: 'center', + padding: '10px 12px', + border: '1.5px solid var(--border-card)', + borderRadius: 10, cursor: 'pointer', + background: 'var(--bg-card)', transition: 'all 0.15s', + }} + onMouseEnter={e => { e.currentTarget.style.borderColor = '#1d4ed8'; e.currentTarget.style.background = '#eff6ff'; }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-card)'; e.currentTarget.style.background = 'var(--bg-card)'; }} + > +
+
+ {note.reference || `#${note.id}`} +
+
+ {note.collaborateur} +
+
+ {note.libelle} +
+
+
+ {fmt(note.montant || 0)} +
+
+ ))} + + {notesEnAttentePresident.length > 0 && ( +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) )}
@@ -3048,6 +3241,8 @@ const Dashboard = (): JSX.Element => {
)} + + {/* ════════════════════════════════════════ */} {/* NOUVELLE NOTE */} @@ -3065,6 +3260,8 @@ const Dashboard = (): JSX.Element => { dateDebutInitiale={date} commentaireInitial={description} depensesInitiales={lignes} + onNavigateToProfil={() => nav('profil')} + /> )} @@ -3104,7 +3301,7 @@ const Dashboard = (): JSX.Element => { - + @@ -3741,12 +3938,20 @@ const Dashboard = (): JSX.Element => { {notesFiltreesPaiements.length} +<<<<<<< HEAD
@@ -4177,7 +4399,19 @@ const Dashboard = (): JSX.Element => {
); })()} - + {section === 'president' && isPresident && ( + + )} {/* ════════════════════════════════════════ */} {/* DOCUMENTS À VALIDER (Finance) */} {/* ════════════════════════════════════════ */} @@ -4295,6 +4529,7 @@ const Dashboard = (): JSX.Element => { {[ { label: 'Poste', value: profile?.poste }, { label: 'Département', value: profile?.departement }, + { label: 'Société', value: profile?.societe }, { label: 'Campus', value: profile?.campus ? normalizeCampus(profile.campus) : undefined }, { label: 'Type de contrat', value: profile?.TypeContrat }, { label: "Date d'entrée", value: profile?.DateEntree ? new Date(profile.DateEntree).toLocaleDateString('fr-FR') : undefined }, @@ -4320,7 +4555,11 @@ const Dashboard = (): JSX.Element => { textTransform: 'uppercase', letterSpacing: '0.6px', display: 'flex', alignItems: 'center', gap: 6, }}> +<<<<<<< HEAD 🏠 Votre Adresse postale & Société +======= + 🏠 Votre Adresse postale personnelle +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) {(!profile?.adresse_rue || !profile?.adresse_cp || !profile?.adresse_ville || !profile?.adresse_pays) && ( { {!adresseEditing ? ( <> {[ - { label: 'Société', value: profile?.societe }, + { label: 'Rue', value: profile?.adresse_rue }, { label: 'Code postal', value: profile?.adresse_cp }, { label: 'Ville', value: profile?.adresse_ville }, @@ -4378,36 +4617,32 @@ const Dashboard = (): JSX.Element => { ) : (
-
- - -
+
setAdresseForm(f => ({ ...f, adresse_rue: e.target.value }))} - placeholder="12 rue de la Paix" /> + />
setAdresseForm(f => ({ ...f, adresse_cp: e.target.value }))} - placeholder="75001" maxLength={10} /> + />
setAdresseForm(f => ({ ...f, adresse_ville: e.target.value }))} - placeholder="Paris" /> + />
setAdresseForm(f => ({ ...f, adresse_pays: e.target.value }))} - placeholder="France" /> + />
{adresseError && (
@@ -5544,7 +5779,7 @@ const Dashboard = (): JSX.Element => { label: 'Motif de refus', value: ancienneNoteComparaison.motifRefus || (ancienneNoteComparaison as any).ancienMotifRefus || - ancienneNoteComparaison.commentaireN2 || + ancienneNoteComparaison.commentaireN1 || '— Non renseigné' }, ].map(({ label, value }) => ( @@ -5702,6 +5937,11 @@ const Dashboard = (): JSX.Element => {
)} +<<<<<<< HEAD +======= + + +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) {/* ── URL PREVIEW ── */} {previewUrl && setPreviewUrl(null)} />} diff --git a/ndf/src/pages/NdfChatbot.tsx b/ndf/src/pages/NdfChatbot.tsx index b8a3f59..f695b4c 100644 --- a/ndf/src/pages/NdfChatbot.tsx +++ b/ndf/src/pages/NdfChatbot.tsx @@ -449,6 +449,7 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) { gap: 8, alignItems: "flex-end", marginBottom: 10, animation: "ndfFade 0.2s ease", }}> +<<<<<<< HEAD {isBot && (
🤖
+======= + {isBot && ( + Emma +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) )}
+<<<<<<< HEAD
🤖
+======= + Emma +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
>>>>>> 78516cb (version_Rôle_President Version_Chatbot) topic: null, }, ]); @@ -723,7 +744,11 @@ export default function NDFChatbot() { >✕
Hello 👋
+<<<<<<< HEAD Je suis NDF BOT, je peux t'aider si besoin. +======= + Je suis Emma, je peux t'aider si besoin. +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
+<<<<<<< HEAD
🤖
Assistant NDF
+======= + Emma +
+
Emma
+>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
+<<<<<<< HEAD Assistant NDF · ENSUP Group +======= + Emma · ENSUP Group +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
)} diff --git a/ndf/src/pages/NouvelleNote.tsx b/ndf/src/pages/NouvelleNote.tsx index b39fa5d..5100d23 100644 --- a/ndf/src/pages/NouvelleNote.tsx +++ b/ndf/src/pages/NouvelleNote.tsx @@ -60,6 +60,7 @@ interface NouvelleNoteProps { dateDebutInitiale?: string; commentaireInitial?: string; depensesInitiales?: any[]; + onNavigateToProfil?: () => void; } // ── CONSTANTES ──────────────────────────────────────── @@ -207,6 +208,26 @@ function calcIndemnite(km: number, cv: number): number { return parseFloat((km * b.t3).toFixed(2)); } function getTranche(km: number) { return km <= 0 ? 0 : km <= 5000 ? 1 : km <= 20000 ? 2 : 3; } + // ── Helper — vérifie qu'une date n'est pas dans un mois futur ────────── + function isDateFutureMonth(dateStr: string): boolean { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return false; + const now = new Date(); + // Date du 1er jour du mois suivant + const firstDayNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return d >= firstDayNextMonth; + } + + // Retourne la date max autorisée pour les inputs (dernier jour du mois courant) + function getMaxAllowedDate(): string { + const now = new Date(); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const y = lastDay.getFullYear(); + const m = String(lastDay.getMonth() + 1).padStart(2, '0'); + const d = String(lastDay.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } // ── PROFIL VÉHICULE — interface partagée ───────────────── interface ProfilVehiculeData { @@ -1085,17 +1106,20 @@ function isRepasEvenementiel(libelle: string, description: string): boolean { // ── DEPENSE CARD ────────────────────────────────────── const DepenseCard = React.memo(({ - depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule + depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule,onNavigateToProfil, }: { depense: Depense; index: number; total: number; expanded: boolean; onToggle: (id: number) => void; onUpdate: (id: number, f: keyof Depense, v: any) => void; onDelete: (id: number) => void; onGenerateQR?: (id: number) => void; + onNavigateToProfil?: () => void; disabled?: boolean; apiBaseUrl: string; profilVehicule?: ProfilVehiculeData | null; + }) => { + const fileRef = useRef(null); const set = (f: keyof Depense, v: any) => onUpdate(depense.id, f, v); @@ -1121,7 +1145,24 @@ const DepenseCard = React.memo(({ // Détection repas événementiel — désactive l'alerte 25€ const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description); +<<<<<<< HEAD const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel; +======= + const [alerteRepasVue, setAlerteRepasVue] = useState(false); + + useEffect(() => { + if (isRepas && ttcParPersonne > 25 && !isEvenementiel) { + setAlerteRepasVue(true); + } + }, [ttcParPersonne, isRepas, isEvenementiel]); + + useEffect(() => { + if (ttcTotal <= 25) setAlerteRepasVue(false); + }, [ttcTotal]); + + // Remplace l'ancienne ligne repasAlerte : + const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel; +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot) // ✅ Calcul HT — gestion du cas MIXED const htTotal = isKm @@ -1240,7 +1281,13 @@ const DepenseCard = React.memo(({
- set("date", e.target.value)} /> + set("date", e.target.value)} + />
@@ -1267,7 +1314,11 @@ const DepenseCard = React.memo(({ où vous choisirez le « trajet le plus rapide », en favorisant les trajets sans section à péage.
+<<<<<<< HEAD {profilVehicule ? ( +======= + {profilVehicule && ( +>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
- ) : ( - <> -
- ⚠️ Aucun véhicule renseigné dans votre profil. Configurer maintenant ↗ -
-
- {BAREME_KM.map(b => ( -
set("chevaux", b.cv)}> - {b.label} - × {b.t1} -
- ))} -
- )} + {!profilVehicule && ( +
+ ⚠️ Aucun véhicule renseigné dans votre profil. Configurer maintenant ↗ +
+ )} + {/* Sélecteur CV — toujours visible */} + +
@@ -1686,8 +1736,9 @@ export default function NouvelleNote({ initialBrouillonId = null, libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '', depensesInitiales, + onNavigateToProfil, }: NouvelleNoteProps) { - + const initDepenses = (defaultCv = 7): Depense[] => { if (depensesInitiales && depensesInitiales.length > 0) { return depensesInitiales.map((l: any) => { @@ -1963,11 +2014,20 @@ export default function NouvelleNote({ setSubmitError(""); if (submitting) return; if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; } + if (dateDebut && isDateFutureMonth(dateDebut)) { + setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés."); + return; + } for (const d of depenses) { if (!d.date || !d.libelle.trim()) { setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`); setExpandedId(d.id); return; } + // ✅ Bloquer les dates dans un mois futur + if (isDateFutureMonth(d.date)) { + setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`); + setExpandedId(d.id); return; + } const isKmLine = d.categorie.toLowerCase().includes("kilom"); if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) { setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`); @@ -2174,7 +2234,14 @@ export default function NouvelleNote({
- setDateDebut(e.target.value)} disabled={submitting} /> + setDateDebut(e.target.value)} + disabled={submitting} + />
@@ -2191,13 +2258,15 @@ export default function NouvelleNote({
- {depenses.map((d, i) => ( - - ))} + {depenses.map((d, i) => ( + + ))} + ))} +
+ + {/* ════════════════════════════════════════ + ONGLET 1 : NOTES À VALIDER + ════════════════════════════════════════ */} + {tab === 'notes' && ( +
+ + {/* ── Stat cards ── */} +
+ {[ + { label: 'Notes en attente', value: totalNotes, accent: '#1d4ed8', icon: '📋' }, + { label: 'Volume total', value: fmt(totalMontant), accent: '#7c3aed', icon: '💰' }, + { label: 'Sélection', value: `${selectedIds.length} note(s) — ${fmt(totalSelectionne)}`, accent: '#15803d', icon: '✅' }, + ].map((c, i) => ( +
+
+ {c.icon} {c.label} +
+
{c.value}
+
+ ))} +
+ + {notesLoading ? ( +
+ ⏳ Chargement des notes... +
+ ) : notes.length === 0 ? ( +
+
+
+ Aucune note en attente +
+
+ Les notes soumises par le ValidateurFinance apparaîtront ici. +
+
+ ) : ( + <> + {/* ── Barre d'action fixe ── */} +
+
+ {/* Sélectionner tout */} + + + {selectedIds.length > 0 && ( + + · {selectedIds.length} note(s) — {fmt(totalSelectionne)} + + )} + + {/* Commentaire optionnel */} + setCommentaire(e.target.value)} + placeholder="Commentaire de validation (optionnel)" + style={{ ...inputStyle, flex: 1, minWidth: 200, fontSize: 12, padding: '8px 12px' }} + /> + + {/* Bouton générer XML */} + +
+
+ + {/* ── Tableau des notes ── */} +
+
+ + + + {['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Libellé', 'Montant', 'Vérificateur', 'Statut'].map(h => ( + + ))} + + + + {notes.map(note => { + const isChecked = selectedIds.includes(note.id); + return ( + setSelectedIds(isChecked + ? selectedIds.filter(id => id !== note.id) + : [...selectedIds, note.id] + )} + onMouseEnter={e => { if (!isChecked) e.currentTarget.style.background = 'var(--bg-input)'; }} + onMouseLeave={e => { e.currentTarget.style.background = isChecked ? '#eff6ff' : ''; }} + > + + + + + + + + + + + ); + })} + + + {/* Pied de tableau : total sélection */} + {selectedIds.length > 0 && ( + + + + + + + )} +
{h}
+ { }} + style={{ width: 16, height: 16, cursor: 'pointer' }} + /> + + {note.reference} + +
{note.collaborateur}
+
{note.collaborateurEmail}
+
+ {note.campus ? ( + + {normalizeCampus(note.campus)} + + ) : '—'} + + {note.societe || '—'} + +
+ {note.libelle} +
+
+ {note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '—'} +
+
+ {fmt(note.montant)} + + {note.nomVerificateur ? ( +
+
{note.nomVerificateur}
+ {note.dateVerification && ( +
+ {new Date(note.dateVerification).toLocaleDateString('fr-FR')} +
+ )} +
+ ) : ( + + )} +
+ {tagStatut(note.statut)} +
+ Total sélection ({selectedIds.length} note{selectedIds.length > 1 ? 's' : ''}) + + {fmt(totalSelectionne)} +
+
+
+ + {/* ── Récap bouton bas ── */} + {selectedIds.length > 0 && ( +
+
+
+ 💼 Prêt à valider {selectedIds.length} virement{selectedIds.length > 1 ? 's' : ''} +
+
+ Montant total : {fmt(totalSelectionne)} + {commentaire.trim() && ` · "${commentaire.trim()}"`} +
+
+ La mention "Validé par le Président {user.prenom} {user.nom}" sera inscrite dans le fichier XML +
+
+ +
+ )} + + )} +
+ )} + + {/* ════════════════════════════════════════ + ONGLET 2 : HISTORIQUE + ════════════════════════════════════════ */} + {tab === 'historique' && ( +
+ + {/* Filtres */} +
+
+ + 🔍 Filtrer + + + + +
+
+ + {historiqueLoading ? ( +
+ ⏳ Chargement... +
+ ) : historique.length === 0 ? ( +
+
📂
+
+ Aucune validation trouvée +
+
+ Les virements que vous avez validés apparaîtront ici. +
+
+ ) : ( +
+ {historique.map((batch, i) => { + const dateXml = new Date(batch.dateXmlExacte || batch.dateXmlJour); + const dateLabel = dateXml.toLocaleDateString('fr-FR', { + weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' + }); + const heureLabel = dateXml.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + + return ( +
+ {/* En-tête batch */} +
+
+
🏦
+
+
+ XML du {dateLabel} +
+
+ {heureLabel} · {batch.nbNotes} virement{batch.nbNotes > 1 ? 's' : ''} +
+ {/* Badge Président */} +
+ 💼 Validé par le Président {batch.presidentNom || `${user.prenom} ${user.nom}`} +
+ {batch.commentairePresident && ( +
+ 💬 {batch.commentairePresident} +
+ )} +
+
+
+ {fmt(parseFloat(String(batch.totalMontant)) || 0)} +
+
+ + {/* Références */} +
+ + Virements : + + {(batch.listeReferences || '').split(', ').slice(0, 8).map((ref, ri) => ( + + {ref.trim()} + + ))} + {(batch.listeReferences || '').split(', ').length > 8 && ( + + +{(batch.listeReferences || '').split(', ').length - 8} autres + + )} +
+
+ ); + })} +
+ )} +
+ )} + + {/* Animation spinner */} + +
+ ); +}; + +export default PresidentValidation; \ No newline at end of file