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 0000000..342e874 Binary files /dev/null and b/ndf/public/img/emma-avatar.jpg differ 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)
🚘
- Véhicule depuis votre profil + Véhicule depuis votre profil — vous pouvez ajuster les CV ci-dessous
{profilVehicule.marque} {profilVehicule.modele} {profilVehicule.immatriculation && ` · ${profilVehicule.immatriculation}`} - {" "}— {profilVehicule.chevaux} CV fiscaux
- +
- ) : ( - <> -
- ⚠️ 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