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 ?
+
+ Connectez-vous à la plateforme NDF
+ Rendez-vous dans Mes notes
+ Cliquez sur la note ${note.reference}
+ Corrigez les informations demandées
+ Resoumettez la note
+
+
+
+
+
`
+ : `
+
+
${titreCollab}
+
+
+
Bonjour ${c.prenom} ${c.nom} ,
+
${msgCollab}
+
+
+
`;
+
+ 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 :
+
+
+
+
+ Référence
+ Collaborateur
+ Montant
+
+
+ ${lignesNotes}
+
+
+ Total à virer
+ ${total} €
+
+
+
+
+
+
+ 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})
+
+
+
+
+
+ Référence
+ Collaborateur
+ Montant
+
+
+ ${lignesNotes}
+
+
+ Total
+ ${totalFormate} €
+
+
+
+
+
+
+ Vous pouvez re-télécharger ce fichier XML depuis la rubrique "XML virements" de la plateforme.
+
+
+
+
`
+ );
+
+ 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
+
+
+
+
nav('docsavalider')}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '7px 14px',
+ background: 'linear-gradient(135deg,#d97706,#b45309)',
+ color: '#fff', border: 'none', borderRadius: 8,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 12, fontWeight: 700,
+ }}>
+ Voir tout →
+
+
+
+ )}
{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`
+ }
+
+
+
+
nav('president')}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '7px 14px',
+ background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
+ color: '#fff', border: 'none', borderRadius: 8,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 12, fontWeight: 700,
+ }}>
+ Voir tout →
+
+
+
+ {/* 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)
nav('paiements')}
+ onClick={() => nav('president')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '11px 22px', marginTop: 4,
- background: 'linear-gradient(135deg,#10b981,#059669)',
+ background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
color: '#fff', border: 'none', borderRadius: 10,
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 700,
- boxShadow: '0 4px 14px rgba(16,185,129,.35)',
+ boxShadow: '0 4px 14px rgba(29,78,216,.35)',
}}>
- 💶 Générer XML — {notesVerifiees.length} paiement{notesVerifiees.length > 1 ? 's' : ''}
+ 💼 Valider {notesEnAttentePresident.length} virement{notesEnAttentePresident.length > 1 ? 's' : ''}
)}
@@ -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 => {
Tous les statuts
En attente
-
Validé N1
+
Validation
Approuvé
Vérifié
Paiement en attente
@@ -3741,12 +3938,20 @@ const Dashboard = (): JSX.Element => {
{notesFiltreesPaiements.length}
+<<<<<<< HEAD
>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
style={{
...btnPrimary,
background: paiementLoading
? 'linear-gradient(135deg,#4b5563,#374151)'
+<<<<<<< HEAD
: 'linear-gradient(135deg,#10b981,#059669)',
+=======
+ : 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
+>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
opacity: selectedNoteIds.length === 0 ? 0.5 : 1,
cursor: paiementLoading ? 'wait' : 'pointer',
}}
@@ -3755,11 +3960,12 @@ const Dashboard = (): JSX.Element => {
if (!selectedNoteIds.length) return;
setPaiementLoading(true);
try {
- const res = await fetch(`${API}/api/paiements/generer-xml`, {
+ const res = await fetch(`${API}/api/paiements/soumettre-president`, {
method: 'POST',
headers: hdrs,
body: JSON.stringify({ noteIds: selectedNoteIds })
});
+<<<<<<< HEAD
if (!res.ok) {
const e = await res.json();
if (res.status === 422 && e.details?.length) {
@@ -3775,14 +3981,30 @@ const Dashboard = (): JSX.Element => {
a.click();
URL.revokeObjectURL(url);
showToast(`✅ XML généré — ${selectedNoteIds.length} virement(s)`, 'success');
+=======
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Erreur');
+ showToast(
+ `✅ ${data.nbNotes} note(s) soumises au Président pour validation`,
+ 'success'
+ );
+>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
setSelectedNoteIds([]);
await refreshNotesPaiements();
- } catch (e: any) { showToast(e.message, 'error'); }
- finally { setPaiementLoading(false); }
+ } catch (e: any) {
+ showToast(e.message, 'error');
+ } finally {
+ setPaiementLoading(false);
+ }
}}>
{paiementLoading
+<<<<<<< HEAD
? <>⏳ Génération... >
: <>🏦 Générer XML ({selectedNoteIds.length}) >
+=======
+ ? <>⏳ Envoi... >
+ : <>💼 Soumettre au Président ({selectedNoteIds.length}) >
+>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
}
@@ -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 => {
>
) : (
-
- Société
-
-
+
Rue
setAdresseForm(f => ({ ...f, adresse_rue: e.target.value }))}
- placeholder="12 rue de la Paix" />
+ />
Pays
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 && (
+
+>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
)}
+<<<<<<< HEAD
🤖
+=======
+
+>>>>>>> 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
+>>>>>>> 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(({
Date *
- set("date", e.target.value)} />
+ set("date", e.target.value)}
+ />
Libellé *
@@ -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)
- ) : (
- <>
-
-
- {BAREME_KM.map(b => (
-
set("chevaux", b.cv)}>
- {b.label}
- × {b.t1}
-
- ))}
-
- >
)}
+ {!profilVehicule && (
+
+ )}
+ {/* Sélecteur CV — toujours visible */}
+
+
Kilométrage *
@@ -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({
Date *
- setDateDebut(e.target.value)} disabled={submitting} />
+ setDateDebut(e.target.value)}
+ disabled={submitting}
+ />
Commentaire
@@ -2191,13 +2258,15 @@ export default function NouvelleNote({
- {depenses.map((d, i) => (
-
- ))}
+ {depenses.map((d, i) => (
+
+ ))}
+ Ajouter une dépense
diff --git a/ndf/src/pages/PresidentValidation.tsx b/ndf/src/pages/PresidentValidation.tsx
new file mode 100644
index 0000000..7016cef
--- /dev/null
+++ b/ndf/src/pages/PresidentValidation.tsx
@@ -0,0 +1,693 @@
+// PresidentValidation.tsx
+// Page dédiée au rôle Président pour valider les notes de frais et générer le XML
+// Ajouter dans src/pages/ ou src/components/
+
+import { useState, useEffect, useCallback } from 'react';
+
+// ── Types ─────────────────────────────────────────────────────────────────
+interface Note {
+ id: number;
+ reference: string;
+ libelle: string;
+ montant: number;
+ date: string;
+ categorie: string;
+ statut: string;
+ collaborateur: string;
+ collaborateurEmail: string;
+ departement: string;
+ campus: string;
+ societe?: string;
+ nomN1?: string;
+ nomVerificateur?: string;
+ dateVerification?: string;
+ commentaireVerification?: string;
+ lignesJson?: string;
+ fichiers?: string;
+}
+
+interface HistoriqueXml {
+ dateXmlJour: string;
+ dateXmlExacte: string;
+ nbNotes: number;
+ totalMontant: number;
+ noteIds: string;
+ listeReferences: string;
+ presidentNom?: string;
+ dateValidationPresident?: string;
+ commentairePresident?: string;
+}
+
+interface Props {
+ apiBaseUrl: string;
+ authToken: string;
+ user: { prenom: string; nom: string; email: string; roles: string[] };
+ onShowToast: (msg: string, type?: string) => void;
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+const fmt = (n: number) =>
+ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
+
+const normalizeCampus = (campus?: string): string => {
+ if (!campus) return '';
+ const c = campus.toUpperCase();
+ if (c.includes('SQY') || c.includes('SAINT')) return 'SQY';
+ if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
+ if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
+ if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
+ return campus;
+};
+
+// ── Styles ────────────────────────────────────────────────────────────────
+const card: React.CSSProperties = {
+ background: 'var(--bg-card)',
+ borderRadius: 16,
+ border: '1px solid var(--border-card)',
+ boxShadow: 'var(--shadow-card)',
+ overflow: 'hidden',
+};
+
+const inputStyle: React.CSSProperties = {
+ width: '100%', padding: '10px 14px',
+ border: '1.5px solid var(--border-input)', borderRadius: 8,
+ background: 'var(--bg-input)', fontFamily: 'inherit',
+ fontSize: 14, color: 'var(--text-primary)', outline: 'none',
+ boxSizing: 'border-box',
+};
+
+const tagStatut = (s: string) => {
+ const map: Record = {
+ 'en_attente_president': { bg: '#ede9fe', color: '#7c3aed', label: '⏳ Attente Président' },
+ 'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: '🏦 Paiement en attente' },
+ 'verifie': { bg: '#ede9fe', color: '#7c3aed', label: '🔍 Vérifié' },
+ 'approuve': { bg: '#dcfce7', color: '#15803d', label: '✅ Approuvé' },
+ };
+ const k = s?.toLowerCase().trim() ?? '';
+ const st = map[k] ?? { bg: '#f1f5f9', color: '#64748b', label: s };
+ return (
+ {st.label}
+ );
+};
+
+// ══════════════════════════════════════════════════════════════════════════
+// COMPOSANT PRINCIPAL
+// ══════════════════════════════════════════════════════════════════════════
+const PresidentValidation = ({ apiBaseUrl, authToken, user, onShowToast }: Props) => {
+ const [tab, setTab] = useState<'notes' | 'historique'>('notes');
+
+ // Notes en attente
+ const [notes, setNotes] = useState([]);
+ const [notesLoading, setNotesLoading] = useState(false);
+
+ // Sélection
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [commentaire, setCommentaire] = useState('');
+
+ // Historique XML
+ const [historique, setHistorique] = useState([]);
+ const [historiqueLoading, setHistoriqueLoading] = useState(false);
+
+ // XML generation
+ const [xmlLoading, setXmlLoading] = useState(false);
+ const [xmlFiltreAnnee, setXmlFiltreAnnee] = useState('');
+ const [xmlFiltreMois, setXmlFiltreMois] = useState('');
+
+ const hdrs = { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' };
+
+ // ── Chargement des notes ───────────────────────────────────────────
+ const loadNotes = useCallback(async () => {
+ setNotesLoading(true);
+ try {
+ const res = await fetch(`${apiBaseUrl}/api/president/notes`, { headers: hdrs });
+ const data = await res.json();
+ if (res.ok && Array.isArray(data)) setNotes(data);
+ } catch { }
+ finally { setNotesLoading(false); }
+ }, [apiBaseUrl, authToken]);
+
+ // ── Chargement historique ──────────────────────────────────────────
+ const loadHistorique = useCallback(async () => {
+ setHistoriqueLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (xmlFiltreAnnee) params.append('annee', xmlFiltreAnnee);
+ if (xmlFiltreMois) params.append('mois', xmlFiltreMois);
+ const res = await fetch(`${apiBaseUrl}/api/president/historique?${params}`, { headers: hdrs });
+ const data = await res.json();
+ if (res.ok && Array.isArray(data)) setHistorique(data);
+ } catch { }
+ finally { setHistoriqueLoading(false); }
+ }, [apiBaseUrl, authToken, xmlFiltreAnnee, xmlFiltreMois]);
+
+ useEffect(() => { loadNotes(); }, [loadNotes]);
+ useEffect(() => { if (tab === 'historique') loadHistorique(); }, [tab, loadHistorique]);
+
+ // ── Générer XML ────────────────────────────────────────────────────
+ const handleGenererXml = async () => {
+ if (!selectedIds.length) return;
+ if (!window.confirm(`Valider et générer le virement XML pour ${selectedIds.length} note(s) ?`)) return;
+
+ setXmlLoading(true);
+ try {
+ const res = await fetch(`${apiBaseUrl}/api/president/generer-xml`, {
+ method: 'POST',
+ headers: hdrs,
+ body: JSON.stringify({ noteIds: selectedIds, commentaire: commentaire.trim() || undefined }),
+ });
+
+ if (!res.ok) {
+ const e = await res.json();
+ if (res.status === 422 && e.details?.length) {
+ alert(`❌ ${e.error}\n\n${e.details.map((d: string) => `• ${d}`).join('\n')}`);
+ } else {
+ throw new Error(e.error);
+ }
+ return;
+ }
+
+ // Télécharger le fichier XML
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `virements-ndf-PRESIDENT-${new Date().toISOString().split('T')[0]}.xml`;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ onShowToast(`✅ XML généré et signé par ${user.prenom} ${user.nom} — ${selectedIds.length} virement(s)`, 'success');
+ setSelectedIds([]);
+ setCommentaire('');
+ await loadNotes();
+ } catch (e: any) {
+ onShowToast(e.message || 'Erreur lors de la génération', 'error');
+ } finally {
+ setXmlLoading(false);
+ }
+ };
+
+ // ── Statistiques ───────────────────────────────────────────────────
+ const totalNotes = notes.length;
+ const totalMontant = notes.reduce((s, n) => s + (n.montant || 0), 0);
+ const totalSelectionne = notes
+ .filter(n => selectedIds.includes(n.id))
+ .reduce((s, n) => s + (n.montant || 0), 0);
+
+ return (
+
+
+ {/* ── BANDEAU IDENTITÉ PRÉSIDENT ── */}
+
+ {/* Décoration fond */}
+
+
+ 💼
+
+
+
+ Espace Président — Validation des virements
+
+
+ {user.prenom} {user.nom}
+
+
+ Votre validation est requise avant tout virement bancaire
+
+
+ {/* Badge compte notes en attente */}
+ {totalNotes > 0 && (
+
+
+ {totalNotes}
+
+
+ en attente
+
+
+ )}
+
+
+ {/* ── ONGLETS ── */}
+
+ {[
+ { id: 'notes', icon: '📋', label: `Notes à valider${totalNotes > 0 ? ` (${totalNotes})` : ''}` },
+ { id: 'historique', icon: '📅', label: 'Historique de mes validations' },
+ ].map(t => (
+ setTab(t.id as any)}
+ style={{
+ padding: '12px 24px',
+ border: 'none', borderBottom: tab === t.id ? '2px solid #1d4ed8' : '2px solid transparent',
+ marginBottom: -2, cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 13, fontWeight: tab === t.id ? 800 : 500,
+ color: tab === t.id ? '#1d4ed8' : 'var(--text-muted)',
+ background: 'transparent', transition: 'all 0.15s',
+ }}>
+ {t.icon} {t.label}
+
+ ))}
+
+
+ {/* ════════════════════════════════════════
+ 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 */}
+
+ 0}
+ onChange={e => setSelectedIds(e.target.checked ? notes.map(n => n.id) : [])}
+ style={{ width: 16, height: 16 }}
+ />
+
+ Tout sélectionner
+
+
+
+ {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 */}
+
0 && !xmlLoading
+ ? '0 4px 14px rgba(29,78,216,.4)' : 'none',
+ whiteSpace: 'nowrap',
+ transition: 'all 0.15s',
+ }}>
+ {xmlLoading ? (
+ <>
+
+ Génération...
+ >
+ ) : (
+ <>🏦 Valider et générer XML ({selectedIds.length})>
+ )}
+
+
+
+
+ {/* ── Tableau des notes ── */}
+
+
+
+
+
+ {['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Libellé', 'Montant', 'Vérificateur', 'Statut'].map(h => (
+ {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' : ''; }}
+ >
+
+ { }}
+ 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)}
+
+
+ );
+ })}
+
+
+ {/* Pied de tableau : total sélection */}
+ {selectedIds.length > 0 && (
+
+
+
+ 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
+
+
+
+ {xmlLoading ? '⏳ Génération...' : '🏦 Valider et générer XML →'}
+
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* ════════════════════════════════════════
+ ONGLET 2 : HISTORIQUE
+ ════════════════════════════════════════ */}
+ {tab === 'historique' && (
+
+
+ {/* Filtres */}
+
+
+
+ 🔍 Filtrer
+
+ setXmlFiltreAnnee(e.target.value)}
+ style={{ ...inputStyle, width: 140 }}>
+ Toutes les années
+ {[2024, 2025, 2026, 2027].map(a => (
+ {a}
+ ))}
+
+ setXmlFiltreMois(e.target.value)}
+ style={{ ...inputStyle, width: 160 }}>
+ Tous les mois
+ {[
+ ['1', 'Janvier'], ['2', 'Février'], ['3', 'Mars'], ['4', 'Avril'],
+ ['5', 'Mai'], ['6', 'Juin'], ['7', 'Juillet'], ['8', 'Août'],
+ ['9', 'Septembre'], ['10', 'Octobre'], ['11', 'Novembre'], ['12', 'Décembre']
+ ].map(([v, l]) => {l} )}
+
+
+ 🔄 Actualiser
+
+
+
+
+ {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