From 78516cb99f8ddca258596d92531ccbefde3ecae3 Mon Sep 17 00:00:00 2001 From: Imer ouijdane Date: Tue, 19 May 2026 15:17:34 +0200 Subject: [PATCH] =?UTF-8?q?version=5FR=C3=B4le=5FPresident=20Version=5FCha?= =?UTF-8?q?tbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndf/public/backend/ndfPdfGenerator.js | 358 ++- ndf/public/backend/server.js | 3308 ++++++++++++++------ ndf/public/img/emma-avatar.jpg | Bin 0 -> 30914 bytes ndf/src/components/Login.css | 2 +- ndf/src/components/RoleSelector.tsx | 27 + ndf/src/context/AuthContext.tsx | 8 +- ndf/src/index.css | 2 +- ndf/src/pages/AuthCallback.tsx | 2 +- ndf/src/pages/Dashboard.tsx | 1691 +++++++--- ndf/src/pages/NdfChatbot.tsx | 900 ++++++ ndf/src/pages/NouvelleNote.tsx | 788 +++-- ndf/src/pages/PresidentValidation.tsx | 693 ++++ ndf/src/pages/VerificateurFinanceLight.tsx | 1649 +++++----- ndf/tsconfig.app.json | 2 +- 14 files changed, 6992 insertions(+), 2438 deletions(-) create mode 100644 ndf/public/img/emma-avatar.jpg create mode 100644 ndf/src/pages/NdfChatbot.tsx create mode 100644 ndf/src/pages/PresidentValidation.tsx diff --git a/ndf/public/backend/ndfPdfGenerator.js b/ndf/public/backend/ndfPdfGenerator.js index 3eb310c..191bd4e 100644 --- a/ndf/public/backend/ndfPdfGenerator.js +++ b/ndf/public/backend/ndfPdfGenerator.js @@ -1,20 +1,9 @@ // ══════════════════════════════════════════════════════════════════════════════ -// ndfPdfGenerator.js — v4 (pdfkit pur, 0% Python) -// Génère la fiche Note de Frais au format exact du modèle ENSUP -// avec signatures électroniques intégrées. -// v4 : Tarif km et Sous-total km intégrés dans le tableau après colonne Km -// -// Prérequis : pdfkit déjà installé (npm install pdfkit) -// Copier dans le même dossier que server.js. +// ndfPdfGenerator.js — v5 (pdfkit pur, support proratisation repas) // ══════════════════════════════════════════════════════════════════════════════ import PDFDocument from 'pdfkit'; -// ───────────────────────────────────────────────────────────────────────────── -// CONSTANTES -// ───────────────────────────────────────────────────────────────────────────── - - const C = { blue: '#1B4F8A', header: '#2563EB', totalBg: '#DBEAFE', altRow: '#EFF6FF', @@ -27,14 +16,11 @@ const C = { validBg: '#EEF2FF', validBorder: '#C7D2FE', validText: '#4F46E5', refusBg: '#FEF2F2', refusBorder: '#FCA5A5', refusText: '#DC2626', waitBg: '#F8FAFC', waitBorder: '#E2E8F0', - kmBg: '#F5F3FF', // fond violet clair pour colonne tarif km - kmBorder: '#DDD6FE', // bordure violet clair - kmText: '#7C3AED', // texte violet - kmTotalBg: '#EDE9FE', // fond sous-total km + kmBg: '#F5F3FF', kmBorder: '#DDD6FE', kmText: '#7C3AED', kmTotalBg: '#EDE9FE', + // ✅ Nouveaux — lignes proratisées + prorataBg: '#FFFBEB', prorataText: '#D97706', prorataBorder: '#FDE68A', }; -// Colonnes tableau (largeurs en points) -// ── v4 : 'tarifKm' et 'sousKm' insérées après 'km' ── const COLS = [ { key: 'num', label: 'N°pièce', w: 34, align: 'center' }, { key: 'date', label: 'Date', w: 58, align: 'left' }, @@ -51,14 +37,13 @@ const COLS = [ { key: 'ht', label: 'HT', w: 50, align: 'right' }, ]; -// Colonnes km (pour coloration spéciale) const KM_COLS = ['km', 'tarifKm', 'sousKm']; const MARGIN = 30; const ROW_H = 16; const HEAD_H = 20; -const PAGE_W = 841.89; // A4 largeur -const PAGE_H = 595.28; // A4 hauteur +const PAGE_W = 841.89; +const PAGE_H = 595.28; // ───────────────────────────────────────────────────────────────────────────── // HELPERS @@ -89,11 +74,8 @@ function fmtDateTime(s) { function drawRect(doc, x, y, w, h, fill, stroke = null, lw = 0.3) { doc.save(); - if (stroke) { - doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke); - } else { - doc.rect(x, y, w, h).fill(fill); - } + if (stroke) doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke); + else doc.rect(x, y, w, h).fill(fill); doc.restore(); } @@ -103,68 +85,130 @@ function drawCellText(doc, text, x, y, w, h, font, size, color, align, padX = 3) doc.save().font(font).fontSize(size).fillColor(color); while (text.length > 1 && doc.widthOfString(text) > maxW) text = text.slice(0, -1); const ty = y + h * 0.28; - if (align === 'right') { - doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false }); - } else if (align === 'center') { - doc.text(text, x + padX, ty, { width: maxW, align: 'center', lineBreak: false }); - } else { - doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false }); - } + if (align === 'right') doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false }); + else if (align === 'center') doc.text(text, x + padX, ty, { width: maxW, align: 'center', lineBreak: false }); + else doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false }); doc.restore(); } function drawVLine(doc, x, y1, y2) { - doc.save().strokeColor(C.border).lineWidth(0.4) - .moveTo(x, y1).lineTo(x, y2).stroke().restore(); + doc.save().strokeColor(C.border).lineWidth(0.4).moveTo(x, y1).lineTo(x, y2).stroke().restore(); } - function drawHLine(doc, x1, x2, y) { - doc.save().strokeColor(C.border).lineWidth(0.3) - .moveTo(x1, y).lineTo(x2, y).stroke().restore(); + doc.save().strokeColor(C.border).lineWidth(0.3).moveTo(x1, y).lineTo(x2, y).stroke().restore(); } // ───────────────────────────────────────────────────────────────────────────── -// preparerLignesPDF — convertit lignes formulaire → lignes PDF +// BARÈME KM fiscal (miroir de server.js) // ───────────────────────────────────────────────────────────────────────────── -export function preparerLignesPDF(lignesParsed, tarifKm = TARIF_KM_DEFAULT) { +const BAREME_KM = { + 3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 }, + 4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 }, + 5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 }, + 6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 }, + 7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 }, +}; + +function getIndemniteKm(kmTotal, chevaux) { + const cv = Math.min(Math.max(parseInt(chevaux) || 7, 3), 7); + const b = BAREME_KM[cv]; + if (!b || kmTotal <= 0) return 0; + if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2)); + if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2)); + return parseFloat((kmTotal * b.t3).toFixed(2)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// preparerLignesPDF — v5 : gère montantAjuste + barème CV fiscal +// ───────────────────────────────────────────────────────────────────────────── +export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) { + console.log('🔍 preparerLignesPDF reçoit:', JSON.stringify(lignesParsed, null, 2)); + return (lignesParsed || []).map((l, idx) => { const isKm = (l.categorie || '').toLowerCase().includes('kilom'); - const km = parseFloat(l.km) || 0; - const ttc = isKm ? 0 : (parseFloat(l.montant) || 0); - const taux = parseFloat(l.tauxTVA) || 0; - let ht = ttc, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0; + const km = parseFloat(l.km) || 0; + const cv = parseInt(l.chevaux) || 7; - if (!isKm && taux > 0 && ttc > 0) { - ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2)); - const tvaM = parseFloat((ttc - ht).toFixed(2)); - if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM; - else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM; - else if (Math.abs(taux - 10) < 0.01) tva10 = tvaM; - else if (Math.abs(taux - 20) < 0.01) tva20 = tvaM; + const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0; + const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0; + + const montantAjuste = l.montantAjuste === true; + const montantOriginal = montantAjuste + ? (parseFloat(l.montantOriginal) || 0) + : 0; + + // ✅ Toutes les variables avec let + let ttc = 0, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0, ht = 0; + + if (!isKm) { + if (montantAjuste) { + // ✅ Ligne proratisée — recalcul depuis montant retenu + ttc = parseFloat(l.montant) || 0; + const taux = parseFloat(l.tauxTVA) || 0; + if (taux > 0 && ttc > 0) { + ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2)); + const tvaM = parseFloat((ttc - ht).toFixed(2)); + if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM; + else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM; + else if (Math.abs(taux - 10 ) < 0.01) tva10 = tvaM; + else if (Math.abs(taux - 20 ) < 0.01) tva20 = tvaM; + } else { + ht = ttc; + } + } else if (Array.isArray(l.tvaItems) && l.tvaItems.length > 0) { + // ✅ Multi-TVA normal + for (const item of l.tvaItems) { + const itemTTC = parseFloat(item.montantTTC) || 0; + const itemHT = parseFloat(item.montantHT) || 0; + const itemTau = parseFloat(item.taux) || 0; + ttc += itemTTC; + ht += itemHT; + const tvaM = parseFloat((itemTTC - itemHT).toFixed(2)); + if (Math.abs(itemTau - 2.1) < 0.01) tva21 += tvaM; + else if (Math.abs(itemTau - 5.5) < 0.01) tva55 += tvaM; + else if (Math.abs(itemTau - 10 ) < 0.01) tva10 += tvaM; + else if (Math.abs(itemTau - 20 ) < 0.01) tva20 += tvaM; + } + ttc = parseFloat(ttc.toFixed(2)); + ht = parseFloat(ht.toFixed(2)); + } else { + // ✅ Mono-TVA simple + ttc = parseFloat(l.montant) || 0; + const taux = parseFloat(l.tauxTVA) || 0; + if (taux > 0 && ttc > 0) { + ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2)); + const tvaM = parseFloat((ttc - ht).toFixed(2)); + if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM; + else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM; + else if (Math.abs(taux - 10 ) < 0.01) tva10 = tvaM; + else if (Math.abs(taux - 20 ) < 0.01) tva20 = tvaM; + } else { + ht = ttc; + } + } } - const indemniteKm = isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0; - return { - numPiece: idx + 1, - date: l.date, - nature: l.categorie || '', - libelle: l.libelle || '', - km: isKm ? km : 0, - tarifKmVal: isKm ? tarifKm : 0, // ← valeur numérique tarif - montantTTC: isKm ? 0 : ttc, + numPiece: idx + 1, + date: l.date, + nature: l.categorie || '', + libelle: l.libelle || '', + km: isKm ? km : 0, + tarifKmVal: isKm ? tarifKmAffiche : 0, + montantTTC: isKm ? 0 : ttc, tva21, tva55, tva10, tva20, - montantHT: isKm ? 0 : ht, + montantHT: isKm ? 0 : ht, indemniteKm, + montantAjuste, + montantOriginal, }; }); } - // ───────────────────────────────────────────────────────────────────────────── -// generateFicheSignee — point d'entrée appelé depuis server.js +// generateFicheSignee // ───────────────────────────────────────────────────────────────────────────── export async function generateFicheSignee(note, signatures = []) { - const tarifKm = parseFloat(note.tarifKm) || TARIF_KM_DEFAULT; + const tarifKm = parseFloat(note.tarifKm) || 0.697; let lignesPDF = []; if (note.lignesJson) { @@ -176,16 +220,20 @@ export async function generateFicheSignee(note, signatures = []) { } else if (note.lignes && Array.isArray(note.lignes)) { lignesPDF = preparerLignesPDF(note.lignes, tarifKm); } else { + // Fallback ligne unique (rétrocompat) const isKm = !!(note.km && parseFloat(note.km) > 0); const km = isKm ? parseFloat(note.km) : 0; + const cv = parseInt(note.chevaux) || 7; + const indem = isKm ? getIndemniteKm(km, cv) : 0; lignesPDF = [{ numPiece: 1, date: note.date, nature: note.categorie || '', libelle: note.libelle || '', km: isKm ? km : 0, - tarifKmVal: isKm ? tarifKm : 0, + tarifKmVal: isKm && km > 0 ? parseFloat((indem / km).toFixed(3)) : tarifKm, montantTTC: isKm ? 0 : parseFloat(note.montant || 0), tva21: 0, tva55: 0, tva10: 0, tva20: 0, montantHT: isKm ? 0 : parseFloat(note.montantHT || note.montant || 0), - indemniteKm: isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0, + indemniteKm: indem, + montantAjuste: false, montantOriginal: 0, }]; } @@ -209,19 +257,17 @@ export async function generateFicheSignee(note, signatures = []) { } // ───────────────────────────────────────────────────────────────────────────── -// _buildPDF — génère le Buffer PDF +// _buildPDF // ───────────────────────────────────────────────────────────────────────────── function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) { return new Promise((resolve, reject) => { const doc = new PDFDocument({ - size: 'A4', - layout: 'landscape', - margin: 0, + size: 'A4', layout: 'landscape', margin: 0, info: { Title: `Note de Frais ${reference}`, Author: `ENSUP — ${nomPrenom}`, Subject: `NDF ${reference}`, - Creator: 'NDF ENSUP v4', + Creator: 'NDF ENSUP v5', }, }); @@ -230,19 +276,19 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', e => reject(e)); - // ── Positions X colonnes ───────────────────────────────────── + // Positions X colonnes const colX = {}; let cx = MARGIN; for (const col of COLS) { colX[col.key] = cx; cx += col.w; } const tableW = cx - MARGIN; - // ── 2. BANDEAU ─────────────────────────────────────────────── + // ── Bandeau titre ──────────────────────────────────────────── const bandY = 44; drawRect(doc, MARGIN, bandY, tableW, 18, C.header); doc.font('Helvetica-Bold').fontSize(11).fillColor(C.white) .text('NOTE DE FRAIS', MARGIN, bandY + 3.5, { width: tableW, align: 'center', lineBreak: false }); - // ── 3. INFOS COLLAB ────────────────────────────────────────── + // ── Infos collaborateur ────────────────────────────────────── const infoY = bandY + 23; doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark) .text(`NOM : ${nomPrenom}`, MARGIN, infoY, { lineBreak: false }); @@ -254,33 +300,24 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark) .text(`Mois : ${mois}`, MARGIN, infoY + 13, { lineBreak: false }); - // ── 4. EN-TÊTE COLONNES ────────────────────────────────────── + // ── En-tête colonnes ───────────────────────────────────────── const tableTop = infoY + 27; - - // Fond de base drawRect(doc, MARGIN, tableTop, tableW, HEAD_H, C.headerRow, C.border, 0.5); - - // Fond spécial violet pour les 3 colonnes km dans l'en-tête - for (const key of KM_COLS) { + for (const key of KM_COLS) drawRect(doc, colX[key], tableTop, COLS.find(c => c.key === key).w, HEAD_H, C.kmBg); - } for (const col of COLS) { const isKmCol = KM_COLS.includes(col.key); - drawCellText( - doc, col.label, - colX[col.key], tableTop, col.w, HEAD_H, + drawCellText(doc, col.label, colX[col.key], tableTop, col.w, HEAD_H, 'Helvetica-Bold', isKmCol ? 6.5 : 7, - isKmCol ? C.kmText : C.dark, - col.align - ); + isKmCol ? C.kmText : C.dark, col.align); drawVLine(doc, colX[col.key], tableTop, tableTop + HEAD_H); } drawVLine(doc, MARGIN + tableW, tableTop, tableTop + HEAD_H); drawHLine(doc, MARGIN, MARGIN + tableW, tableTop); drawHLine(doc, MARGIN, MARGIN + tableW, tableTop + HEAD_H); - // ── 5. LIGNES DONNÉES ──────────────────────────────────────── + // ── Lignes données ─────────────────────────────────────────── const MIN_ROWS = 18; const totalRows = Math.max(MIN_ROWS, lignes.length); let y = tableTop + HEAD_H; @@ -288,10 +325,15 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s for (let i = 0; i < totalRows; i++) { const lig = lignes[i] || null; - // Fond de ligne alterné - drawRect(doc, MARGIN, y, tableW, ROW_H, i % 2 === 1 ? C.altRow : C.white); + const isProrata = lig?.montantAjuste === true; - // Fond violet léger sur les 3 colonnes km (toutes lignes) + // Fond : prorata = amber pâle, sinon alternance + const rowBg = isProrata + ? C.prorataBg + : (i % 2 === 1 ? C.altRow : C.white); + drawRect(doc, MARGIN, y, tableW, ROW_H, rowBg); + + // Fond violet km for (const key of KM_COLS) { const col = COLS.find(c => c.key === key); drawRect(doc, colX[key], y, col.w, ROW_H, @@ -315,13 +357,16 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s totHT += ht; totSousKm += sousKm; + // ✅ Libellé enrichi si proratisé : afficher montant original barré + let libelleAffiche = lig.libelle || ''; + const r = { num: String(lig.numPiece || i + 1), date: fmtDate(lig.date), nature: lig.nature || '', - lib: lig.libelle || '', + lib: libelleAffiche, km: km > 0 ? f2(km) : '', - tarifKm: tarif > 0 ? f3(tarif) : '', // ex: 0.697 + tarifKm: tarif > 0 ? f3(tarif) : '', sousKm: sousKm > 0 ? f2(sousKm) : '', ttc: f2(ttc), tva21: f2(t21), tva55: f2(t55), @@ -331,34 +376,45 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s for (const col of COLS) { const isKmCol = KM_COLS.includes(col.key); - drawCellText( - doc, r[col.key], + // ✅ Couleur ambre pour montants proratisés + const textColor = isProrata && ['ttc', 'ht'].includes(col.key) + ? C.prorataText + : isKmCol ? C.kmText : C.dark; + + drawCellText(doc, r[col.key], colX[col.key], y, col.w, ROW_H, - 'Helvetica', 7, - isKmCol ? C.kmText : C.dark, - col.align - ); + 'Helvetica', 7, textColor, col.align); } + + // ✅ Indicateur proratisation — petit triangle orange en coin haut-gauche + if (isProrata) { + doc.save() + .fillColor(C.prorataText) + .moveTo(MARGIN, y) + .lineTo(MARGIN + 6, y) + .lineTo(MARGIN, y + 6) + .fill() + .restore(); + } + } else { - // Ligne vide — zéros en gris sur colonnes numériques + // Ligne vide — zéros en gris for (const col of COLS) { if (['ttc', 'tva21', 'tva55', 'tva10', 'tva20', 'ht'].includes(col.key)) - drawCellText(doc, '0.00', colX[col.key], y, col.w, ROW_H, 'Helvetica', 7, C.border, 'right'); + drawCellText(doc, '0.00', colX[col.key], y, col.w, ROW_H, + 'Helvetica', 7, C.border, 'right'); } } - // Bordures ligne drawHLine(doc, MARGIN, MARGIN + tableW, y + ROW_H); for (const col of COLS) drawVLine(doc, colX[col.key], y, y + ROW_H); drawVLine(doc, MARGIN + tableW, y, y + ROW_H); y += ROW_H; } - // ── 6. LIGNE TOTAL ─────────────────────────────────────────── + // ── Ligne Total ────────────────────────────────────────────── const totalY = y; drawRect(doc, MARGIN, totalY, tableW, ROW_H + 2, C.totalBg, C.border, 0.5); - - // Fond violet sur les colonnes km dans la ligne total for (const key of KM_COLS) { const col = COLS.find(c => c.key === key); drawRect(doc, colX[key], totalY, col.w, ROW_H + 2, C.kmTotalBg); @@ -369,7 +425,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s const totMap = { km: totKm > 0 ? f2(totKm) : '', - tarifKm: '', // pas de somme de tarifs + tarifKm: '', sousKm: totSousKm > 0 ? f2(totSousKm) : '', ttc: f2(totTTC), tva21: f2(totT21), tva55: f2(totT55), @@ -381,45 +437,82 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s if (!totMap[col.key] && totMap[col.key] !== '0.00') continue; if (totMap[col.key] === '') continue; const isKmCol = KM_COLS.includes(col.key); - drawCellText( - doc, totMap[col.key], + drawCellText(doc, totMap[col.key], colX[col.key], totalY, col.w, ROW_H + 2, - 'Helvetica-Bold', 8, - isKmCol ? C.kmText : C.dark, - 'right' - ); + 'Helvetica-Bold', 8, isKmCol ? C.kmText : C.dark, 'right'); } - // ── 7. ZONE BAS (simplifiée — les infos km sont dans le tableau) ── + // ── Zone bas ───────────────────────────────────────────────── const footY = totalY + ROW_H + 12; const montantR = parseFloat((totTTC + totSousKm).toFixed(2)); const bw = 64; - // Montant à rembourser (simplifié) + // ✅ Légende proratisation si au moins une ligne ajustée + const hasProrata = lignes.some(l => l?.montantAjuste === true); + if (hasProrata) { + const nbProrata = lignes.filter(l => l?.montantAjuste === true).length; + const montantOriginalTotal = lignes + .filter(l => l?.montantAjuste === true) + .reduce((s, l) => s + (parseFloat(l.montantOriginal) || 0), 0); + const economie = parseFloat((montantOriginalTotal - lignes + .filter(l => l?.montantAjuste === true) + .reduce((s, l) => s + (parseFloat(l.montantTTC) || 0), 0)).toFixed(2)); + + drawRect(doc, MARGIN, footY - 1, tableW * 0.6, 14, C.prorataBg, C.prorataBorder, 0.5); + doc.font('Helvetica').fontSize(6.5).fillColor(C.prorataText) + .text( + `⚠ ${nbProrata} ligne${nbProrata > 1 ? 's' : ''} de repas plafonnée${nbProrata > 1 ? 's' : ''} à 25 €/pers. par la Finance — ` + + `Montant soumis : ${f2(montantOriginalTotal)} € → Retenu : ${f2(montantOriginalTotal - economie)} €`, + MARGIN + 3, footY + 1.5, { lineBreak: false } + ); + } + + const labelOffsetY = hasProrata ? 16 : 0; + doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark) - .text('Montant total à rembourser', MARGIN, footY + 4, { lineBreak: false }); - drawRect(doc, MARGIN + 180, footY, bw + 10, 18, C.amountBg, C.border, 0.5); + .text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false }); + drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5); doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue) - .text(f2(montantR) + ' €', MARGIN + 182, footY + 3.5, { width: bw + 6, align: 'right', lineBreak: false }); + .text(f2(montantR) + ' €', + MARGIN + 182, footY + 3.5 + labelOffsetY, + { width: bw + 6, align: 'right', lineBreak: false }); - // Rappel tarif utilisé (petit, discret) doc.font('Helvetica').fontSize(6.5).fillColor(C.grey) - .text(`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`, - MARGIN, footY + 24, { lineBreak: false }); + .text( + `Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`, + MARGIN, footY + 24 + labelOffsetY, { lineBreak: false } + ); - // ── 8. SIGNATURES ───────────────────────────────────────────── + // ── Signatures ──────────────────────────────────────────────── const sigStartX = MARGIN + 310; const sigW = (tableW - 313) / 2 - 4; const sigH = 52; - const sigY = footY - 2; + const sigY = footY - 2 + labelOffsetY; const sigCollab = signatures.find(s => s.niveau === 'COLLAB'); - const sigManager = signatures.find(s => ['N1', 'N2'].includes(s.niveau)); + const sigManager = signatures.find(s => ['N1', 'N2', 'VERIF'].includes(s.niveau)); - _drawSigBox(doc, sigCollab, sigStartX, sigY, sigW, sigH, 'Date et signature Collaborateur', false); - _drawSigBox(doc, sigManager, sigStartX + sigW + 6, sigY, sigW, sigH, 'Date et signature', true); + // ✅ Afficher toutes les signatures (jusqu'à 3 : COLLAB, N1/N2, VERIF) + const sigsAffichees = ['COLLAB', 'N1', 'N2', 'VERIF'] + .map(niv => signatures.find(s => s.niveau === niv)) + .filter(Boolean) + .slice(0, 3); - // ── 9. PIED DE PAGE ─────────────────────────────────────────── + const nbSigs = sigsAffichees.length; + const sigWAdj = nbSigs > 2 ? (tableW - 313) / 3 - 4 : sigW; + + sigsAffichees.forEach((sig, idx) => { + const label = sig.niveau === 'COLLAB' ? 'Date et signature Collaborateur' + : sig.niveau === 'VERIF' ? 'Vérification Finance' + : `Date et signature Validateur ${sig.niveau}`; + _drawSigBox(doc, sig, + sigStartX + idx * (sigWAdj + 4), + sigY, sigWAdj, sigH, + label, + sig.niveau !== 'COLLAB'); + }); + + // ── Pied de page ────────────────────────────────────────────── doc.font('Helvetica').fontSize(6).fillColor(C.light) .text( `Réf. ${reference} — Généré le ${new Date().toLocaleDateString('fr-FR')} — NDF ENSUP Groupe — Document électronique`, @@ -431,7 +524,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s } // ───────────────────────────────────────────────────────────────────────────── -// _drawSigBox — boîte signature avec ou sans contenu +// _drawSigBox // ───────────────────────────────────────────────────────────────────────────── function _drawSigBox(doc, sig, x, y, w, h, label, isManager) { let bg, border, accent, icon; @@ -440,6 +533,8 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) { const a = sig.action || ''; if (a === 'refuser' || a === 'refuse') { bg = C.refusBg; border = C.refusBorder; accent = C.refusText; icon = '✗ REFUSÉ'; + } else if (sig.niveau === 'VERIF') { + bg = '#FEFCE8'; border = '#FDE047'; accent = '#CA8A04'; icon = '✓ VÉRIFIÉ Finance'; } else if (isManager) { bg = C.validBg; border = C.validBorder; accent = C.validText; icon = '✓ VALIDÉ'; } else { @@ -451,7 +546,6 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) { drawRect(doc, x, y, w, h, bg, border, 1); - // Label haut doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C.grey) .text(label, x + 4, y + 4, { width: w - 8, lineBreak: false }); @@ -477,9 +571,11 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) { doc.save().strokeColor(border).lineWidth(0.5) .moveTo(x + 3, y + h - 9).lineTo(x + w - 3, y + h - 9).stroke().restore(); doc.font('Helvetica').fontSize(6).fillColor(C.grey) - .text('Signature — NDF ENSUP', x + 4, y + h - 7, { width: w - 8, align: 'center', lineBreak: false }); + .text('Signature — NDF ENSUP', x + 4, y + h - 7, + { width: w - 8, align: 'center', lineBreak: false }); } else { doc.font('Helvetica').fontSize(8).fillColor(C.grey) - .text('En attente de signature', x + 4, y + h / 2 - 5, { width: w - 8, align: 'center', lineBreak: false }); + .text('En attente de signature', x + 4, y + h / 2 - 5, + { width: w - 8, align: 'center', lineBreak: false }); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/ndf/public/backend/server.js b/ndf/public/backend/server.js index aab2c6c..742ef0c 100644 --- a/ndf/public/backend/server.js +++ b/ndf/public/backend/server.js @@ -25,15 +25,18 @@ console.log('✅ 3. Dotenv chargé'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); const proxyCache = new Map(); -const PROXY_TTL = 10 * 60 * 1000; +const PROXY_TTL = 30 * 60 * 1000; // 10 → 30 min +const PROXY_MAX_SIZE = 200; // 50 → 200 entrées + function getCached(url) { const entry = proxyCache.get(url); if (!entry) return null; if (Date.now() - entry.at > PROXY_TTL) { proxyCache.delete(url); return null; } return entry; } + function setCache(url, buffer, contentType) { - if (proxyCache.size >= 50) { + if (proxyCache.size >= PROXY_MAX_SIZE) { const oldest = [...proxyCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; proxyCache.delete(oldest[0]); } @@ -194,20 +197,30 @@ async function getTarifKm() { } } -async function getConfigDebiteur() { +async function getConfigDebiteur(campus = null) { try { - const result = await pool.request().query(` + const request = pool.request(); + let campusWhere = ''; + + if (campus) { + const campusCode = normalizeCampus(campus) || campus; + request.input('campus', sql.NVarChar, campusCode); + campusWhere = `AND (campus = @campus OR campus IS NULL)`; + } + + const result = await request.query(` SELECT TOP 1 companyName, companyIban, companyBic, - companyAddress, companyCp, companyVille, companyPays + companyAddress, companyCp, companyVille, companyPays, campus FROM ConfigDebiteurXML - WHERE actif = 1 - ORDER BY DateModification DESC + WHERE actif = 1 ${campusWhere} + ORDER BY + CASE WHEN campus IS NOT NULL AND campus != '' THEN 0 ELSE 1 END ASC, + DateModification DESC `); if (result.recordset.length) return result.recordset[0]; } catch (e) { console.warn('⚠️ getConfigDebiteur fallback .env:', e.message); } - // Fallback .env si table inaccessible return { companyName: process.env.COMPANY_NAME || 'ENSUP GROUP', companyIban: process.env.COMPANY_IBAN || 'FR0000000000000000000000000', @@ -300,15 +313,21 @@ console.log('✅ 10. MSAL configuré'); // ================================================ // 🔑 TOKEN MICROSOFT GRAPH // ================================================ +// Cache du token Graph (évite 1 appel HTTP Azure par opération) +let _graphTokenCache = null; +let _sharePointTokenCache = null; + async function getGraphToken() { + const now = Date.now(); + if (_graphTokenCache && now < _graphTokenCache.expiresAt) { + return _graphTokenCache.token; + } + try { - console.log('🔑 Tentative d\'obtention du token...'); - console.log(' Tenant ID:', AZURE_CONFIG.tenantId ? '✅' : '❌ MANQUANT'); - console.log(' Client ID:', AZURE_CONFIG.clientId ? '✅' : '❌ MANQUANT'); - console.log(' Client Secret:', AZURE_CONFIG.clientSecret ? '✅' : '❌ MANQUANT'); + console.log('🔑 Obtention nouveau token Graph...'); if (!AZURE_CONFIG.tenantId || !AZURE_CONFIG.clientId || !AZURE_CONFIG.clientSecret) { - throw new Error('Configuration Azure incomplète - vérifiez votre fichier .env'); + throw new Error('Configuration Azure incomplète'); } const params = new URLSearchParams({ @@ -324,14 +343,13 @@ async function getGraphToken() { { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); - console.log('✅ Token obtenu avec succès'); - return response.data.access_token; + const token = response.data.access_token; + _graphTokenCache = { token, expiresAt: now + 55 * 60 * 1000 }; // 55 min + console.log('✅ Token Graph obtenu et mis en cache (55 min)'); + return token; + } catch (error) { console.error('❌ Erreur obtention token:', error.message); - if (error.response) { - console.error(' Status HTTP:', error.response.status); - console.error(' Erreur détaillée:', JSON.stringify(error.response.data, null, 2)); - } return null; } } @@ -722,7 +740,7 @@ app.get('/api/auth/callback', async (req, res) => { app.get('/api/verificateur/notes', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); - + try { const request = pool.request(); let campusWhere = ''; @@ -733,41 +751,48 @@ app.get('/api/verificateur/notes', authenticateToken, async (req, res) => { campusWhere = `AND c.campus LIKE @campus`; } } - + const result = await 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, - v2.nom + ' ' + v2.prenom AS nomN2, - vf.nom + ' ' + vf.prenom AS nomVerificateur, - n.dateVerification, n.commentaireVerification + 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 - LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId + WHERE n.statut = 'approuve' ${campusWhere} ORDER BY n.DateCreation DESC `); - + const notes = result.recordset; - for (const note of notes) { - const ncResult = await pool.request() - .input('noteId', sql.Int, note.id) - .query(` - SELECT fileName, motif, statut, dateSignalement - FROM JustificatifsNonConformes - WHERE noteDeFraisId = @noteId - ORDER BY dateSignalement DESC - `); - note.nonConformes = ncResult.recordset; + if (!notes.length) return res.json([]); + + // Charger les lignes refusées actives en batch (utile si une note "approuve" + // a déjà eu un refus archivé qu'on veut afficher en historique côté UI) + const noteIds = notes.map(n => n.id).join(','); + const refusedRows = await pool.request().query(` + SELECT noteDeFraisId, ligneIndex, motif, statut, dateRefus + FROM LignesRefusees + WHERE noteDeFraisId IN (${noteIds}) AND statut = 'active' + `); + + const refusedByNote = {}; + for (const r of refusedRows.recordset) { + if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = []; + refusedByNote[r.noteDeFraisId].push({ index: r.ligneIndex, motif: r.motif }); } - + + for (const note of notes) { + note.lignesRefusees = refusedByNote[note.id] || []; + } + res.json(notes); } catch (error) { + console.error('GET /api/verificateur/notes:', error.message); res.status(500).json({ error: error.message }); } }); @@ -776,11 +801,10 @@ app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, r if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); - const { commentaire } = req.body; + const { commentaire, montantsModifies } = req.body; const noteId = parseInt(req.params.id); try { - // Récupérer la note const noteResult = await pool.request() .input('id', sql.Int, noteId) .query(` @@ -795,27 +819,113 @@ app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, r const note = noteResult.recordset[0]; - // Marquer comme vérifiée + let lignesParsed = []; + try { lignesParsed = JSON.parse(note.lignesJson || '[]'); } catch { } + + let montantFinalAjuste = parseFloat(note.montant); + let lignesJsonModifie = note.lignesJson; + + if (Array.isArray(montantsModifies) && montantsModifies.length > 0) { + for (const mod of montantsModifies) { + const { ligneIndex, montantRetenu } = mod; + if ( + typeof ligneIndex === 'number' && + ligneIndex >= 0 && + ligneIndex < lignesParsed.length && + typeof montantRetenu === 'number' && + montantRetenu > 0 + ) { + const ligneOriginale = lignesParsed[ligneIndex]; + const montantOriginalVal = parseFloat(ligneOriginale.montant) || montantRetenu; + const ratio = montantOriginalVal > 0 ? montantRetenu / montantOriginalVal : 1; + + let tvaItemsMisAJour = ligneOriginale.tvaItems; + if (Array.isArray(ligneOriginale.tvaItems) && ligneOriginale.tvaItems.length > 0) { + tvaItemsMisAJour = ligneOriginale.tvaItems.map(item => { + const itemTTC = parseFloat((parseFloat(item.montantTTC) * ratio).toFixed(2)); + const itemTau = parseFloat(item.taux) || 0; + const itemHT = itemTau > 0 + ? parseFloat((itemTTC / (1 + itemTau / 100)).toFixed(2)) + : itemTTC; + return { ...item, montantTTC: itemTTC.toFixed(2), montantHT: itemHT.toFixed(2) }; + }); + } + + lignesParsed[ligneIndex] = { + ...ligneOriginale, + montant: montantRetenu.toFixed(2), + montantOriginal: ligneOriginale.montant, + montantAjuste: true, + tvaItems: tvaItemsMisAJour, + }; + } + } + + const tarifKm = await getTarifKm(); + montantFinalAjuste = lignesParsed.reduce((total, l) => { + const isKm = (l.categorie || '').toLowerCase().includes('kilom'); + if (isKm) { + const km = parseFloat(l.km) || 0; + const cv = parseInt(l.chevaux) || 7; + return total + getIndemniteKmServer(km, cv); + } + return total + (parseFloat(l.montant) || 0); + }, 0); + montantFinalAjuste = parseFloat(montantFinalAjuste.toFixed(2)); + lignesJsonModifie = JSON.stringify(lignesParsed); + } + + const nbLignes = lignesParsed.length; + const commentaireVerif = montantsModifies?.length > 0 + ? `` + : commentaire || null; + await pool.request() .input('id', sql.Int, noteId) .input('verificateurId', sql.Int, req.user.id) - .input('commentaire', sql.NVarChar, commentaire || null) + .input('commentaire', sql.NVarChar, commentaireVerif) + .input('montant', sql.Decimal, montantFinalAjuste) + .input('lignesJson', sql.NVarChar, lignesJsonModifie) .query(` UPDATE NoteDeFrais SET statut = 'verifie', verificateurFinanceId = @verificateurId, dateVerification = GETDATE(), commentaireVerification = @commentaire, + montant = @montant, + lignesJson = @lignesJson, DateModification = GETDATE() WHERE id = @id `); - // Historique + if (Array.isArray(montantsModifies) && montantsModifies.length > 0) { + for (const mod of montantsModifies) { + const { ligneIndex, montantRetenu } = mod; + if (typeof ligneIndex !== 'number' || montantRetenu <= 0) continue; + const numPiece = ligneIndex + 1; + const l = lignesParsed[ligneIndex] || {}; + const taux = parseFloat(l.tauxTVA) || 0; + const ht = taux > 0 ? montantRetenu / (1 + taux / 100) : montantRetenu; + + await pool.request() + .input('noteId', sql.Int, noteId) + .input('numPiece', sql.Int, numPiece) + .input('montantTTC', sql.Decimal, montantRetenu) + .input('montantHT', sql.Decimal, parseFloat(ht.toFixed(2))) + .query(` + UPDATE LigneNoteDeFrais + SET montantTTC = @montantTTC, montantHT = @montantHT + WHERE noteDeFraisId = @noteId AND numPiece = @numPiece + `); + } + } + + const commentaireHisto = `${nbLignes} ligne${nbLignes > 1 ? 's' : ''} validée${nbLignes > 1 ? 's' : ''}${montantsModifies?.length > 0 ? ` — ${montantsModifies.length} montant(s) proratisé(s)` : ''}${commentaire ? ' — ' + commentaire : ''}`; await pool.request() .input('noteId', sql.Int, noteId) .input('validateurId', sql.Int, req.user.id) .input('action', sql.NVarChar, 'verifier') - .input('commentaire', sql.NVarChar, commentaire || null) + .input('commentaire', sql.NVarChar, commentaireHisto) .input('statut', sql.NVarChar, 'verifie') .query(` INSERT INTO HistoriqueValidation @@ -824,97 +934,553 @@ app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, r (@noteId, @validateurId, 'VERIF', @action, @commentaire, @statut, GETDATE()) `); - // Trouver les ValidateurFinance du même campus pour les notifier - const campusNorm = normalizeCampus(note.campus); - const validRequest = pool.request() - .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%'); - const validateurs = await validRequest.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 = 'ValidateurFinance' AND r.actif = 1 - AND c.campus LIKE @campus AND c.Actif = 1 -`); + res.json({ + success: true, + statut: 'verifie', + montantAjuste: montantFinalAjuste, + nbMontantsModifies: Array.isArray(montantsModifies) ? montantsModifies.length : 0, + }); + setImmediate(async () => { + try { + // ── Récupérer historique des signatures ─────────────────── + const histResult = await pool.request() + .input('noteId', sql.Int, noteId) + .query(` + SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction, + c.prenom + ' ' + c.nom AS nomPrenom + FROM HistoriqueValidation h + JOIN CollaborateurAD c ON c.id = h.ValidateurId + WHERE h.NoteDeFraisId = @noteId + ORDER BY h.DateAction ASC + `); + + const noteComplete = await pool.request() + .input('id', sql.Int, noteId) + .query(` + SELECT n.reference, n.libelle, n.montant, n.date, + n.categorie, n.lignesJson, n.fichiers, n.DateCreation, + c.prenom + ' ' + c.nom AS nomPrenom, + c.prenom AS collabPrenom, c.nom AS collabNom, + c.departement + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + WHERE n.id = @id + `); + + const nd = noteComplete.recordset[0]; + if (!nd) return; + + const moisStr = (() => { + const d = new Date(nd.date); + const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + return m.charAt(0).toUpperCase() + m.slice(1); + })(); + + const nomPrenom = nd.nomPrenom; + const verificateurNom = `${req.user.prenom} ${req.user.nom}`; + + // ── Construire les signatures COLLAB + N1/N2 + VERIF ────── + const signatures = []; + signatures.push({ + niveau: 'COLLAB', nomPrenom, + date: nd.DateCreation, action: 'soumettre', commentaire: null + }); + for (const h of histResult.recordset) { + if (h.Niveau !== 'VERIF') { + signatures.push({ + niveau: h.Niveau, nomPrenom: h.nomPrenom, + date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null + }); + } + } + signatures.push({ + niveau: 'VERIF', nomPrenom: verificateurNom, + date: new Date(), action: 'verifier', commentaire: commentaireVerif || null + }); + + const noteDataPDF = { + reference: nd.reference, + nomPrenom, + mois: moisStr, + departement: nd.departement, + lignesJson: lignesJsonModifie, + tarifKm: await getTarifKm(), + statut: 'verifie', + montant: montantFinalAjuste, + }; + + let fichiersExistants = []; + try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { } + + const existingFolder = fichiersExistants[0]?.folderPath; + const nomDossier = existingFolder + ? existingFolder.split('/')[1] + : `${nd.collabNom}_${nd.collabPrenom}` + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_'); + const moisDossier = existingFolder + ? existingFolder.split('/')[2] + : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + + // ── Générer PDF fiche vérifiée (fiche seule) ────────────── + try { + const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); + const suffixe = montantsModifies?.length > 0 ? 'verifie-proratise' : 'verifie'; + + const signedResult = await uploadToSharePointHierarchique( + { + buffer: pdfSigne, + originalname: `${nd.reference}-${suffixe}.pdf`, + mimetype: 'application/pdf', + size: pdfSigne.length + }, + nd.reference, nomDossier, moisDossier + ); + fichiersExistants.push(signedResult); + + await pool.request() + .input('id', sql.Int, noteId) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) + .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); + + console.log(`✅ [ASYNC] PDF vérification ${suffixe} généré: ${signedResult.fileName}`); + } catch (e) { + console.error('❌ [ASYNC] PDF vérification:', e.message); + } + + // ── Régénérer le recap complet (fiche + justifs + 3 signatures) ── + 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 + ); + + // Récupérer la liste de fichiers à jour après upload du PDF verifie + const noteUpdated = await pool.request() + .input('id', sql.Int, noteId) + .query('SELECT fichiers FROM NoteDeFrais WHERE id = @id'); + + let fichiersAJour = []; + try { fichiersAJour = JSON.parse(noteUpdated.recordset[0]?.fichiers || '[]'); } catch { } + + // Remplacer l'ancien _recap.pdf (garder recap-paiement intact) + const fichiersFinaux = fichiersAJour.filter(f => { + const fname = (f.fileName || '').toLowerCase(); + return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement'); + }); + fichiersFinaux.push(recapResult); + + await pool.request() + .input('id', sql.Int, noteId) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersFinaux)) + .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); + + console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s) (proratisé: ${Array.isArray(montantsModifies) && montantsModifies.length > 0}): ${recapResult.fileName}`); + } catch (recapError) { + console.error('❌ [ASYNC] Régénération recap vérification:', recapError.message); + } + + // ── Notifier les ValidateurFinance ──────────────────────── + try { + const campusNorm = normalizeCampus(note.campus); + const validateurs = 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 = 'ValidateurFinance' AND r.actif = 1 + AND c.campus LIKE @campus AND c.Actif = 1 + `); + + const montantFormate = montantFinalAjuste.toFixed(2); + const montantOriginal = parseFloat(note.montant).toFixed(2); + const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; + const aProratise = Array.isArray(montantsModifies) && montantsModifies.length > 0; + + for (const val of validateurs.recordset) { + try { + await creerNotification({ + destinataireId: val.id, + destinataireEmail: val.email, + type: 'paiement', + titre: `✅ Note vérifiée à valider — ${note.reference}`, + message: `${verificateurNom} a vérifié la note ${note.reference} (${montantFormate} €${aProratise ? ` — montant ajusté de ${montantOriginal} €` : ''}) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`, + noteId + }); + } catch (e) { console.error('Notif ValidateurFinance:', e.message); } + + try { + await sendMailGraph( + val.email, + `✅ Note vérifiée — validation paiement requise : ${note.reference}`, + `
+
+

✅ Note vérifiée — paiement à valider

+
+
+

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

+

La note ${note.reference} de ${note.prenom} ${note.nom} a été vérifiée par ${verificateurNom} et est prête pour le paiement.

+ ${aProratise ? `
+ ⚠️ Montants proratisés
+ Montant original : ${montantOriginal} € → Montant retenu : ${montantFormate} € (${montantsModifies.length} repas plafonné${montantsModifies.length > 1 ? 's' : ''} à 25 €/pers.) +
` : ''} + ${commentaire ? `

💬 ${commentaire}

` : ''} +
+ Valider le paiement → +
+
+
` + ); + } catch (e) { console.error('Email ValidateurFinance:', e.message); } + } + + // Notifier le collaborateur + const montantAjusteMsg = aProratise + ? `Votre note ${note.reference} a été vérifiée. Montant retenu : ${montantFormate} € (ajusté depuis ${montantOriginal} € — plafonnement repas à 25 €/pers.).` + : `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`; + + try { + await creerNotification({ + destinataireId: note.collaborateurId, + destinataireEmail: note.email, + type: 'paiement', + titre: `Note ${note.reference} ${aProratise ? 'vérifiée — montant ajusté' : 'en cours de traitement'}`, + message: montantAjusteMsg, + noteId + }); + + if (aProratise) { + await sendMailGraph( + note.email, + `ℹ️ Montant ajusté — Note ${note.reference}`, + `
+
+

ℹ️ Montant de votre note ajusté

+
+
+

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

+

Votre note ${note.reference} a été vérifiée par la Finance. Certains frais de repas ont été plafonnés à 25 €/personne conformément à la politique de l'entreprise.

+
+ + + + +
Montant soumis${montantOriginal} €
Montant retenu${montantFormate} €
Ajustements${montantsModifies.length} ligne${montantsModifies.length > 1 ? 's' : ''} de repas plafonnée${montantsModifies.length > 1 ? 's' : ''}
+
+

Le plafond légal pour les frais de repas est de 25 € par personne. Les montants ont été ajustés en conséquence.

+
+
` + ); + } + } catch (e) { console.error('Notif collab vérification:', e.message); } + + } catch (e) { + console.error('❌ [ASYNC] Notifications vérification:', e.message); + } + + } catch (e) { + console.error('❌ [ASYNC] PDF vérification général:', e.message); + } + }); + + } catch (error) { + console.error('Erreur PUT /verificateur/notes/:id/verifier:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/verificateur/notes/:id/refuser', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); + + const noteId = parseInt(req.params.id); + const { lignesRefusees, notifier = true } = req.body; + + if (!Array.isArray(lignesRefusees) || lignesRefusees.length === 0) + return res.status(400).json({ error: 'Au moins une ligne refusée est requise' }); + + // Validation : chaque entrée doit avoir index (number) + motif (string non vide) + for (const r of lignesRefusees) { + if (typeof r.index !== 'number' || r.index < 0) + return res.status(400).json({ error: 'Chaque ligne refusée doit avoir un index (number) >= 0' }); + if (!r.motif || typeof r.motif !== 'string' || !r.motif.trim()) + return res.status(400).json({ error: `Motif manquant pour la ligne d'index ${r.index}` }); + } + + const transaction = new sql.Transaction(pool); + + try { + // Récupérer la note + collab + N1 + N2 (avant transaction pour validation) + const noteResult = await pool.request() + .input('id', sql.Int, noteId) + .query(` + SELECT n.id, n.reference, n.libelle, n.montant, n.statut, + n.collaborateurId, n.lignesJson, + c.prenom, c.nom, c.email, c.campus, + 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 + + WHERE n.id = @id AND n.statut = 'approuve' + `); + + if (!noteResult.recordset.length) + return res.status(404).json({ error: 'Note introuvable ou statut incompatible (doit être "approuve")' }); + + const note = noteResult.recordset[0]; + + // Parser les lignes pour récupérer libellé + catégorie au moment du refus + let lignesData = []; + try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { } + + // Vérifier que les index sont valides + for (const r of lignesRefusees) { + if (r.index >= lignesData.length) + return res.status(400).json({ error: `Index ${r.index} hors limites (note a ${lignesData.length} lignes)` }); + } + + await transaction.begin(); + + // ── 1. Archiver les anciens refus actifs (si re-refus après correction partielle) + await new sql.Request(transaction) + .input('noteId', sql.Int, noteId) + .query(` + UPDATE LignesRefusees + SET statut = 'archive' + WHERE noteDeFraisId = @noteId AND statut = 'active' + `); + + // ── 2. Insérer les nouveaux refus + for (const r of lignesRefusees) { + const ligne = lignesData[r.index] || {}; + await new sql.Request(transaction) + .input('noteId', sql.Int, noteId) + .input('ligneIndex', sql.Int, r.index) + .input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`) + .input('ligneCategorie', sql.NVarChar, ligne.categorie || null) + .input('motif', sql.NVarChar, r.motif.trim()) + .input('verificateurId', sql.Int, req.user.id) + .query(` + INSERT INTO LignesRefusees + (noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut) + VALUES + (@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active') + `); + } + + // ── 3. Passer la note en 'refuse_verif' + const commentaireSynth = lignesRefusees.map(r => { + const ligne = lignesData[r.index] || {}; + const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; + return `• ${label} — ${r.motif.trim()}`; + }).join(' | '); + + await new sql.Request(transaction) + .input('id', sql.Int, noteId) + .input('verificateurId', sql.Int, req.user.id) + .input('commentaire', sql.NVarChar, `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} : ${commentaireSynth}`) + .query(` + UPDATE NoteDeFrais SET + statut = 'refuse_verif', + verificateurFinanceId = @verificateurId, + dateVerification = GETDATE(), + commentaireVerification = @commentaire, + DateModification = GETDATE() + WHERE id = @id + `); + + // ── 4. Historique + await new sql.Request(transaction) + .input('noteId', sql.Int, noteId) + .input('validateurId', sql.Int, req.user.id) + .input('commentaire', sql.NVarChar, commentaireSynth) + .input('statut', sql.NVarChar, 'refuse_verif') + .query(` + INSERT INTO HistoriqueValidation + (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) + VALUES + (@noteId, @validateurId, 'VERIF', 'refuser', @commentaire, @statut, GETDATE()) + `); + + await transaction.commit(); + + // ── 5. Notifications (en dehors de la transaction) const verificateurNom = `${req.user.prenom} ${req.user.nom}`; const montantFormate = parseFloat(note.montant).toFixed(2); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; - - for (const val of validateurs.recordset) { - // Notification BDD + + // HTML : tableau récap des lignes refusées + const tableLignesHtml = lignesRefusees.map(r => { + const ligne = lignesData[r.index] || {}; + const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; + const cat = ligne.categorie || '—'; + return ` + + ${r.index + 1} + +
${label}
+
${cat}
+ + ${r.motif.trim()} + `; + }).join(''); + + let notifiedCollab = false, notifiedN1 = false; + + if (notifier) { + // Notif collaborateur try { await creerNotification({ - destinataireId: val.id, - destinataireEmail: val.email, - type: 'paiement', - titre: `✅ Note vérifiée à valider — ${note.reference}`, - message: `${verificateurNom} a vérifié la note ${note.reference} (${montantFormate} €) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`, - noteId: noteId + destinataireId: note.collaborateurId, + destinataireEmail: note.email, + type: 'refus', + titre: `❌ Note ${note.reference} refusée par la Finance`, + message: `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} sur votre note ${note.reference}. Vous devez corriger uniquement ces lignes et resoumettre.`, + noteId }); - } catch (e) { console.error('Notif ValidateurFinance:', e.message); } - - // Email + notifiedCollab = true; + } catch (e) { console.error('Notif BDD collab refus:', e.message); } + try { await sendMailGraph( - val.email, - `✅ Note vérifiée — validation paiement requise : ${note.reference}`, - `
-
-

✅ Note vérifiée — paiement à valider

+ note.email, + `❌ Note refusée — corrections demandées : ${note.reference}`, + `
+
+

❌ Votre note a été refusée par la Finance

+

${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger

-

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

-

La note ${note.reference} de ${note.prenom} ${note.nom} (${montantFormate} €) - a été vérifiée par ${verificateurNom} et est prête pour le paiement.

- ${commentaire ? `

- 💬 Commentaire vérificateur : ${commentaire}

` : ''} -
+

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

+

Votre note ${note.reference} (${montantFormate} €) a été refusée par ${verificateurNom} (Vérificateur Finance).

+ +
+
+ + Lignes à corriger (${lignesRefusees.length}) + +
- - - - + + + + + + + + ${tableLignesHtml}
Référence${note.reference}
Collaborateur${note.prenom} ${note.nom}
Montant${montantFormate} €
Campus${note.campus || '—'}
LigneMotif
-
` ); - } catch (e) { console.error('Email ValidateurFinance:', e.message); } + } catch (e) { console.error('Email collab refus:', e.message); } + + // Notif N1 + if (note.n1Id && note.emailN1) { + try { + await creerNotification({ + destinataireId: note.n1Id, + destinataireEmail: note.emailN1, + type: 'refus', + titre: `⚠️ Note ${note.reference} refusée par la Finance`, + message: `La note ${note.reference} de ${note.prenom} ${note.nom} a été refusée par ${verificateurNom} (${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger).`, + noteId + }); + notifiedN1 = true; + } catch (e) { console.error('Notif BDD N1 refus:', e.message); } + + try { + await sendMailGraph( + note.emailN1, + `⚠️ Note ${note.reference} refusée par la Finance`, + `
+
+

⚠️ Note refusée par la Finance

+
+
+

Bonjour ${note.prenomN1} ${note.nomN1},

+

La note ${note.reference} de ${note.prenom} ${note.nom} que vous aviez validée a été refusée par ${verificateurNom} (Vérificateur Finance).

+
+
${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger
+ + ${lignesRefusees.map(r => { + const ligne = lignesData[r.index] || {}; + const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; + return ``; + }).join('')} +
${label}${r.motif.trim()}
+
+

${note.prenom} ${note.nom} a été notifié et doit corriger uniquement les lignes listées.

+
+
` + ); + } catch (e) { console.error('Email N1 refus:', e.message); } + } } - - // Notifier aussi le collaborateur - try { - await creerNotification({ - destinataireId: note.collaborateurId, - destinataireEmail: note.email, - type: 'paiement', - titre: `Note ${note.reference} en cours de traitement`, - message: `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`, - noteId: noteId - }); - } catch (e) { console.error('Notif collab vérification:', e.message); } - - res.json({ success: true, statut: 'verifie', notifiesCount: validateurs.recordset.length }); - + + res.json({ + success: true, + statut: 'refuse_verif', + nbLignesRefusees: lignesRefusees.length, + notifiedCollab, + notifiedN1 + }); + } catch (error) { - console.error('Erreur PUT verificateur/notes/:id/verifier:', error.message); + try { await transaction.rollback(); } catch { } + console.error('Erreur POST /verificateur/notes/:id/refuser:', error.message); res.status(500).json({ error: error.message }); } }); -// GET /api/verificateur/historique app.get('/api/verificateur/historique', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès refusé' }); - + try { const request = pool.request().input('verificateurId', sql.Int, req.user.id); - + let campusWhere = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { const campusCode = normalizeCampus(req.user.campus); @@ -923,62 +1489,71 @@ app.get('/api/verificateur/historique', authenticateToken, async (req, res) => { campusWhere = 'AND c.campus LIKE @campus'; } } - + const result = await request.query(` SELECT - n.id, n.reference, n.libelle, n.montant, + n.id, n.reference, n.libelle, n.montant, n.statut, n.dateVerification, n.commentaireVerification, n.lignesJson, n.fichiers, c.nom + ' ' + c.prenom AS collaborateur, - c.campus, c.departement, - (SELECT COUNT(*) FROM JustificatifsNonConformes j - WHERE j.noteDeFraisId = n.id) AS nbNonConformes + c.campus, c.departement FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.verificateurFinanceId = @verificateurId - AND n.statut IN ('verifie', 'paiementenattente', 'payee') + AND n.statut IN ('verifie', 'paiementenattente', 'payee', 'refuse_verif', + 'refuse_verif_archive', 'non_conforme_verif', 'non_conforme_archive') ${campusWhere} ORDER BY n.dateVerification DESC `); - - const notesAvecNC = await Promise.all(result.recordset.map(async row => { - const ncResult = await pool.request() - .input('noteId', sql.Int, row.id) - .query(` - SELECT fileName, motif, statut, dateSignalement - FROM JustificatifsNonConformes - WHERE noteDeFraisId = @noteId - ORDER BY dateSignalement DESC - `); - - const nonConformes = ncResult.recordset; - - let nbJustifs = 0; - try { - const fichiers = JSON.parse(row.fichiers || '[]'); - const SYSTEME = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve']; - nbJustifs = fichiers.filter(f => - !SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw)) - ).length; - } catch { } - - const nbNonConformes = nonConformes.length; - const nbConformes = Math.max(0, nbJustifs - nbNonConformes); - + + const notes = result.recordset; + if (!notes.length) return res.json([]); + + // Récupérer toutes les lignes refusées en un seul appel + const noteIds = notes.map(n => n.id).join(','); + const refusedRows = await pool.request().query(` + SELECT noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, dateRefus + FROM LignesRefusees + WHERE noteDeFraisId IN (${noteIds}) + ORDER BY noteDeFraisId, ligneIndex ASC + `); + + const refusedByNote = {}; + for (const r of refusedRows.recordset) { + if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = []; + refusedByNote[r.noteDeFraisId].push({ + index: r.ligneIndex, + motif: r.motif, + ligneLibelle: r.ligneLibelle, + ligneCategorie: r.ligneCategorie, + dateRefus: r.dateRefus + }); + } + + const enriched = notes.map(row => { + let nbLignes = 0; + try { nbLignes = (JSON.parse(row.lignesJson || '[]')).length; } catch { } + const lignesRefusees = refusedByNote[row.id] || []; + const nbLignesRefusees = lignesRefusees.length; + const nbLignesOk = Math.max(0, nbLignes - nbLignesRefusees); + const isRefusee = row.statut === 'refuse_verif' || row.statut === 'refuse_verif_archive' + || row.statut === 'non_conforme_verif' || row.statut === 'non_conforme_archive'; + return { ...row, - nbJustifs, - nbConformes, - nbNonConformes, - nonConformes, - dateVerification: row.dateVerification, + statut: isRefusee ? 'REFUSEE' : 'VERIFIEE', + nbLignes, + nbLignesOk: isRefusee ? nbLignesOk : nbLignes, + nbLignesRefusees, + lignesRefusees, commentaire: row.commentaireVerification, }; - })); - - res.json(notesAvecNC); - + }); + + res.json(enriched); + } catch (error) { + console.error('GET /api/verificateur/historique:', error.message); res.status(500).json({ error: error.message }); } }); @@ -1149,16 +1724,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, ''); @@ -1173,14 +1747,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'); @@ -1196,9 +1775,24 @@ async function uploadToSharePoint(file, ndfReference, collaborateurNomPrenom) { ); return { fileName, uploadUrl: res.data.webUrl }; } - +const downloadUrlCache = new Map(); // webUrl SP → { url, expiresAt } async function downloadFromSharePoint(webUrl) { const accessToken = await getGraphToken(); + + // ✅ Cache de l'URL de téléchargement direct (valable ~1h) + const cached = downloadUrlCache.get(webUrl); + if (cached && Date.now() < cached.expiresAt) { + try { + const fileRes = await axios.get(cached.url, { + responseType: 'arraybuffer', + timeout: 15000 + }); + return Buffer.from(fileRes.data); + } catch { + downloadUrlCache.delete(webUrl); // URL expirée, on refait + } + } + const urlObj = new URL(webUrl); const fullPath = decodeURIComponent(urlObj.pathname); const marker = '/Shared Documents/'; @@ -1210,27 +1804,49 @@ async function downloadFromSharePoint(webUrl) { const parts = fullPath.split('/sites/')[1]?.split('/'); relativePath = parts?.slice(2).join('/') || ''; } - const res = await axios.get( - `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}:/content`, - { headers: { Authorization: `Bearer ${accessToken}` }, responseType: 'arraybuffer' } + + const metaRes = await axios.get( + `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`, + { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 5000 } ); - return Buffer.from(res.data); + + const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl']; + if (!downloadUrl) throw new Error('downloadUrl absent de la réponse Graph'); + + // Mettre en cache 50 min (les URLs pré-signées expirent vers 1h) + downloadUrlCache.set(webUrl, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 }); + + const fileRes = await axios.get(downloadUrl, { + responseType: 'arraybuffer', + timeout: 15000 + }); + return Buffer.from(fileRes.data); } 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 }; @@ -1273,111 +1889,200 @@ async function generateRecapWithJustifs(noteData, justifFiles, signaturesOpt) { return Buffer.from(await finalPdf.save()); } +// GET /api/notes/:id/download-urls — préchargement des URLs directes +app.get('/api/notes/:id/download-urls', authenticateToken, async (req, res) => { + try { + const noteId = parseInt(req.params.id); + + // Récupérer les fichiers de la note + const noteResult = await pool.request() + .input('id', sql.Int, noteId) + .query(`SELECT fichiers, lignesJson FROM NoteDeFrais WHERE id = @id`); + + if (!noteResult.recordset.length) return res.json({}); + + const note = noteResult.recordset[0]; + let allUrls = []; + + // Fichiers globaux + try { + const fichiers = JSON.parse(note.fichiers || '[]'); + fichiers.forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); }); + } catch { } + + // Fichiers des lignes + try { + const lignes = JSON.parse(note.lignesJson || '[]'); + lignes.forEach(l => { + (l.qrFiles || []).forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); }); + }); + } catch { } + + if (!allUrls.length) return res.json({}); + + const accessToken = await getGraphToken(); + const result = {}; + + // ✅ Graph Batch — résout toutes les URLs en UNE SEULE requête HTTP + const batchRequests = allUrls.slice(0, 20).map((url, i) => { + const urlObj = new URL(url); + const fullPath = decodeURIComponent(urlObj.pathname); + const marker = '/Shared Documents/'; + const markerAlt = '/Documents/'; + let relativePath = ''; + if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1]; + else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1]; + else { + const parts = fullPath.split('/sites/')[1]?.split('/'); + relativePath = parts?.slice(2).join('/') || ''; + } + return { + id: String(i), + method: 'GET', + url: `/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}` + }; + }); + + const batchRes = await axios.post( + 'https://graph.microsoft.com/v1.0/$batch', + { requests: batchRequests }, + { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } + ); + + for (const response of batchRes.data.responses) { + const i = parseInt(response.id); + const downloadUrl = response.body?.['@microsoft.graph.downloadUrl']; + if (downloadUrl && allUrls[i]) { + result[allUrls[i]] = downloadUrl; + // Mettre en cache côté serveur aussi + downloadUrlCache.set(allUrls[i], { + url: downloadUrl, + expiresAt: Date.now() + 50 * 60 * 1000 + }); + } + } + + res.json(result); + } catch (error) { + console.error('GET /api/notes/:id/download-urls:', error.message); + res.json({}); // Fail silencieux — le client tombera sur le proxy normal + } +}); + // ══════════════════════════════════════════════════════ // POST /api/notes — Créer une note de frais (multi-lignes) // ══════════════════════════════════════════════════════ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { + try { + const { libelle, date, description, participants, nombreParticipants, lignes, qrNoteRef } = req.body; + + if (!libelle || !date || !lignes) + return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' }); + + let lignesParsed; try { - const { libelle, date, description, participants, nombreParticipants, lignes, qrNoteRef } = req.body; + lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; + } catch { + return res.status(400).json({ error: 'Format des lignes invalide' }); + } + if (!Array.isArray(lignesParsed) || lignesParsed.length === 0) + return res.status(400).json({ error: 'Au moins une ligne est obligatoire' }); - if (!libelle || !date || !lignes) - return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' }); + // ── Tarif KM ───────────────────────────────────────────────────── + let tarifKm = await getTarifKm(); + try { + const annee = new Date().getFullYear(); + const kmParam = await pool.request() + .input('annee', sql.Int, annee) + .query(`SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC`); + if (kmParam.recordset[0]?.tarifParKm) tarifKm = parseFloat(kmParam.recordset[0].tarifParKm); + } catch { } - let lignesParsed; - try { - lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; - } catch { - return res.status(400).json({ error: 'Format des lignes invalide' }); + const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm); + const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0); + const indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0); + const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0); + const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2)); + const montantFormate = montantFinal.toFixed(2); + const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0); + const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple'; + + // ── Collaborateur + hiérarchie ──────────────────────────────────── + 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`); + if (!collabResult.recordset.length) + return res.status(404).json({ error: 'Collaborateur non trouvé' }); + const collaborateur = collabResult.recordset[0]; + const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`; + + 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 hierarchie = await pool.request() + .input('collabId', sql.Int, req.user.id) + .query(` + 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; + const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null; + const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null; + const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null; + + 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(); + const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + + // ── Collecte des fichiers (QR global) ──────────────────────────── + const allFiles = [...(req.files || [])]; + if (qrNoteRef) { + const qrToken = await pool.request() + .input('noteRef', sql.NVarChar, qrNoteRef) + .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); + const qrDownloads = await Promise.all( + qrFichiers.map(f => + downloadFromSharePoint(f.uploadUrl) + .then(buf => ({ + buffer: buf, + originalname: f.fileName, + mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', + size: buf.length + })) + .catch(e => { console.warn('⚠️ QR global download fail:', e.message); return null; }) + ) + ); + qrDownloads.filter(Boolean).forEach(f => allFiles.push(f)); } - if (!Array.isArray(lignesParsed) || lignesParsed.length === 0) - return res.status(400).json({ error: 'Au moins une ligne est obligatoire' }); + } - let tarifKm = await getTarifKm(); - try { - const annee = new Date().getFullYear(); - const kmParam = await pool.request() - .input('annee', sql.Int, annee) - .query(`SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC`); - if (kmParam.recordset[0]?.tarifParKm) tarifKm = parseFloat(kmParam.recordset[0].tarifParKm); - } catch { } - - const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm); - const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0); - const indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0); - const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0); - const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2)); - const montantFormate = montantFinal.toFixed(2); - const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0); - console.log('🔍 isKmOnly:', isKmOnly, 'kmTotal:', kmTotal, 'montantTTC:', montantTTC); - - const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple'; - - // POST /api/notes — ligne ~420 - 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`); - if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur non trouvé' }); - const collaborateur = collabResult.recordset[0]; - const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`; - - 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 hierarchie = await pool.request() - .input('collabId', sql.Int, req.user.id) - .query(` - SELECT h.SuperieurId, h.[SuperieurIdn+2], - s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1, - s2.email AS emailN2 - 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; - - const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); - - 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 allFiles = [...(req.files || [])]; - if (qrNoteRef) { - const qrToken = await pool.request() - .input('noteRef', sql.NVarChar, qrNoteRef) - .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); - for (const f of qrFichiers) { - try { - const buf = await downloadFromSharePoint(f.uploadUrl); - allFiles.push({ buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length }); - } catch (e) { console.warn('⚠️ QR global download fail:', e.message); } - } - } - } - - // ✅ QR par ligne — récupère et STOCKE les fichiers dans qrFiles de chaque ligne - for (let i = 0; i < lignesParsed.length; i++) { - const ligneQrRef = lignesParsed[i].qrNoteRef; - if (!ligneQrRef) continue; + // ── QR par ligne : téléchargement + upload en parallèle ────────── + await Promise.all( + lignesParsed.map(async (ligne, i) => { + const ligneQrRef = ligne.qrNoteRef; + if (!ligneQrRef) return; try { const qrLigne = await pool.request() .input('noteRef', sql.NVarChar, ligneQrRef) .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); - if (qrLigne.recordset.length && qrLigne.recordset[0].fichiers) { - const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers); + if (!qrLigne.recordset.length || !qrLigne.recordset[0].fichiers) { + console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`); + return; + } + const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers); + if (!ligne.qrFiles) ligne.qrFiles = []; - // ✅ Initialiser qrFiles pour cette ligne - if (!lignesParsed[i].qrFiles) lignesParsed[i].qrFiles = []; - - for (const f of qrFichiers) { + await Promise.all( + qrFichiers.map(async f => { try { const buf = await downloadFromSharePoint(f.uploadUrl); const fileObj = { @@ -1386,211 +2091,221 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length }; - - // Ajouter à allFiles pour le récap PDF global allFiles.push(fileObj); - // ✅ Uploader vers SP avec la référence finale et stocker dans qrFiles - try { - const uploaded = await uploadToSharePointHierarchique( - fileObj, reference, nomDossier, moisDossier - ); - // Éviter les doublons - const dejaSauve = lignesParsed[i].qrFiles.some(x => x.fileName === uploaded.fileName); - if (!dejaSauve) { - lignesParsed[i].qrFiles.push({ - fileName: uploaded.fileName, - uploadUrl: uploaded.uploadUrl - }); - } - console.log(`✅ QR ligne ${i} stocké dans qrFiles: ${uploaded.fileName}`); - } catch (uploadErr) { - console.warn(`⚠️ Upload SP ligne ${i}:`, uploadErr.message); + const uploaded = await uploadToSharePointHierarchique(fileObj, reference, nomDossier, moisDossier); + const dejaSauve = ligne.qrFiles.some(x => x.fileName === uploaded.fileName); + if (!dejaSauve) { + ligne.qrFiles.push({ fileName: uploaded.fileName, uploadUrl: uploaded.uploadUrl }); } - } catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); } - } - } else { - console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`); - } + console.log(`✅ QR ligne ${i} stocké: ${uploaded.fileName}`); + } catch (e) { + console.warn(`⚠️ QR ligne ${i} download/upload fail:`, e.message); + } + }) + ); } catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); } - } + }) + ); - // ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles - const lignesJsonFinal = JSON.stringify(lignesParsed); + const lignesJsonFinal = JSON.stringify(lignesParsed); - const fichiersUploades = []; - for (const file of allFiles) { + // ── Upload justificatifs en parallèle ──────────────────────────── + const fichiersUploades = (await Promise.all( + allFiles.map(file => + uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier) + .catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; }) + ) + )).filter(Boolean); + + const noteDataPDF = { + reference, + nomPrenom, + mois: moisCapitalized, + date, + categorie: categorieNote, + libelle, + montant: montantFinal, + lignes: lignesParsed, + lignesJson: lignesJsonFinal, + tarifKm, + statut: 'enattente', + departement: collaborateur.departement, + participants: participants || null, + }; + + // ── Insérer la note en BDD ─────────────────────────────────────── + const insertResult = await pool.request() + .input('reference', sql.NVarChar, reference) + .input('collaborateurId', sql.Int, req.user.id) + .input('libelle', sql.NVarChar, libelle) + .input('montant', sql.Decimal, montantFinal) + .input('date', sql.Date, new Date(date)) + .input('categorie', sql.NVarChar, categorieNote) + .input('description', sql.NVarChar, description || null) + .input('participants', sql.NVarChar, participants ? String(participants) : null) + .input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null) + .input('sharepointUrl', sql.NVarChar, fichiersUploades[0]?.uploadUrl || null) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) + .input('statut', sql.NVarChar, 'enattente') + .input('validateurN1Id', sql.Int, n1Id) + .input('montantHT', sql.Decimal, null) + .input('tauxTVA', sql.Decimal, null) + .input('montantTVA21', sql.Decimal, null) + .input('montantTVA55', sql.Decimal, null) + .input('montantTVA10', sql.Decimal, null) + .input('montantTVA20', sql.Decimal, null) + .input('km', sql.Decimal, kmTotal || null) + .input('indemniteKm', sql.Decimal, indemKm || null) + .input('lignesJson', sql.NVarChar, lignesJsonFinal) + .query(` + INSERT INTO NoteDeFrais + (reference, collaborateurId, libelle, montant, date, categorie, + description, participants, nombreParticipants, sharepointUrl, fichiers, + statut, validateurN1Id, + 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, + @statut, @validateurN1Id, + @montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20, + @km, @indemniteKm, @lignesJson) + `); + + const noteCreee = insertResult.recordset[0]; + + // ── Insérer les lignes ──────────────────────────────────────────── + for (let i = 0; i < lignesParsed.length; i++) { + const l = lignesParsed[i]; + const pdf = lignesPDF[i]; + try { + await pool.request() + .input('noteId', sql.Int, noteCreee.id) + .input('numPiece', sql.Int, i + 1) + .input('date', sql.Date, new Date(l.date)) + .input('nature', sql.NVarChar, l.categorie || '') + .input('libelle', sql.NVarChar, l.libelle || '') + .input('km', sql.Decimal, pdf.km || null) + .input('montantTTC', sql.Decimal, pdf.montantTTC || null) + .input('tva21', sql.Decimal, pdf.tva21 || null) + .input('tva55', sql.Decimal, pdf.tva55 || null) + .input('tva10', sql.Decimal, pdf.tva10 || null) + .input('tva20', sql.Decimal, pdf.tva20 || null) + .input('montantHT', sql.Decimal, pdf.montantHT || null) + .input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null) + .input('indemniteKm', sql.Decimal, pdf.indemniteKm || null) + .query(` + INSERT INTO LigneNoteDeFrais + (noteDeFraisId, numPiece, date, nature, libelle, + km, montantTTC, tva21, tva55, tva10, tva20, + montantHT, tauxTVA, indemniteKm) + VALUES + (@noteId, @numPiece, @date, @nature, @libelle, + @km, @montantTTC, @tva21, @tva55, @tva10, @tva20, + @montantHT, @tauxTVA, @indemniteKm) + `); + } catch (e) { console.error(`❌ Insertion ligne ${i + 1}:`, e.message); } + } + + console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`); + + res.status(201).json({ + success: true, + id: noteCreee.id, + reference: noteCreee.reference, + fichiers: fichiersUploades, + recapUrl: null, + pending: true, + }); + + // ── Traitement lourd en arrière-plan ───────────────────────────── + setImmediate(async () => { + const fichiersAsync = [...fichiersUploades]; + try { + console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`); + const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; + + let ficheResult = null; try { - const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier); - fichiersUploades.push(r); - } catch (e) { console.error(`❌ Upload justif ${file.originalname}:`, e.message); } - } + const fichePDF = await generateFicheSignee( + noteDataPDF, + [{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: null }] + ); + ficheResult = await uploadToSharePointHierarchique( + { buffer: fichePDF, originalname: `${reference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length }, + reference, nomDossier, moisDossier + ); + fichiersAsync.push(ficheResult); + console.log(`✅ [ASYNC] Fiche soumission: ${ficheResult.fileName}`); + } catch (e) { console.error('❌ [ASYNC] Fiche PDF:', e.message); } - const noteDataPDF = { - reference, - nomPrenom, - mois: moisCapitalized, - date, - categorie: categorieNote, - libelle, - montant: montantFinal, - lignes: lignesParsed, - lignesJson: JSON.stringify(lignesParsed), - tarifKm: await getTarifKm(), - statut: 'enattente', - departement: collaborateur.departement, - participants: participants || null, - }; + let recapUrl = null; + try { + const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles); + const recapResult = await uploadToSharePointHierarchique( + { buffer: recapBuffer, originalname: `${reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, + reference, nomDossier, moisDossier + ); + fichiersAsync.push(recapResult); + recapUrl = recapResult.uploadUrl; + console.log(`✅ [ASYNC] Récap PDF: ${recapResult.fileName}`); + } catch (e) { console.error('❌ [ASYNC] Récap PDF:', e.message); } - let ficheResult = null; - try { - const fichePDF = await generateFicheSignee( - noteDataPDF, - [{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: null }] - ); - ficheResult = await uploadToSharePointHierarchique( - { buffer: fichePDF, originalname: `${reference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length }, - reference, nomDossier, moisDossier - ); - fichiersUploades.push(ficheResult); - console.log('✅ Fiche soumission uploadée:', ficheResult.fileName); - } catch (e) { console.error('❌ Génération fiche PDF:', e.message, e.stack); } - - let recapUrl = null; - try { - const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles); - const recapResult = await uploadToSharePointHierarchique( - { buffer: recapBuffer, originalname: `${reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, - reference, nomDossier, moisDossier - ); - fichiersUploades.push(recapResult); - recapUrl = recapResult.uploadUrl; - console.log('✅ Récap PDF uploadé:', recapResult.fileName); - } catch (e) { console.error('❌ Génération récap PDF:', e.message); } - - const insertResult = await pool.request() - .input('reference', sql.NVarChar, reference) - .input('collaborateurId', sql.Int, req.user.id) - .input('libelle', sql.NVarChar, libelle) - .input('montant', sql.Decimal, montantFinal) - .input('date', sql.Date, new Date(date)) - .input('categorie', sql.NVarChar, categorieNote) - .input('description', sql.NVarChar, description || null) - .input('participants', sql.NVarChar, participants ? String(participants) : null) - .input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null) - .input('sharepointUrl', sql.NVarChar, recapUrl || ficheResult?.uploadUrl || fichiersUploades[0]?.uploadUrl || null) - .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) - .input('statut', sql.NVarChar, 'enattente') - .input('validateurN1Id', sql.Int, n1Id) - .input('validateurN2Id', sql.Int, n2Id) - .input('montantHT', sql.Decimal, null) - .input('tauxTVA', sql.Decimal, null) - .input('montantTVA21', sql.Decimal, null) - .input('montantTVA55', sql.Decimal, null) - .input('montantTVA10', sql.Decimal, null) - .input('montantTVA20', sql.Decimal, null) - .input('km', sql.Decimal, kmTotal || null) - .input('indemniteKm', sql.Decimal, indemKm || null) - .input('lignesJson', sql.NVarChar, lignesJsonFinal) - .query(` - INSERT INTO NoteDeFrais - (reference, collaborateurId, libelle, montant, date, categorie, - description, participants, nombreParticipants, sharepointUrl, fichiers, - statut, validateurN1Id, validateurN2Id, - 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, - @statut, @validateurN1Id, @validateurN2Id, - @montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20, - @km, @indemniteKm, @lignesJson) - `); - - const noteCreee = insertResult.recordset[0]; - - for (let i = 0; i < lignesParsed.length; i++) { - const l = lignesParsed[i]; - const pdf = lignesPDF[i]; try { await pool.request() - .input('noteId', sql.Int, noteCreee.id) - .input('numPiece', sql.Int, i + 1) - .input('date', sql.Date, new Date(l.date)) - .input('nature', sql.NVarChar, l.categorie || '') - .input('libelle', sql.NVarChar, l.libelle || '') - .input('km', sql.Decimal, pdf.km || null) - .input('montantTTC', sql.Decimal, pdf.montantTTC || null) - .input('tva21', sql.Decimal, pdf.tva21 || null) - .input('tva55', sql.Decimal, pdf.tva55 || null) - .input('tva10', sql.Decimal, pdf.tva10 || null) - .input('tva20', sql.Decimal, pdf.tva20 || null) - .input('montantHT', sql.Decimal, pdf.montantHT || null) - .input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null) - .input('indemniteKm', sql.Decimal, pdf.indemniteKm || null) + .input('id', sql.Int, noteCreee.id) + .input('sharepointUrl', sql.NVarChar, recapUrl || ficheResult?.uploadUrl || null) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAsync)) .query(` - INSERT INTO LigneNoteDeFrais - (noteDeFraisId, numPiece, date, nature, libelle, - km, montantTTC, tva21, tva55, tva10, tva20, - montantHT, tauxTVA, indemniteKm) - VALUES - (@noteId, @numPiece, @date, @nature, @libelle, - @km, @montantTTC, @tva21, @tva55, @tva10, @tva20, - @montantHT, @tauxTVA, @indemniteKm) + UPDATE NoteDeFrais SET + sharepointUrl = @sharepointUrl, + fichiers = @fichiers, + DateModification = GETDATE() + WHERE id = @id `); - } catch (e) { console.error(`❌ Insertion ligne ${i + 1}:`, e.message); } - } + } catch (e) { console.error('❌ [ASYNC] UPDATE BDD fichiers:', e.message); } - console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`); + await Promise.all([ + creerNotification({ + destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission', + titre: `✅ Note ${reference} soumise avec succès`, + message: `Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été reçue.`, + noteId: noteCreee.id + }).catch(e => console.error('❌ [ASYNC] Notif BDD collab:', e.message)), - const dateFormatee = new Date(date).toLocaleDateString('fr-FR'); - const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; - - try { - await creerNotification({ - destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission', - titre: `✅ Note ${reference} soumise avec succès`, - message: `Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été reçue.`, - noteId: noteCreee.id - }); - } catch (e) { console.error('❌ Notif BDD collab:', e.message); } - - try { - await sendMailGraph( - collaborateur.email, - `✅ Accusé de réception — Note ${reference}`, - `
-
-

✅ Note de frais bien reçue

-
-
-

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

-

Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été enregistrée.

- ${nomN1 ? `

Validateur : ${prenomN1} ${nomN1}

` : ''} - ${recapUrl ? `

📎 Voir le récapitulatif PDF

` : ''} - -
-
` - ); - } catch (e) { console.error('❌ Email accusé collab:', e.message); } - - if (n1Id && emailN1) { - try { - await creerNotification({ + n1Id ? creerNotification({ destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation', titre: `📋 Note à valider — ${reference}`, - message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de frais de ${montantFormate} € en attente de votre validation.`, + message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de ${montantFormate} € en attente de votre validation.`, noteId: noteCreee.id - }); - } catch (e) { console.error('❌ Notif BDD N1:', e.message); } + }).catch(e => console.error('❌ [ASYNC] Notif BDD N1:', e.message)) : Promise.resolve(), + ]); - try { - await sendMailGraph( + await Promise.all([ + sendMailGraph( + collaborateur.email, + `✅ Accusé de réception — Note ${reference}`, + `
+
+

✅ Note de frais bien reçue

+
+
+

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

+

Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été enregistrée.

+ ${nomN1 ? `

Validateur : ${prenomN1} ${nomN1}

` : ''} + ${recapUrl ? `

📎 Voir le récapitulatif PDF

` : ''} + +
+
` + ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)), + + (n1Id && emailN1) ? sendMailGraph( emailN1, `📋 Note de frais à valider — ${reference}`, `
@@ -1606,21 +2321,21 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
` - ); - } catch (e) { console.error('❌ Email N1:', e.message); } + ).catch(e => console.error('❌ [ASYNC] Email N1:', e.message)) : Promise.resolve(), + ]); + + console.log(`✅ [ASYNC] Traitement terminé pour ${reference}`); + } catch (e) { + console.error(`❌ [ASYNC] Erreur générale ${reference}:`, e.message); } + }); - res.status(201).json({ - success: true, id: noteCreee.id, reference: noteCreee.reference, - fichiers: fichiersUploades, recapUrl, - }); - - } catch (error) { - console.error('❌ Erreur POST /api/notes:', error.message); - res.status(500).json({ error: error.message }); - } + } catch (error) { + console.error('❌ Erreur POST /api/notes:', error.message); + res.status(500).json({ error: error.message }); } -); +}); + app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { try { const noteId = parseInt(req.params.id); @@ -1632,7 +2347,8 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { .query(` SELECT * FROM NoteDeFrais WHERE id = @id AND collaborateurId = @collabId - AND statut IN ('enattente', 'refuse', 'non_conforme_verif') + AND statut IN ('enattente', 'refuse', 'refuse_verif', 'non_conforme_verif', 'brouillon') + `); if (!noteCheck.recordset.length) @@ -1641,7 +2357,9 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { const noteExist = noteCheck.recordset[0]; // ✅ Détecter si correction (refusée ou non-conforme) → nouvelle note - const estCorrection = noteExist.statut === 'refuse' || noteExist.statut === 'non_conforme_verif'; + const estCorrection = noteExist.statut === 'refuse' + || noteExist.statut === 'refuse_verif' + || noteExist.statut === 'non_conforme_verif'; const { libelle, date, description, participants, nombreParticipants, lignes } = req.body; @@ -1686,10 +2404,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 @@ -1699,7 +2419,9 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { // 1. Archiver l'ancienne note const statutArchive = noteExist.statut === 'non_conforme_verif' ? 'non_conforme_archive' - : 'refuse_archive'; + : noteExist.statut === 'refuse_verif' + ? 'refuse_verif_archive' + : 'refuse_archive'; await pool.request() .input('id', sql.Int, noteId) @@ -1777,16 +2499,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; @@ -1805,7 +2527,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)) @@ -1814,14 +2536,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()) `); @@ -2093,72 +2815,88 @@ 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 `); const notes = result.recordset; - for (const note of notes) { - // ✅ Parser fichiers → sharepointFiles pour le frontend - if (note.fichiers) { - try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; } - } else { note.sharepointFiles = []; } - if (note.statut === 'non_conforme_verif') { - try { - const ncResult = await pool.request() - .input('noteId', sql.Int, note.id) - .query(` - SELECT fileName, motif, dateSignalement + if (!notes.length) return res.json([]); + + // ── 1. Une seule requête pour tous les non-conformes ───────────── + const noteIds = notes.map(n => n.id).join(','); + let ncByNote = {}; + try { + const ncAll = await pool.request().query(` + SELECT noteDeFraisId, fileName, motif, statut, dateSignalement FROM JustificatifsNonConformes - WHERE noteDeFraisId = @noteId + WHERE noteDeFraisId IN (${noteIds}) ORDER BY dateSignalement DESC `); - note.nonConformes = ncResult.recordset; - } catch (e) { note.nonConformes = []; } + for (const row of ncAll.recordset) { + if (!ncByNote[row.noteDeFraisId]) ncByNote[row.noteDeFraisId] = []; + ncByNote[row.noteDeFraisId].push(row); } - // ✅ Toujours re-parser lignesJson depuis la BDD pour avoir les qrFiles à jour - // Ne reconstruire depuis LigneNoteDeFrais qu'en dernier recours - // ✅ Enrichir chaque ligne avec ses fichiers QR depuis UploadTokens + } catch (e) { console.warn('⚠️ NC batch fetch:', e.message); } + + // ── 2. Collecter tous les qrNoteRef qui manquent encore de qrFiles + const allQrRefs = new Set(); + for (const note of notes) { + if (!note.lignesJson) continue; + try { + const lignes = JSON.parse(note.lignesJson); + for (const l of lignes) { + if (l.qrNoteRef && !(l.qrFiles?.length)) allQrRefs.add(l.qrNoteRef); + } + } catch { } + } + + // ── 3. Une seule requête pour tous les tokens QR manquants ─────── + let qrByRef = {}; + if (allQrRefs.size > 0) { + const refsStr = [...allQrRefs] + .map(r => `'${r.replace(/'/g, "''")}'`) + .join(','); + try { + const qrAll = await pool.request().query(` + SELECT noteRef, fichiers + FROM UploadTokens + WHERE noteRef IN (${refsStr}) AND used = 1 + `); + for (const row of qrAll.recordset) { + try { qrByRef[row.noteRef] = JSON.parse(row.fichiers || '[]'); } catch { } + } + } catch (e) { console.warn('⚠️ QR batch fetch:', e.message); } + } + + // ── 4. Enrichissement en mémoire — zéro requête SQL ────────────── + for (const note of notes) { + // Parser fichiers → sharepointFiles + try { note.sharepointFiles = JSON.parse(note.fichiers || '[]'); } catch { note.sharepointFiles = []; } + + // Non-conformes depuis le batch + if (note.statut === 'non_conforme_verif') { + note.nonConformes = ncByNote[note.id] || []; + } + + // Enrichissement QR en mémoire if (note.lignesJson) { try { const lignes = JSON.parse(note.lignesJson); let enrichi = false; - - const lignesEnrichies = await Promise.all(lignes.map(async (l) => { - if (l.qrFiles && l.qrFiles.length > 0) return l; - - const qrRef = l.qrNoteRef || ''; - if (!qrRef) return l; - - try { - const qrResult = await pool.request() - .input('noteRef', sql.NVarChar, qrRef) - .query(`SELECT TOP 1 fichiers FROM UploadTokens - WHERE noteRef = @noteRef AND used = 1 - ORDER BY expiresAt DESC`); - - if (qrResult.recordset.length && qrResult.recordset[0].fichiers) { - const fichiers = JSON.parse(qrResult.recordset[0].fichiers); - if (fichiers.length > 0) { - enrichi = true; - return { ...l, qrFiles: fichiers }; - } - } - } catch (e) { } + const lignesEnrichies = lignes.map(l => { + if (l.qrFiles?.length > 0) return l; + if (!l.qrNoteRef) return l; + const fichiers = qrByRef[l.qrNoteRef]; + if (fichiers?.length) { enrichi = true; return { ...l, qrFiles: fichiers }; } return l; - })); - - if (enrichi) { - note.lignesJson = JSON.stringify(lignesEnrichies); - } - } catch (e) { - console.warn('⚠️ Enrichissement QR fail:', note.id, e.message); - } + }); + if (enrichi) note.lignesJson = JSON.stringify(lignesEnrichies); + } catch (e) { console.warn('⚠️ Enrichissement QR fail:', note.id, e.message); } } } @@ -2172,21 +2910,26 @@ app.get('/api/notes', authenticateToken, async (req, res) => { // 🔑 TOKEN SHAREPOINT (scope différent de Graph) // ================================================ async function getSharePointToken() { + const now = Date.now(); + if (_sharePointTokenCache && now < _sharePointTokenCache.expiresAt) { + return _sharePointTokenCache.token; + } try { const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: AZURE_CONFIG.clientId, client_secret: AZURE_CONFIG.clientSecret, - scope: 'https://ensup.sharepoint.com/.default' // ← scope SharePoint + scope: 'https://ensup.sharepoint.com/.default' }); - const response = await axios.post( `https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); - - return response.data.access_token; + const token = response.data.access_token; + _sharePointTokenCache = { token, expiresAt: now + 55 * 60 * 1000 }; + console.log('✅ Token SharePoint mis en cache (55 min)'); + return token; } catch (error) { console.error('❌ Erreur token SharePoint:', error.response?.data || error.message); return null; @@ -2203,40 +2946,50 @@ app.get('/api/proxy-pdf', async (req, res) => { url = url.split('/api/proxy-pdf?url=')[1]; try { url = decodeURIComponent(url); } catch { } } - if (!url.startsWith('http')) return res.status(400).send('URL invalide : ' + url); + if (!url.startsWith('http')) return res.status(400).send('URL invalide'); - // ✅ Headers cache navigateur - res.setHeader('Cache-Control', 'private, max-age=600'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Disposition', 'inline'); - - // ✅ Vérifier cache serveur - const cached = getCached(url); - if (cached) { - res.setHeader('Content-Type', cached.contentType); - res.setHeader('X-Cache', 'HIT'); - return res.send(cached.buffer); + // ✅ Cache HIT → redirection instantanée vers CDN + const cached = downloadUrlCache.get(url); + if (cached && Date.now() < cached.expiresAt) { + res.setHeader('Cache-Control', 'private, max-age=3600'); + return res.redirect(302, cached.url); } try { - const buffer = await downloadFromSharePoint(url); - const urlLower = url.toLowerCase(); - let contentType = 'application/octet-stream'; - if (urlLower.includes('.pdf')) contentType = 'application/pdf'; - else if (urlLower.includes('.jpg') || urlLower.includes('.jpeg')) contentType = 'image/jpeg'; - else if (urlLower.includes('.png')) contentType = 'image/png'; + const accessToken = await getGraphToken(); + const urlObj = new URL(url); + const fullPath = decodeURIComponent(urlObj.pathname); + const marker = '/Shared Documents/'; + const markerAlt = '/Documents/'; + let relativePath = ''; + if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1]; + else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1]; + else { + const parts = fullPath.split('/sites/')[1]?.split('/'); + relativePath = parts?.slice(2).join('/') || ''; + } + + const metaRes = await axios.get( + `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 15000 // ← 5000 → 15000ms + } + ); + const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl']; + if (!downloadUrl) throw new Error('downloadUrl absent'); + + downloadUrlCache.set(url, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 }); + + res.setHeader('Cache-Control', 'private, max-age=3600'); + return res.redirect(302, downloadUrl); - setCache(url, buffer, contentType); - res.setHeader('Content-Type', contentType); - res.setHeader('X-Cache', 'MISS'); - res.send(buffer); } catch (err) { - console.error('❌ proxy-pdf erreur:', err.message); - res.status(500).json({ error: err.message, url }); + console.error('proxy-pdf erreur:', err.message); + res.status(500).json({ error: err.message }); } }); - // ================================================ // GET /api/notes/pending // ================================================ @@ -2258,7 +3011,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 `); @@ -2364,7 +3116,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} @@ -2391,13 +3143,11 @@ app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => { // ================================================ // PUT /api/notes/:id/statut — Valider ou refuser // ================================================ -// PUT /api/notes/:id/statut — Valider ou refuser app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { try { const { id } = req.params; const { action, commentaire, motifRefus } = req.body; const userId = Number(req.user.id); - console.log('Validation demande', id, action, userId); const noteResult = await pool.request() .input('id', sql.Int, id) @@ -2407,26 +3157,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) @@ -2435,8 +3176,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 @@ -2445,7 +3186,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) @@ -2457,48 +3198,57 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { (@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE()) `); - // Génération PDF signé - let signedPdfUrl = null; - try { - const validateurSelfResult = await pool.request() - .input('id', sql.Int, userId) - .query('SELECT prenom, nom FROM CollaborateurAD WHERE id = @id'); - const validateurSelf = validateurSelfResult.recordset[0]; - const nomValidateurActuel = (validateurSelf - ? `${validateurSelf.prenom} ${validateurSelf.nom}` - : `${req.user.prenom} ${req.user.nom}`).trim(); + res.json({ success: true, statut: nouveauStatut, niveau: 'N1' }); - const noteComplete = await pool.request() - .input('id', sql.Int, id) - .query(` - SELECT n.reference, n.libelle, n.montant, n.date, n.categorie, - n.montantHT, n.tauxTVA, n.km, n.participants, n.description, - n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson, - n.commentaireN1, n.commentaireN2, n.dateValidationN1, n.dateValidationN2, - c.prenom + ' ' + c.nom AS nomPrenom, - c.prenom AS collabPrenom, c.nom AS collabNom, c.departement, - v1.prenom + ' ' + v1.nom AS nomValidateurN1, - v2.prenom + ' ' + v2.nom AS nomValidateurN2 - 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 - WHERE n.id = @id - `); + setImmediate(async () => { + try { + console.log(`🔄 [ASYNC] PDF + emails validation ${id} → ${nouveauStatut}`); - if (noteComplete.recordset.length) { - const nd = noteComplete.recordset[0]; - const signatures = [ - { niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null } - ]; - if (niveauValidation === 'N1') { - signatures.push({ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }); - } else if (niveauValidation === 'N2') { - 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 }); + const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; + const montantFormate = parseFloat(note.montant).toFixed(2); + + const [collabResult, validateurResult, noteCompleteResult] = await Promise.all([ + pool.request() + .input('id', sql.Int, note.collaborateurId) + .query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'), + pool.request() + .input('id', sql.Int, userId) + .query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'), + pool.request() + .input('id', sql.Int, id) + .query(` + SELECT n.reference, n.libelle, n.montant, n.date, n.categorie, + n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson, + 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 + WHERE n.id = @id + `) + ]); + + const c = collabResult.recordset[0]; + const v = validateurResult.recordset[0]; + const nd = noteCompleteResult.recordset[0]; + + if (!c || !nd) { + console.error(`❌ [ASYNC] Données manquantes pour note ${id}`); + return; } + 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); @@ -2507,169 +3257,186 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { })(); const noteDataPDF = { - reference: nd.reference, nomPrenom: nd.nomPrenom, mois: moisStr, - departement: nd.departement, lignesJson: nd.lignesJson, - tarifKm: await getTarifKm(), statut: nouveauStatut + reference: nd.reference, + nomPrenom: nd.nomPrenom, + mois: moisStr, + departement: nd.departement, + lignesJson: nd.lignesJson, + tarifKm: await getTarifKm(), + statut: nouveauStatut }; - const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); - const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' - : nouveauStatut === 'refuse' ? 'signe-refuse' : `signe-${nouveauStatut}`; - let fichiersExistants = []; - try { fichiersExistants = JSON.parse(nd.fichiers); } catch { } + try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { } const existingFolder = fichiersExistants[0]?.folderPath; - 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')}`; - const signedResult = await uploadToSharePointHierarchique( - { buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length }, - nd.reference, nomDossier, moisDossier - ); - fichiersExistants.push(signedResult); + // ── Génération PDF signé (fiche seule) ──────────────────── + try { + const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); + const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse'; - await pool.request() - .input('id', sql.Int, id) - .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) - .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); + const signedResult = await uploadToSharePointHierarchique( + { buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length }, + nd.reference, nomDossier, moisDossier + ); + fichiersExistants.push(signedResult); - signedPdfUrl = signedResult.uploadUrl; - console.log('PDF signé uploadé:', signedResult.fileName, suffixe); - } - } catch (pdfError) { - console.error('Erreur génération PDF signé:', pdfError.message); - } + await pool.request() + .input('id', sql.Int, id) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) + .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); - // Notifications collaborateur + validateur suivant - const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; - const montantFormate = parseFloat(note.montant).toFixed(2); - const collabResult = await pool.request().input('id', sql.Int, note.collaborateurId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'); - const validateurResult = await pool.request().input('id', sql.Int, userId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'); + console.log(`✅ [ASYNC] PDF signé uploadé: ${signedResult.fileName}`); + } catch (pdfError) { + console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message); + } - if (collabResult.recordset.length) { - const c = collabResult.recordset[0]; - const v = validateurResult.recordset[0]; - const isApprouve = nouveauStatut === 'approuve'; - const isValidn1 = nouveauStatut === 'validen1'; - const isRefus = nouveauStatut === 'refuse'; - const titreCollab = isApprouve ? `Note ${note.reference} approuvée` : isValidn1 ? `Note ${note.reference} validée N1` : `Note ${note.reference} refusée`; - const msgCollab = isApprouve - ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.` - : isValidn1 - ? `Votre note ${note.reference} a été validée N1 par ${v?.prenom} ${v?.nom}.` + // ── 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é'}`; - try { await creerNotification({ destinataireId: c.id, destinataireEmail: c.email, type: isRefus ? 'refus' : 'validation', titre: titreCollab, message: msgCollab, noteId: parseInt(id) }); } catch { } - - // ── Email collaborateur ────────────────────────────────────────── - try { const motifAffiche = motifRefus || commentaire || 'Non précisé'; - const nomValidateur = `${v?.prenom || ''} ${v?.nom || ''}`.trim(); - await sendMailGraph( - c.email, - isRefus - ? `❌ Note refusée — action requise : ${note.reference}` - : titreCollab, - isRefus - ? `
-
-

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

-

Une action de votre part est nécessaire

+ 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}
-
-

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

-

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

- -
-
Motif du refus
-
${motifAffiche}
-
- -
- - - - - - -
Référence${note.reference}
Libellé${note.libelle}
Montant${montantFormate} €
Refusé par${nomValidateur}
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. -
-
- - -

- Vous pouvez modifier votre note tant qu'elle est au statut "Refusée". -

+
+ + + + + + +
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' })}
-
` - : `
-
-

${titreCollab}

+
+
📝 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. +
-
-

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

-

${msgCollab}

- + -
` - ); - } catch { } +
+
` + : `
+
+

${titreCollab}

+
+
+

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

+

${msgCollab}

+ +
+
`; - // Notifier N2 si validation N1 - if (action === 'valider' && niveauValidation === 'N1' && note.validateurN2Id && n2Id !== userId) { - const n2Result = await pool.request().input('id', sql.Int, note.validateurN2Id).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'); - if (n2Result.recordset.length) { - const n2 = n2Result.recordset[0]; - try { await creerNotification({ destinataireId: n2.id, destinataireEmail: n2.email, type: 'validation', titre: `Note à valider N2 : ${note.reference}`, message: `La note ${note.reference} (${montantFormate}€) de ${c.prenom} ${c.nom} attend votre validation finale.`, noteId: parseInt(id) }); } catch { } - try { - await sendMailGraph(n2.email, `Note à valider N2 : ${note.reference}`, ` -
-
-

Note à valider — Niveau N2

-
-
-

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

-

La note ${note.reference} de ${c.prenom} ${c.nom} (${montantFormate}€) a été validée N1 et attend votre validation finale.

- -
-
`); - } catch { } - } + 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); } - } + }); - res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation }); } catch (error) { console.error('Erreur validation:', error.message); res.status(500).json({ error: error.message }); } }); - // ================================================ // GET /api/notes/:id/historique // ================================================ @@ -2718,11 +3485,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); @@ -3127,33 +3894,46 @@ app.get('/api/notes-all', authenticateToken, async (req, res) => { const request = pool.request(); let campusWhere = ''; + if (req.user.campus) { const campusCode = normalizeCampus(req.user.campus); - if (campusCode) { - request.input('campus', sql.NVarChar, `%${campusCode}%`); - campusWhere = `AND c.campus LIKE @campus`; - } + + // Variantes de recherche par campus normalisé + const campusVariants = { + 'SQY': ['%SQY%', '%SAINT%'], + 'CGY': ['%CGY%', '%CERGY%'], + 'MRS': ['%MRS%', '%MARSEILLE%'], + 'NTE': ['%NTE%', '%NANTES%'], + }; + + const variants = campusVariants[campusCode] || [`%${campusCode}%`]; + + // Construire les conditions OR pour chaque variante + const conditions = variants.map((v, i) => { + request.input(`campus${i}`, sql.NVarChar, v); + return `c.campus LIKE @campus${i}`; + }); + campusWhere = `AND (${conditions.join(' OR ')})`; } 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' )`; const result = await request.query(` SELECT n.*, - 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 @@ -3195,16 +3975,20 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { if (!notes.recordset.length) return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' }); + // ── Validation des données AVANT de générer quoi que ce soit ───── + // On fait toutes les vérifications en une seule requête SQL (batch) + const checksResult = await pool.request().query(` + SELECT id AS collabId, IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays, + nom, prenom + FROM CollaborateurAD + WHERE id IN (${notes.recordset.map(n => n.collabId).join(',')}) + `); + const checksMap = {}; + for (const c of checksResult.recordset) checksMap[c.collabId] = c; + const erreurs = []; for (const n of notes.recordset) { - const checks = await pool.request() - .input('collabId', sql.Int, n.collabId) - .query(` - SELECT IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays - FROM CollaborateurAD - WHERE id = @collabId - `); - const c = checks.recordset[0]; + const c = checksMap[n.collabId]; if (!c) { erreurs.push(`${n.reference} : collaborateur introuvable`); continue; } if (!c.IBAN) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`); if (!c.BIC) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`); @@ -3212,10 +3996,7 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { 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 - }); + return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs }); const now = new Date(); const annee = now.getFullYear(); @@ -3226,20 +4007,25 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); const totalFormate = total.toFixed(2); - // ── Config débiteur depuis .env ────────────────────────────────── - const cfg = await getConfigDebiteur(); - const dbtrNom = cfg.companyName; - const dbtrIban = cfg.companyIban; - const dbtrBic = cfg.companyBic; - const dbtrAdrLine = cfg.companyAddress; - const dbtrCp = cfg.companyCp; - const dbtrVille = cfg.companyVille; - const dbtrPays = cfg.companyPays; + // Détecter le campus dominant des notes sélectionnées + 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; + } + // Campus le plus fréquent parmi les notes + return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null; + })(); - // ── Générer les transactions ────────────────────────────────────── + const cfg = await getConfigDebiteur(campusDominant); + console.log(`🏦 Config débiteur utilisée : ${cfg.companyName} (campus: ${campusDominant || 'global'})`); + const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic, + companyAddress: dbtrAdrLine, companyCp: dbtrCp, + companyVille: dbtrVille, companyPays: dbtrPays } = cfg; + + // ── Générer les transactions XML ────────────────────────────────── let transactions = ''; - let numTx = 1; - for (const n of notes.recordset) { let ibanClair = 'FR0000000000000000000000000'; try { @@ -3254,8 +4040,6 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { const cp = n.adresse_cp || ''; const ville = (n.adresse_ville || '').toUpperCase(); const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase(); - - // BIC bénéficiaire : si présent utiliser, sinon NOTPROVIDED const benefBicBlock = n.bic ? `${n.bic}` : `NOTPROVIDED`; @@ -3288,10 +4072,8 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { `; - numTx++; } - // ── XML final au format PAIN.001.001.03 ────────────────────────── const xml = ` @@ -3342,127 +4124,10 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { `; - // ── Upload XML sur SharePoint dans Virements/{annee}/{mois}/ ───── - let xmlSharepointUrl = null; - try { - const xmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`; - const xmlFolderPath = `Virements/${annee}/${mois}`; - const xmlUploadPath = `${xmlFolderPath}/${xmlFileName}`; - - const accessToken = await getGraphToken(); - if (accessToken) { - const spRes = await 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 - } - ); - xmlSharepointUrl = spRes.data.webUrl; - console.log(`✅ XML virement uploadé sur SharePoint : ${xmlUploadPath}`); - } - } catch (spErr) { - console.error('⚠️ Upload XML SharePoint échoué (XML quand même téléchargé) :', spErr.message); - } - - // ── Générer les PDF récap pour chaque note ──────────────────────── - for (const note of notes.recordset) { - try { - let fichiersExistants = []; - try { fichiersExistants = JSON.parse(note.fichiers || '[]'); } catch { } - - const justifFiles = []; - for (const f of fichiersExistants) { - const name = (f.fileName || '').toLowerCase(); - if (name.includes('soumission') || name.includes('resoumission') || name.includes('recap')) continue; - try { - const buf = await downloadFromSharePoint(f.uploadUrl); - const mimetype = name.endsWith('.pdf') ? 'application/pdf' - : name.endsWith('.png') ? 'image/png' : 'image/jpeg'; - justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); - } catch (e) { console.warn(`⚠️ Justif non récupérable: ${f.fileName}`, e.message); } - } - - const dateObj = new Date(note.date); - const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); - const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); - const nomPrenom = `${note.nom.toUpperCase()} ${note.prenom}`; - - const histResult = await pool.request() - .input('noteId', sql.Int, note.id) - .query(` - SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction, - c.prenom + ' ' + c.nom AS nomPrenom - FROM HistoriqueValidation h - JOIN CollaborateurAD c ON c.id = h.ValidateurId - WHERE h.NoteDeFraisId = @noteId - ORDER BY h.DateAction ASC - `); - - const signatures = [ - { niveau: 'COLLAB', nomPrenom, date: note.DateCreation || new Date(), action: 'soumettre', commentaire: null }, - ...histResult.recordset.map(h => ({ - niveau: h.Niveau, nomPrenom: h.nomPrenom, - date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null - })) - ]; - - const noteDataPDF = { - reference: note.reference, nomPrenom, mois: moisCapitalized, - date: note.date, categorie: note.categorie || 'Multiple', - libelle: note.libelle, montant: parseFloat(note.montant), - lignesJson: note.lignesJson, tarifKm: await getTarifKm(), - statut: note.statut, departement: note.departement, - }; - - const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); - - const existingFolder = fichiersExistants[0]?.folderPath; - const nomDossier = existingFolder - ? existingFolder.split('/')[1] - : `${note.prenom}_${note.nom}`.replace(/[^a-zA-Z0-9]/g, '_'); - const moisDossier = existingFolder - ? existingFolder.split('/')[2] - : `${annee}-${mois}`; - - const recapResult = await uploadToSharePointHierarchique( - { - buffer: recapBuffer, - originalname: `${note.reference}_recap-paiement.pdf`, - mimetype: 'application/pdf', - size: recapBuffer.length - }, - note.reference, nomDossier, moisDossier - ); - - fichiersExistants.push(recapResult); - - await pool.request() - .input('id', sql.Int, note.id) - .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) - .input('recapUrl', sql.NVarChar, recapResult.uploadUrl) - .query(` - UPDATE NoteDeFrais - SET fichiers = @fichiers, - sharepointUrl = @recapUrl, - DateModification = GETDATE() - WHERE id = @id - `); - - console.log(`✅ PDF récap-paiement généré pour ${note.reference}`); - } catch (pdfErr) { - console.error(`❌ PDF récap ${note.reference}:`, pdfErr.message); - } - } - // ── Passer en 'paiementenattente' + enregistrer date XML ───────── + // Fait AVANT res.send pour que le statut soit correct immédiatement await pool.request() .input('dateXml', sql.DateTime, now) - .input('xmlUrl', sql.NVarChar, xmlSharepointUrl || null) .query(` UPDATE NoteDeFrais SET statut = 'paiementenattente', @@ -3472,83 +4137,188 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { AND statut IN ('approuve', 'approuvé', 'verifie') `); - // ── Notifier chaque collaborateur ───────────────────────────────── - for (const n of notes.recordset) { - try { - await creerNotification({ - destinataireId: n.collabId, - destinataireEmail: n.email, - type: 'paiement', - titre: `Paiement en cours de traitement : ${n.reference}`, - message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)} € est en cours de traitement bancaire.`, - noteId: n.id - }); - } catch (e) { console.error('Notif paiementenattente:', e.message); } - } - - // ── Téléchargement du XML côté client ──────────────────────────── + // ── Réponse immédiate — le client reçoit le XML sans attendre ──── const xmlFileName = `virements-ndf-${annee}-${mois}-${todayISO}.xml`; res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1'); res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`); res.send(xml); + // ── Tout le reste en arrière-plan (non bloquant) ───────────────── + setImmediate(async () => { + console.log(`🔄 [ASYNC] Post-XML : SharePoint + PDFs + notifs pour ${notes.recordset.length} note(s)...`); + + // 1. Upload XML sur SharePoint + try { + const spXmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`; + const xmlUploadPath = `Virements/${annee}/${mois}/${spXmlFileName}`; + const accessToken = await getGraphToken(); + if (accessToken) { + await 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] XML uploadé sur SharePoint : ${xmlUploadPath}`); + } + } catch (spErr) { + console.error('⚠️ [ASYNC] Upload XML SharePoint échoué :', spErr.message); + } + + // 2. Générer les PDFs récap + notifier en parallèle par note + const tarifKm = await getTarifKm(); + + await Promise.allSettled(notes.recordset.map(async (note) => { + try { + // PDFs récap + let fichiersExistants = []; + try { fichiersExistants = JSON.parse(note.fichiers || '[]'); } catch { } + + const justifFiles = []; + for (const f of fichiersExistants) { + const name = (f.fileName || '').toLowerCase(); + if (name.includes('soumission') || name.includes('resoumission') || name.includes('recap')) continue; + try { + const buf = await downloadFromSharePoint(f.uploadUrl); + const mimetype = name.endsWith('.pdf') ? 'application/pdf' + : name.endsWith('.png') ? 'image/png' : 'image/jpeg'; + justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); + } catch (e) { console.warn(`⚠️ [ASYNC] Justif non récupérable: ${f.fileName}`, e.message); } + } + + const dateObj = new Date(note.date); + const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); + const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); + const nomPrenom = `${note.nom.toUpperCase()} ${note.prenom}`; + + const histResult = await pool.request() + .input('noteId', sql.Int, note.id) + .query(` + SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction, + c.prenom + ' ' + c.nom AS nomPrenom + FROM HistoriqueValidation h + JOIN CollaborateurAD c ON c.id = h.ValidateurId + WHERE h.NoteDeFraisId = @noteId + ORDER BY h.DateAction ASC + `); + + const signatures = [ + { niveau: 'COLLAB', nomPrenom, date: note.DateCreation || new Date(), action: 'soumettre', commentaire: null }, + ...histResult.recordset.map(h => ({ + niveau: h.Niveau, nomPrenom: h.nomPrenom, + date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null + })) + ]; + + const noteDataPDF = { + reference: note.reference, nomPrenom, mois: moisCapitalized, + date: note.date, categorie: note.categorie || 'Multiple', + libelle: note.libelle, montant: parseFloat(note.montant), + lignesJson: note.lignesJson, tarifKm, + statut: note.statut, departement: note.departement, + }; + + const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); + + const existingFolder = fichiersExistants[0]?.folderPath; + const nomDossier = existingFolder + ? existingFolder.split('/')[1] + : `${note.prenom}_${note.nom}`.replace(/[^a-zA-Z0-9]/g, '_'); + const moisDossier = existingFolder + ? existingFolder.split('/')[2] + : `${annee}-${mois}`; + + const recapResult = await uploadToSharePointHierarchique( + { buffer: recapBuffer, originalname: `${note.reference}_recap-paiement.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, + note.reference, nomDossier, moisDossier + ); + + fichiersExistants.push(recapResult); + + await pool.request() + .input('id', sql.Int, note.id) + .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) + .input('recapUrl', sql.NVarChar, recapResult.uploadUrl) + .query(` + UPDATE NoteDeFrais + SET fichiers = @fichiers, sharepointUrl = @recapUrl, DateModification = GETDATE() + WHERE id = @id + `); + + console.log(`✅ [ASYNC] PDF récap-paiement généré : ${note.reference}`); + } catch (pdfErr) { + console.error(`❌ [ASYNC] PDF récap ${note.reference}:`, pdfErr.message); + } + + // Notification collaborateur (indépendante du PDF) + try { + await creerNotification({ + destinataireId: note.collabId, + destinataireEmail: note.email, + type: 'paiement', + titre: `Paiement en cours de traitement : ${note.reference}`, + message: `Votre note ${note.reference} de ${parseFloat(note.montant).toFixed(2)} € est en cours de traitement bancaire.`, + noteId: note.id + }); + } catch (e) { console.error(`❌ [ASYNC] Notif ${note.reference}:`, e.message); } + })); + + console.log(`✅ [ASYNC] Traitement post-XML terminé`); + }); + } catch (error) { console.error('Erreur génération XML:', error.message); res.status(500).json({ error: error.message }); } }); - // 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')) @@ -3580,7 +4350,15 @@ app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => const creDtTm = now.toISOString().slice(0, 19); const msgId = `NDF-REGEN-${annee}${mois}-${Date.now().toString().slice(-7)}`; const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2); - const cfg = await getConfigDebiteur(); + 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 dbtrNom = cfg.companyName; const dbtrIban = cfg.companyIban; const dbtrBic = cfg.companyBic; @@ -3663,39 +4441,49 @@ app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => }); // GET /api/paiements/config-debiteur +// GET — récupérer toutes les configs actives (une par campus) app.get('/api/paiements/config-debiteur', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const result = await pool.request().query(` - SELECT TOP 1 id, companyName, companyIban, companyBic, - companyAddress, companyCp, companyVille, companyPays, - DateModification + SELECT id, companyName, companyIban, companyBic, + companyAddress, companyCp, companyVille, companyPays, + campus, DateModification FROM ConfigDebiteurXML WHERE actif = 1 - ORDER BY DateModification DESC + ORDER BY CASE WHEN campus IS NULL THEN 1 ELSE 0 END, campus `); - res.json(result.recordset[0] ?? null); + res.json(result.recordset); } catch (e) { res.status(500).json({ error: e.message }); } }); -// PUT /api/paiements/config-debiteur +// PUT — créer/remplacer la config pour un campus donné app.put('/api/paiements/config-debiteur', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); - const { companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays } = req.body; + const { companyName, companyIban, companyBic, companyAddress, + companyCp, companyVille, companyPays, campus } = req.body; if (!companyName || !companyIban || !companyBic) return res.status(400).json({ error: 'Nom, IBAN et BIC sont obligatoires' }); const ibanClean = companyIban.replace(/\s+/g, '').toUpperCase(); const bicClean = companyBic.replace(/\s+/g, '').toUpperCase(); + const campusCode = campus ? (normalizeCampus(campus) || campus) : null; try { - // Désactiver l'ancienne config et insérer la nouvelle - await pool.request().query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1`); + // Désactiver uniquement la config du même campus + if (campusCode) { + await pool.request() + .input('campus', sql.NVarChar, campusCode) + .query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus = @campus`); + } else { + await pool.request() + .query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus IS NULL`); + } await pool.request() .input('companyName', sql.NVarChar, companyName.trim()) @@ -3705,25 +4493,27 @@ app.put('/api/paiements/config-debiteur', authenticateToken, async (req, res) => .input('companyCp', sql.NVarChar, (companyCp || '').trim()) .input('companyVille', sql.NVarChar, (companyVille || '').trim()) .input('companyPays', sql.NVarChar, (companyPays || 'FR').trim().slice(0, 2).toUpperCase()) + .input('campus', sql.NVarChar, campusCode) .input('modifiePar', sql.Int, req.user.id) .query(` INSERT INTO ConfigDebiteurXML (companyName, companyIban, companyBic, companyAddress, - companyCp, companyVille, companyPays, actif, modifiePar, + companyCp, companyVille, companyPays, campus, actif, modifiePar, DateCreation, DateModification) VALUES (@companyName, @companyIban, @companyBic, @companyAddress, - @companyCp, @companyVille, @companyPays, 1, @modifiePar, + @companyCp, @companyVille, @companyPays, @campus, 1, @modifiePar, GETDATE(), GETDATE()) `); - console.log(`✅ Config débiteur XML mise à jour par ${req.user.email}`); - res.json({ success: true, companyName, companyIban: ibanClean, companyBic: bicClean }); + res.json({ success: true, campus: campusCode, companyName, companyIban: ibanClean }); } catch (e) { - console.error('PUT /api/paiements/config-debiteur:', e.message); res.status(500).json({ error: e.message }); } }); + +// PUT /api/paiements/config-debiteur + // POST /api/paiements/confirmer-paiement // Body: { noteIds: number[], datePaiement: string (ISO) } // POST /api/paiements/confirmer-paiement — Confirme paiement et passe statut à 'payee' @@ -4173,15 +4963,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) { @@ -4243,7 +5033,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(); @@ -4273,7 +5063,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; @@ -4646,28 +5437,42 @@ app.get('/api/paiements/filtres-disponibles', authenticateToken, async (req, res `); const campusSet = new Set(); + // Map : campusCode → Set de sociétés + const societeParCampus = {}; const societeSet = new Set(); for (const row of result.recordset) { if (row.campus) { - const code = (() => { - const c = row.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 row.campus; - })(); + const c = row.campus.toUpperCase(); + const code = + c.includes('SQY') || c.includes('SAINT') ? 'SQY' : + c.includes('CGY') || c.includes('CERGY') ? 'CGY' : + c.includes('MRS') || c.includes('MARSEILLE') ? 'MRS' : + c.includes('NTE') || c.includes('NANTES') ? 'NTE' : + row.campus; + campusSet.add(code); + + // Grouper les sociétés par campus normalisé + if (!societeParCampus[code]) societeParCampus[code] = new Set(); + + if (row.societe && row.societe.trim()) { + societeParCampus[code].add(row.societe.trim()); + societeSet.add(row.societe.trim()); + } } - if (row.societe && row.societe.trim()) { - societeSet.add(row.societe.trim()); - } + } + + // Convertir les Sets en tableaux triés + const societeParCampusFinal = {}; + for (const [campus, set] of Object.entries(societeParCampus)) { + societeParCampusFinal[campus] = [...set].sort(); } res.json({ campus: [...campusSet].sort(), - societes: [...societeSet].sort() + societes: [...societeSet].sort(), // toutes sociétés (fallback) + societeParCampus: societeParCampusFinal, // sociétés par campus ← nouveau }); } catch (error) { @@ -4805,6 +5610,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} €
+
+ +

+ 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. +

+ +
+
` + ); + + console.log(`✅ [ASYNC PRESIDENT] ValidateurFinance notifié : ${soumetteur.email}`); + } else { + console.warn('⚠️ [ASYNC PRESIDENT] Soumetteur introuvable dans HistoriqueValidation'); + } + } catch (e) { + console.error('❌ [ASYNC PRESIDENT] Notification soumetteur:', e.message); + } + + console.log(`✅ [ASYNC PRESIDENT] Traitement terminé pour ${notes.recordset.length} note(s)`); + }); + + } catch (error) { + console.error('Erreur POST /api/president/generer-xml:', error.message); + res.status(500).json({ error: error.message }); + } + }); + + // ══════════════════════════════════════════════════════════════════════════ + // 4. Président → Historique de ses validations + // GET /api/president/historique + // ══════════════════════════════════════════════════════════════════════════ + app.get('/api/president/historique', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé au Président' }); + + try { + const result = await pool.request() + .input('presidentId', sql.Int, req.user.id) + .query(` + SELECT + CAST(n.dateXml AS DATE) AS dateXmlJour, + MIN(n.dateXml) AS dateXmlExacte, + n.dateValidationPresident, + n.commentairePresident, + COUNT(*) AS nbNotes, + SUM(n.montant) AS totalMontant, + STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, + STRING_AGG(n.reference, ', ') AS listeReferences + FROM NoteDeFrais n + WHERE n.presidentId = @presidentId + AND n.presidentValidation = 'valide_president' + GROUP BY CAST(n.dateXml AS DATE), n.dateValidationPresident, n.commentairePresident + ORDER BY CAST(n.dateXml AS DATE) DESC + `); + + res.json(result.recordset); + } catch (error) { + console.error('GET /api/president/historique:', error.message); + res.status(500).json({ error: error.message }); + } + }); + + // ================================================ // GESTION DES ERREURS // ================================================ diff --git a/ndf/public/img/emma-avatar.jpg b/ndf/public/img/emma-avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..342e8742e9cfda87453b9b8d528f9b5f38662868 GIT binary patch literal 30914 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 literal 0 HcmV?d00001 diff --git a/ndf/src/components/Login.css b/ndf/src/components/Login.css index 1b79aa6..4386c58 100644 --- a/ndf/src/components/Login.css +++ b/ndf/src/components/Login.css @@ -29,7 +29,7 @@ } .login-header { - background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%); + color: white; padding: 40px 30px; text-align: center; diff --git a/ndf/src/components/RoleSelector.tsx b/ndf/src/components/RoleSelector.tsx index d868c85..e461dae 100644 --- a/ndf/src/components/RoleSelector.tsx +++ b/ndf/src/components/RoleSelector.tsx @@ -64,6 +64,31 @@ const ROLE_CONFIG: Record { validateur: 'Validateur', validatrice: 'Validatrice', finance: 'Finance', + verificateurfinance: 'VerificateurFinance', + validateurfinance: 'ValidateurFinance', }; return map[role.toLowerCase()] ?? role; }; 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/index.css b/ndf/src/index.css index 6d8de26..46a8158 100644 --- a/ndf/src/index.css +++ b/ndf/src/index.css @@ -8,7 +8,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%); + min-height: 100vh; } diff --git a/ndf/src/pages/AuthCallback.tsx b/ndf/src/pages/AuthCallback.tsx index 2defeb7..25558bd 100644 --- a/ndf/src/pages/AuthCallback.tsx +++ b/ndf/src/pages/AuthCallback.tsx @@ -32,7 +32,7 @@ const AuthCallback = (): JSX.Element => { justifyContent: 'center', fontFamily: 'sans-serif', fontSize: '18px', - color: '#f5f5dc', + }}> ⏳ Connexion en cours...
diff --git a/ndf/src/pages/Dashboard.tsx b/ndf/src/pages/Dashboard.tsx index f4b0f90..609b3b6 100644 --- a/ndf/src/pages/Dashboard.tsx +++ b/ndf/src/pages/Dashboard.tsx @@ -4,8 +4,12 @@ import { RoleSwitcherSidebar } from '../components/RoleSwitcher'; import { ThemeToggleButton } from '../context/ThemeContext'; import NouvelleNote from './NouvelleNote'; import VerificateurFinanceLight from './VerificateurFinanceLight'; +import NDFChatbot from './NdfChatbot'; +import PresidentValidation from './PresidentValidation'; import QRCode from 'react-qr-code'; + + import { LayoutDashboard, PlusCircle, FileText, CheckSquare, History, CreditCard, User, LogOut, Receipt, Upload, Send, @@ -30,7 +34,17 @@ function getMontantTVA(ttc: number, taux: number) { // ── NORMALISATION STATUT ────────────────────────────── const normalizeStatut = (s?: string) => s?.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') ?? ''; +// ── Cache client pour les fichiers proxy (évite de re-télécharger) ── +const clientProxyCache = new Map(); // url SP → objectURL blob +const proxyUrl = (url: string) => { + if (!url) return url; + // ✅ Évite le double encodage + if (url.includes('/api/proxy-pdf')) return url; + if (url.includes('sharepoint.com') || url.includes('.sharepoint.')) + return `${API}/api/proxy-pdf?url=${encodeURIComponent(url)}`; + return url; +}; // ── INTERFACES ───────────────────────────────────────── interface Note { @@ -52,15 +66,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; @@ -317,9 +331,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' }, @@ -330,7 +342,12 @@ const tagStatut = (statut: string) => { 'brouillon': { bg: '#f1f5f9', color: 'var(--text-secondary)', label: 'Brouillon' }, 'valide': { bg: '#dcfce7', color: '#15803d', label: 'Valide' }, 'non_conforme_verif': { bg: '#fff7ed', color: '#c2410c', label: 'Justificatif non conforme' }, + 'refuse_verif': { bg: '#fee2e2', color: '#dc2626', label: 'Refusée par Finance' }, + 'refuse_verif_archive': { bg: '#f1f5f9', color: '#64748b', label: 'Refusée Finance (archivée)' }, + 'non_conforme_archive': { bg: '#f1f5f9', color: '#64748b', label: 'Non conforme (archivée)' }, + 'refuse_archive': { bg: '#f1f5f9', color: '#64748b', label: 'Refusée (archivée)' }, }; + const key = normalizeStatut(statut); const s = map[key] ?? map['brouillon']; return ( @@ -350,8 +367,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: '⏳' }, @@ -360,11 +377,12 @@ const StatutStepper = ({ statut }: { statut: string }) => { const getActiveIndex = () => { if (s === 'refuse' || s === 'refus') return -1; + if (s === 'refuse_verif') return -1; // ✅ AJOUT if (s === 'payee') return 6; 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; @@ -374,6 +392,7 @@ const StatutStepper = ({ statut }: { statut: string }) => { if (active === -1) { const isNonConforme = s === 'non_conforme_verif'; + const isRefuseVerif = s === 'refuse_verif'; return (
{ {isNonConforme ? '⚠️' : '❌'}
- {isNonConforme ? 'Justificatif non conforme — action requise' : 'Note refusée'} + {isNonConforme + ? 'Justificatif non conforme — action requise' + : isRefuseVerif + ? 'Note refusée par la Finance — corrections requises' + : 'Note refusée'}
{isNonConforme ? 'Le vérificateur Finance a signalé un justificatif non conforme. Contactez votre responsable.' - : 'Consultez le motif de refus ci-dessous'} + : isRefuseVerif + ? 'Le vérificateur Finance a refusé certaines lignes. Corrigez-les puis resoumettez.' + : 'Consultez le motif de refus ci-dessous'}
@@ -404,7 +429,7 @@ const StatutStepper = ({ statut }: { statut: string }) => { border: '1px solid var(--border-card)', borderRadius: 14, marginBottom: 16, overflowX: 'auto', }}> -
+
{steps.map((step, i) => { const isDone = i < active; const isCurrent = i === active; @@ -470,11 +495,47 @@ const NotesTable = ({ notes }: { notes: Note[] }) => { try { files = JSON.parse(note.fichiers); } catch { files = []; } } if (!Array.isArray(files)) files = []; - const isApprouve = ['approuve', 'payee'].includes(note.statut || ''); - const file = files.find(f => f.fileName.includes(isApprouve ? 'signe_approuve' : 'soumission')); - return file?.uploadUrl || null; - }; + const s = normalizeStatut(note.statut || ''); + + // Priorité 1 : recap-paiement (note payée) + if (s === 'payee') { + const file = files.find(f => f.fileName.includes('recap-paiement')); + if (file) return { url: file.uploadUrl, label: '💶 Récap paiement', color: '#15803d', bg: '#f0fdf4', border: '#86efac' }; + } + + // Priorité 2 : recap complet régénéré (_recap.pdf) — toutes étapes + const recapFinal = files + .filter(f => { + const n = (f.fileName || '').toLowerCase(); + return n.includes('_recap.pdf') && !n.includes('recap-paiement'); + }) + .sort((a, b) => (b.fileName || '').localeCompare(a.fileName || '')) // le plus récent + [0]; + + if (recapFinal) { + const label = s === 'verifie' ? '🔍 Récap vérifié' + : ['approuve', 'paiementenattente', 'paiement_en_attente'].includes(s) ? '✅ Récap approuvé' + : ['validen1', 'valide_n1', 'validen2', 'valide_n2'].includes(s) ? '📋 Récap validé' + : '📋 Récap complet'; + const color = s === 'verifie' ? '#7c3aed' + : ['approuve', 'paiementenattente', 'paiement_en_attente'].includes(s) ? '#059669' + : '#6366f1'; + const bg = s === 'verifie' ? '#ede9fe' + : ['approuve', 'paiementenattente', 'paiement_en_attente'].includes(s) ? '#dcfce7' + : '#eef2ff'; + const border = s === 'verifie' ? '#c4b5fd' + : ['approuve', 'paiementenattente', 'paiement_en_attente'].includes(s) ? '#6ee7b7' + : '#c7d2fe'; + return { url: recapFinal.uploadUrl, label, color, bg, border }; + } + + // Fallback : soumission si pas encore de recap régénéré + const file = files.find(f => f.fileName.includes('resoumission')) + || files.find(f => f.fileName.includes('soumission')); + if (!file) return null; + return { url: file.uploadUrl, label: '📋 Fiche soumission', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }; + }; return (
@@ -507,10 +568,24 @@ const NotesTable = ({ notes }: { notes: Note[] }) => { @@ -521,58 +596,140 @@ const NotesTable = ({ notes }: { notes: Note[] }) => { ); }; -// ── URL PREVIEW POPUP ───────────────────────────── -const UrlPreviewPopup = ({ item, onClose }: { item: { url: string; name: string } | null; onClose: () => void; }) => { - const [loaded, setLoaded] = useState(false); +const UrlPreviewPopup = ({ item, onClose }: { item: { url: string; name: string } | null; onClose: () => void }) => { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(false); useEffect(() => { - // Reset à chaque ouverture - setLoaded(false); + if (!item) return; + setBlobUrl(null); + setError(false); + + // ✅ Vérifier le cache client d'abord + if (clientProxyCache.has(item.url)) { + setBlobUrl(clientProxyCache.get(item.url)!); + return; + } + + // Télécharger et mettre en cache + fetch(item.url) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.blob(); + }) + .then(blob => { + const url = URL.createObjectURL(blob); + clientProxyCache.set(item.url, url); + setBlobUrl(url); + }) + .catch(() => setError(true)); }, [item?.url]); if (!item) return null; const isPDF = /\.pdf$/i.test(item.name); const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(item.name); + return ( -
-
e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, boxShadow: '0 32px 100px rgba(0,0,0,.5)', width: isPDF ? 860 : 'auto', maxWidth: '94vw', maxHeight: '94vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> -
+
+
e.stopPropagation()} style={{ + background: '#fff', borderRadius: 16, + boxShadow: '0 32px 100px rgba(0,0,0,.5)', + width: isPDF ? 860 : 'auto', maxWidth: '94vw', maxHeight: '94vh', + display: 'flex', flexDirection: 'column', overflow: 'hidden' + }}> + {/* Header */} +
{isPDF ? '📄' : isImage ? '🖼️' : '📎'} -
{item.name}
+
+ {item.name} +
- ↗ Ouvrir - + {blobUrl && ( + ⬇ Télécharger + )} +
-
- {/* ✅ Spinner pendant le chargement */} - {!loaded && ( -
-
-
-
Chargement…
-
+ + {/* Corps */} +
+ + + {/* Spinner */} + {!blobUrl && !error && ( +
+
+
Chargement…
)} - - {isPDF &&
{tagStatut(n.statut || 'brouillon')} {(() => { - const url = getJustificatifUrl(n); - if (url === 'KILOMETRIQUE') return Kilométrique; - if (url) return Voir justificatif; - return -; + const result = getJustificatifUrl(n); + if (result === 'KILOMETRIQUE') return ( + 🚗 Kilométrique + ); + if (!result) return ( + + ); + return ( + + {result.label} + + ); })()}