From eb6ba9f78e7b7ff920fefdca88652fa2a17677fc Mon Sep 17 00:00:00 2001 From: Imer ouijdane Date: Tue, 19 May 2026 15:17:34 +0200 Subject: [PATCH] Version_Chatbot --- ndf/public/backend/ndfPdfGenerator.js | 358 ++- ndf/public/backend/server.js | 2622 +++++++++++++------- ndf/src/components/Login.css | 2 +- ndf/src/components/RoleSelector.tsx | 27 + ndf/src/index.css | 2 +- ndf/src/pages/AuthCallback.tsx | 2 +- ndf/src/pages/Dashboard.tsx | 1410 ++++++++--- ndf/src/pages/NdfChatbot.tsx | 903 +++++++ ndf/src/pages/NouvelleNote.tsx | 665 +++-- ndf/src/pages/VerificateurFinanceLight.tsx | 1649 ++++++------ ndf/tsconfig.app.json | 2 +- 11 files changed, 5372 insertions(+), 2270 deletions(-) create mode 100644 ndf/src/pages/NdfChatbot.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..bf438cc 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 + v2.nom + ' ' + v2.prenom AS nomN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id - 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,551 @@ 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.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')}`; + + // ── 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, + v2.id AS n2Id, v2.email AS emailN2, v2.prenom AS prenomN2, v2.nom AS nomN2 + FROM NoteDeFrais n + JOIN CollaborateurAD c ON c.id = n.collaborateurId + LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id + LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id + 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 +1487,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 }); } }); @@ -1196,9 +1769,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,11 +1798,23 @@ 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) { @@ -1273,111 +1873,203 @@ 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 (2 requêtes SQL, inchangé) ──────── + 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')}`; + + // ── 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); + // ✅ Téléchargements QR globaux en parallèle + 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) { + // téléchargement + upload SharePoint en parallèle pour chaque fichier de la ligne + await Promise.all( + qrFichiers.map(async f => { try { const buf = await downloadFromSharePoint(f.uploadUrl); const fileObj = { @@ -1386,211 +2078,230 @@ 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); + // ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles + 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); + + // ── Préparer noteDataPDF (utilisé en sync ET en async) ─────────── + 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('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]; + + // ── Insérer les lignes (séquentiel, rapide car SQL local) ──────── + 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}`); + + // ✅ Répondre immédiatement — le client n'attend plus le PDF + res.status(201).json({ + success: true, + id: noteCreee.id, + reference: noteCreee.reference, + fichiers: fichiersUploades, + recapUrl: null, // sera mis à jour en BDD en arrière-plan + pending: true, + }); + + // ── Traitement lourd en arrière-plan (non bloquant) ────────────── + setImmediate(async () => { + const fichiersAsync = [...fichiersUploades]; // copie locale pour l'async + try { + console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`); + const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; + + // Fiche PDF soumission + 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, - }; + // Récap PDF complet + 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]; + // Mettre à jour BDD avec PDF final 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}`); + // Notifications BDD (parallèle) + 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( + // Emails (parallèle) + 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 +2317,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 +2343,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 +2353,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; @@ -1699,7 +2413,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) @@ -2103,62 +2819,78 @@ app.get('/api/notes', authenticateToken, async (req, res) => { `); 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 +2904,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 +2940,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 // ================================================ @@ -2391,13 +3138,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) @@ -2457,43 +3202,60 @@ 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: niveauValidation }); - 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 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.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 + `) + ]); + + 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 } ]; if (niveauValidation === 'N1') { signatures.push({ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }); - } else if (niveauValidation === 'N2') { + } else { 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 }); @@ -2507,169 +3269,224 @@ 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, ''); + : `${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')}`; - 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' + : nouveauStatut === 'refuse' ? 'signe-refuse' + : `signe-${nouveauStatut}`; - 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'); - - 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}.` - : `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

-
-
-

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

-
-
` - : `
-
-

${titreCollab}

-
-
-

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

-

${msgCollab}

- -
-
` - ); - } catch { } - - // 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 { } + console.log(`✅ [ASYNC] PDF signé uploadé: ${signedResult.fileName}`); + } catch (pdfError) { + console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message); } - } - } - res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation }); + // ── Régénérer le recap complet (fiche + justifs + signatures à jour) ── + 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 + ); + + // Remplacer l'ancien _recap.pdf (sauf recap-paiement) + 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 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 ${nomValidateurActuel}.` + : `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`; + + const motifAffiche = motifRefus || commentaire || 'Non précisé'; + + const emailCollabHtml = isRefus + ? `
+
+

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

+

Une action de votre part est nécessaire

+
+
+

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

+

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

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

${titreCollab}

+
+
+

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

+

${msgCollab}

+ +
+
`; + + const taches = [ + 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)), + ]; + + 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]; + taches.push( + 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(e => console.error('❌ [ASYNC] Notif N2:', e.message)), + + 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(e => console.error('❌ [ASYNC] Email N2:', e.message)) + ); + } + } + + await Promise.all(taches); + console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`); + + } catch (e) { + console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message); + } + }); + } catch (error) { console.error('Erreur validation:', error.message); res.status(500).json({ error: error.message }); } }); - // ================================================ // GET /api/notes/:id/historique // ================================================ @@ -3127,12 +3944,26 @@ 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') @@ -3143,7 +3974,6 @@ app.get('/api/notes-all', authenticateToken, async (req, res) => { 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, @@ -3195,16 +4025,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 +4046,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 +4057,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 +4090,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 +4122,8 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { `; - numTx++; } - // ── XML final au format PAIN.001.001.03 ────────────────────────── const xml = ` @@ -3342,127 +4174,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,32 +4187,139 @@ 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')) @@ -3580,7 +4402,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 +4493,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 +4545,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' @@ -4646,28 +5488,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) { 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/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..340e4af 100644 --- a/ndf/src/pages/Dashboard.tsx +++ b/ndf/src/pages/Dashboard.tsx @@ -4,8 +4,10 @@ import { RoleSwitcherSidebar } from '../components/RoleSwitcher'; import { ThemeToggleButton } from '../context/ThemeContext'; import NouvelleNote from './NouvelleNote'; import VerificateurFinanceLight from './VerificateurFinanceLight'; +import NDFChatbot from './NdfChatbot'; import QRCode from 'react-qr-code'; + import { LayoutDashboard, PlusCircle, FileText, CheckSquare, History, CreditCard, User, LogOut, Receipt, Upload, Send, @@ -30,7 +32,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 { @@ -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 ( @@ -360,6 +377,7 @@ 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; @@ -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} + + ); })()}