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}
` : ''}
+
+
+
`
+ );
+ } 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})
+
+
- Référence ${note.reference}
- Collaborateur ${note.prenom} ${note.nom}
- Montant ${montantFormate} €
- Campus ${note.campus || '—'}
+
+
+ N°
+ Ligne
+ Motif
+
+
+ ${tableLignesHtml}
-
`
);
- } 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 `${label} ${r.motif.trim()} `;
+ }).join('')}
+
+
+
${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 ?
-
- Connectez-vous à la plateforme NDF
- Rendez-vous dans Mes notes
- Cliquez sur la note ${note.reference}
- Corrigez les informations demandées
- Resoumettez la note
-
-
-
-
-
- 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 ?
+
+ Connectez-vous à la plateforme NDF
+ Rendez-vous dans Mes notes
+ Cliquez sur la note ${note.reference}
+ Corrigez les informations demandées
+ Resoumettez la note
+
+
+
+
+
`
+ : `
+
+
${titreCollab}
+
+
+
Bonjour ${c.prenom} ${c.nom} ,
+
${msgCollab}
+
+
+
`;
+
+ 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[] }) => {
{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}
+
+ );
})()}
@@ -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}
+
-
- {/* ✅ Spinner pendant le chargement */}
- {!loaded && (
-
-
+
+ {/* Corps */}
+
+
+
+ {/* Spinner */}
+ {!blobUrl && !error && (
+
)}
-
- {isPDF &&
@@ -599,10 +756,20 @@ const LignesDetail = ({
if (note.sharepointFiles && note.sharepointFiles.length > 0) fichiersTous = note.sharepointFiles;
else if (note.fichiers) { try { fichiersTous = JSON.parse(note.fichiers); } catch { } }
- const EXCLUS_SYSTEME = ['soumission', 'resoumission', 'recap', 'signe', 'signé', 'signeapprouve', 'signe_approuve', 'signe-approuve', 'approuve'];
+ const EXCLUS_SYSTEME = [
+ '_soumission.pdf',
+ '_resoumission.pdf',
+ '_recap.pdf',
+ '-signe-approuve.pdf',
+ '-signe-refuse.pdf',
+ '-verifie.pdf',
+ '-verifie-proratise.pdf',
+ 'recap-paiement.pdf',
+ ];
+
const totalNote = note.montant || 0;
- const [previewLoading, setPreviewLoading] = useState
(null);
+ //const [previewLoading, setPreviewLoading] = useState(null);
// ── ÉTAPE 1 : résoudre les fichiers de chaque ligne ──────────────────
const lignesResolues = lignesData.map((l: any) => {
@@ -610,17 +777,16 @@ const LignesDetail = ({
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
// Ne pas ignorer les qrFiles même pour les km
- const fichiersKm: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
- (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw))
- );
+ const fichiersKm: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
+ (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
+ );
if (isKm) return { ...l, _resolvedFiles: fichiersKm };
// Fichiers stockés directement dans qrFiles de la ligne
- const fichiersLigne: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
- (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw))
- );
-
+ const fichiersLigne: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
+ (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
+ );
// Fichiers trouvés via qrNoteRef dans fichiersTous
const qrRef: string = l.qrNoteRef || '';
const fichiersQR = qrRef
@@ -641,10 +807,10 @@ const LignesDetail = ({
});
// ── ÉTAPE 2 : tous les fichiers non-système de la note ──
- const tousFichiersNonSysteme = fichiersTous.filter(f => {
- const n = (f.fileName || '').toLowerCase();
- return !EXCLUS_SYSTEME.some(kw => n.includes(kw));
- }).filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
+ const tousFichiersNonSysteme = fichiersTous.filter(f => {
+ const n = (f.fileName || '').toLowerCase();
+ return !EXCLUS_SYSTEME.some(kw => n.endsWith(kw));
+ }).filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
// Fichiers orphelins = non assignés à aucune ligne
const urlsDejaAssignees = new Set(
@@ -802,27 +968,25 @@ const LignesDetail = ({
return (
{
- if (previewLoading) return;
- setPreviewLoading(f.uploadUrl);
- onPreview(f.uploadUrl!, f.fileName || `Fichier ${fi + 1}`);
- setTimeout(() => setPreviewLoading(null), 2000);
+ const url = f.uploadUrl || '';
+ const proxied = url.includes('sharepoint') || url.includes('.sharepoint.')
+ ? proxyUrl(url)
+ : url;
+ onPreview(proxied, f.fileName || `Fichier ${fi + 1}`);
}}
+
title={f.fileName || `Justificatif ${fi + 1}`}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
- padding: '4px 10px',
- background: accent,
+ padding: '4px 10px', background: accent,
border: 'none', borderRadius: 20,
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 11, fontWeight: 700, color: '#fff',
transition: 'opacity 0.15s', flexShrink: 0,
}}
- onMouseEnter={e => (e.currentTarget.style.opacity = '0.85')}
- onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
- {previewLoading === f.uploadUrl ? '⏳' : isImage ? '🖼️' : isPdf ? '📄' : '📎'} Voir
+ {isImage ? '🖼️' : isPdf ? '📄' : '📎'} Voir
);
})}
@@ -836,6 +1000,28 @@ const LignesDetail = ({
{/* badges TVA etc — inchangés */}
+ {/* ── Nuits hébergement ── */}
+ {(l.categorie || '').toLowerCase().includes('hebergement') && l.nuits && parseInt(l.nuits) > 0 && (() => {
+ const montant = parseFloat(l.montant) || 0;
+ const nuits = parseInt(l.nuits);
+ const parNuit = nuits > 0 && montant > 0 ? montant / nuits : 0;
+ return (
+
+ 🌙 {nuits} nuit{nuits > 1 ? 's' : ''}
+ {parNuit > 0 && (
+
+ · {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(parNuit)}/nuit
+
+ )}
+
+ );
+ })()}
+
{l.description && (
💬 {l.description}
@@ -929,38 +1115,51 @@ const FichiersGlobaux = ({
// Sélection du document officiel selon l'état
// Approuvé/Payé → chercher le PDF signé approuvé ou le récap paiement
// Soumis/En attente → chercher la fiche de soumission ou le récap
- const trouverFichierOfficiel = () => {
- if (isApprouve) {
- // Priorité : recap-paiement > signe_approuve > signe > recap > premier fichier système
- return (
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('recap-paiement')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('signe') && (f.fileName || '').toLowerCase().includes('approuve')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('signeapprouve')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('signe_approuve')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('recap')) ||
- fichiers.find(f => {
- const n = (f.fileName || '').toLowerCase();
- return n.includes('soumission') || n.includes('resoumission');
- }) || null
- );
- } else {
- // En attente de validation → fiche de soumission ou récap
- return (
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('resoumission')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('soumission')) ||
- fichiers.find(f => (f.fileName || '').toLowerCase().includes('recap')) ||
- null
- );
- }
- };
+ const trouverFichierOfficiel = () => {
+ if (isApprouve) {
+ const n = (f: any) => (f.fileName || '').toLowerCase();
+ return (
+ fichiers.find(f => n(f).includes('recap-paiement')) ||
+ fichiers.find(f => n(f).endsWith('-verifie-proratise.pdf')) ||
+ fichiers.find(f => n(f).endsWith('-signe-approuve.pdf')) ||
+ fichiers.find(f => n(f).endsWith('-signeapprouve.pdf')) ||
+ fichiers.find(f => n(f).endsWith('-signe_approuve.pdf')) ||
+ fichiers.find(f => n(f).endsWith('_recap.pdf') && !n(f).includes('recap-paiement')) ||
+ null
+ );
+ } else {
+ return null;
+ }
+ };
const fichierOfficiel = trouverFichierOfficiel();
// Libellés contextuels selon le statut
const getLabelOfficiel = () => {
- if (['payee'].includes(statut)) return { icon: '💶', label: 'Récap de paiement', desc: 'Document officiel — note payée', accent: '#15803d', bg: '#f0fdf4', border: '#86efac' };
- if (['approuve', 'verifie', 'paiementenattente', 'paiement_en_attente'].includes(statut)) return { icon: '✅', label: 'PDF approuvé', desc: 'Signé par le validateur', accent: '#7c3aed', bg: '#ede9fe', border: '#c4b5fd' };
- return { icon: '📋', label: 'Fiche de soumission', desc: 'Récapitulatif soumis pour validation', accent: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' };
+ // ✅ Détecter si c'est un PDF proratisé
+ const estProratise = fichierOfficiel &&
+ (fichierOfficiel.fileName || '').toLowerCase().includes('verifie-proratise');
+
+ if (['payee'].includes(statut)) return {
+ icon: '💶', label: 'Récap de paiement',
+ desc: 'Document officiel — note payée',
+ accent: '#15803d', bg: '#f0fdf4', border: '#86efac'
+ };
+ if (estProratise) return {
+ icon: '✂️', label: 'PDF vérifié — montant ajusté',
+ desc: 'Certains repas ont été plafonnés à 25€/pers. par la Finance',
+ accent: '#d97706', bg: '#fffbeb', border: '#fde68a'
+ };
+ if (['approuve', 'verifie', 'paiementenattente', 'paiement_en_attente'].includes(statut)) return {
+ icon: '✅', label: 'PDF approuvé',
+ desc: 'Signé par le validateur',
+ accent: '#7c3aed', bg: '#ede9fe', border: '#c4b5fd'
+ };
+ return {
+ icon: '📋', label: 'Fiche de soumission',
+ desc: 'Récapitulatif soumis pour validation',
+ accent: '#6366f1', bg: '#eef2ff', border: '#c7d2fe'
+ };
};
const infoOfficiel = getLabelOfficiel();
@@ -1003,6 +1202,14 @@ const FichiersGlobaux = ({
{fichierOfficiel ? (
onPreview(proxyFn(fichierOfficiel.uploadUrl!), fichierOfficiel.fileName || 'Document officiel')}
+ onMouseEnter={() => {
+ const proxied = proxyFn(fichierOfficiel.uploadUrl!);
+ if (!clientProxyCache.has(proxied)) {
+ fetch(proxied).then(r => r.blob()).then(blob => {
+ clientProxyCache.set(proxied, URL.createObjectURL(blob));
+ }).catch(() => { });
+ }
+ }}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', width: '100%',
@@ -1012,7 +1219,7 @@ const FichiersGlobaux = ({
fontFamily: 'inherit', textAlign: 'left',
transition: 'all 0.15s',
}}
- onMouseEnter={e => (e.currentTarget.style.transform = 'translateY(-1px)')}
+
onMouseLeave={e => (e.currentTarget.style.transform = 'none')}
>
{/* Icône */}
@@ -1058,15 +1265,50 @@ const FichiersGlobaux = ({
}}>
🚗 Note kilométrique — aucun justificatif global requis
- ) : (
-
- ⚠️ Document officiel non encore généré
-
- )}
+ ) : null}
+
+ );
+};
+
+const InlinePreviewViewer = ({ item }: { item: { url: string; name: string } }) => {
+ const [blobUrl, setBlobUrl] = useState
(
+ clientProxyCache.get(item.url) || null
+ );
+
+ useEffect(() => {
+ if (clientProxyCache.has(item.url)) {
+ setBlobUrl(clientProxyCache.get(item.url)!);
+ return;
+ }
+ // ✅ Utilise item.url directement (après redirection ou cache CDN)
+ 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(() => { });
+ }, [item.url]);
+
+ const isPDF = /\.pdf$/i.test(item.name);
+ const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(item.name);
+
+ if (!blobUrl) return (
+
+ );
+ if (isPDF) return ;
+ if (isImage) return ;
+ return (
+
);
};
@@ -1081,11 +1323,12 @@ const Dashboard = (): JSX.Element => {
const canValidate = isValidateur;
const isRHAdmin = isFinance;
-
+
const [section, setSection] = useState('accueil');
const [notes, setNotes] = useState([]);
const [pending, setPending] = useState([]);
const [profile, setProfile] = useState(null);
+ const [inlinePreview, setInlinePreview] = useState<{ url: string; name: string } | null>(null);
const [adresseForm, setAdresseForm] = useState({
adresse_rue: '', adresse_cp: '', adresse_ville: '', adresse_pays: 'France', societe: ''
@@ -1110,7 +1353,8 @@ const Dashboard = (): JSX.Element => {
const [filtresDisponibles, setFiltresDisponibles] = useState<{
campus: string[];
societes: string[];
- }>({ campus: [], societes: [] });
+ societeParCampus: Record; // ← ajouter
+ }>({ campus: [], societes: [], societeParCampus: {} });
const [paiementFiltreCampus, setPaiementFiltreCampus] = useState('');
const [paiementFiltreSociete, setPaiementFiltreSociete] = useState('');
const [historiqueFiltreCampus, setHistoriqueFiltreCampus] = useState('');
@@ -1254,14 +1498,7 @@ const Dashboard = (): JSX.Element => {
setTimeout(() => setToast(null), 3500);
};
- 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;
- };
+
// ── Navigation avec historique navigateur ────────────
const nav = (s: string) => {
@@ -1310,14 +1547,14 @@ const Dashboard = (): JSX.Element => {
.then(d => Array.isArray(d) && setExceptions(d))
.catch(() => { });
- // ← même fix
+ // ← Charger les filtres dès l'arrivée sur la section paiements
const currentToken = localStorage.getItem('token');
const currentHdrs = {
Authorization: `Bearer ${currentToken}`,
'Content-Type': 'application/json'
};
fetch(`${API}/api/paiements/filtres-disponibles`, { headers: currentHdrs })
- .then(r => r.ok ? r.json() : { campus: [], societes: [] })
+ .then(r => r.ok ? r.json() : { campus: [], societes: [], societeParCampus: {} })
.then(d => setFiltresDisponibles(d))
.catch(() => { });
}
@@ -1346,6 +1583,21 @@ const Dashboard = (): JSX.Element => {
.catch(() => { });
}, []);
+ // ── Charger les filtres dès que les rôles sont connus ──
+ useEffect(() => {
+ if (!isRHAdmin && !isValidateurFinance) return;
+ const currentToken = localStorage.getItem('token');
+ fetch(`${API}/api/paiements/filtres-disponibles`, {
+ headers: {
+ Authorization: `Bearer ${currentToken}`,
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(r => r.ok ? r.json() : null)
+ .then(d => { if (d?.campus) setFiltresDisponibles(d); })
+ .catch(() => { });
+ }, [isRHAdmin, isValidateurFinance]);
+
useEffect(() => {
const isEmpty = !libelle && !description && lignes.every(l => !l.libelle && !l.categorie && !l.montant && !l.km);
if (isEmpty) return;
@@ -1543,8 +1795,37 @@ const Dashboard = (): JSX.Element => {
}
}, [section, filtreMois, filtreStatut, adminFiltreMois, adminFiltreStatut, paiementFiltreMois, isRHAdmin, isVerificateurFinance, isValidateurFinance, isSuperUser, canValidate, isFinance]);
-
+ // ── À ajouter dans le Dashboard, après les autres useEffect ──
+ useEffect(() => {
+ const noteActive = selectedNote || noteDetail;
+ if (!noteActive) return;
+ const token = localStorage.getItem('token');
+
+ fetch(`${API}/api/notes/${noteActive.id}/download-urls`, {
+ headers: { Authorization: `Bearer ${token}` }
+ })
+ .then(r => r.ok ? r.json() : {})
+ .then(async (urlMap: Record) => {
+ if (!Object.keys(urlMap).length) return;
+
+ await Promise.all(
+ Object.entries(urlMap).map(async ([spUrl, directUrl]) => {
+ const proxied = proxyUrl(spUrl); // clé de cache = URL proxy
+ if (clientProxyCache.has(proxied)) return; // déjà en cache
+
+ try {
+ // ✅ Télécharge directement depuis Microsoft CDN (pas via ton serveur)
+ const r = await fetch(directUrl);
+ if (!r.ok) return;
+ const blob = await r.blob();
+ clientProxyCache.set(proxied, URL.createObjectURL(blob));
+ } catch { }
+ })
+ );
+ })
+ .catch(() => { });
+ }, [selectedNote?.id, noteDetail?.id]);
const marquerNotifLue = async (id: number) => {
await fetch(`${API}/api/notifications/${id}/lu`, { method: 'PUT', headers: hdrs });
@@ -1694,6 +1975,7 @@ const Dashboard = (): JSX.Element => {
qrUploaded: l.qrUploaded || false,
// ✅ AJOUT CRITIQUE : transmettre les fichiers QR du brouillon
qrFiles: l.qrFiles || [],
+ nuits: l.nuits || null,
};
});
@@ -1701,12 +1983,14 @@ const Dashboard = (): JSX.Element => {
try {
const formData = new FormData();
formData.append('libelle', titre);
- formData.append('date', date);
- formData.append('description', description);
+ formData.append('date', dateDebut || date);
+ formData.append('description', commentaire || description);
formData.append('lignes', JSON.stringify(lignesPayload));
if (qrNoteRef) formData.append('qrNoteRef', qrNoteRef);
files.forEach(f => formData.append('justificatifs', f));
- lignesExterne.forEach(l => l.files?.forEach((f: File) => formData.append('justificatifs', f)));
+ lignesExterne.forEach(l => {
+ l.files?.forEach((f: File) => formData.append(`files_${l.id}`, f));
+ });
// ✅ Détecter si c'est une modification de note refusée
const brouillonKey = String(activeBrouillonId || '');
@@ -1797,12 +2081,12 @@ const Dashboard = (): JSX.Element => {
// ── RENDU PANNEAU DETAIL NOTE (réutilisé dans mesnotes + validation) ──
const renderNoteDetailPanel = (note: Note, onClose: () => void, withActions = false) => (
-
+
Détail de la note
✕
-
+
{(note as any).noteRefuseeId && (
{
)}
- {normalizeStatut(note.statut || '') === 'non_conforme_verif' && (
+ {normalizeStatut(note.statut || '') === 'refuse_verif' && (
-
- ⚠️ Justificatif(s) non conforme(s) — action requise
+
+ ❌ Note refusée par la Finance — action requise
-
- Le vérificateur Finance a signalé un problème sur votre dossier.
- Corrigez les informations demandées puis resoumettez votre note.
+
+ Le Vérificateur Finance a refusé certaines lignes. Corrigez-les puis resoumettez votre note.
-
- {/* Détail des non-conformités */}
- {(note as any).nonConformes?.length > 0 && (
-
- {((note as any).nonConformes as any[]).map((nc: any, i: number) => (
-
-
- 📄 {nc.fileName}
-
-
- Motif : {nc.motif}
-
-
- Signalé le {new Date(nc.dateSignalement).toLocaleDateString('fr-FR')}
-
-
- ))}
+ {note.commentaireVerification && (
+
+ {note.commentaireVerification}
)}
-
- {/* ✅ Bouton modifier — même comportement que note refusée */}
handleModifierNote(note)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '9px 18px',
- background: 'linear-gradient(135deg,#f97316,#ea580c)',
+ background: 'linear-gradient(135deg,#ef4444,#dc2626)',
color: '#fff', border: 'none', borderRadius: 8,
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 700,
- boxShadow: '0 4px 12px rgba(249,115,22,.3)',
+ boxShadow: '0 4px 12px rgba(239,68,68,.3)',
}}>
✏️ Corriger et resoumettre
@@ -1979,34 +2246,49 @@ const Dashboard = (): JSX.Element => {
{/* Lignes détail */}
setPreviewUrl({ url: proxyUrl(url), name })} />
- {/* Fichiers globaux */}
-
-
Justificatifs
-
setPreviewUrl({ url, name })} proxyFn={proxyUrl} />
-
-
- {/* Actions validation */}
- {withActions && (
-
-
{
- setMotifRefusInput('');
- setModalValidation({ noteId: note.id, action: 'valider', onClose });
- }}>
- ✅ Valider
-
-
{
- setMotifRefusInput('');
- setModalValidation({ noteId: note.id, action: 'refuser', onClose });
- }}>
- ❌ Refuser
-
+ {/* Fichiers globaux — uniquement si approuvé/vérifié/payé */}
+ {/* Fichiers globaux — uniquement si approuvé/vérifié/payé */}
+ {['approuve', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee'].includes(normalizeStatut(note.statut || '')) && (
+
+
Justificatifs
+
setPreviewUrl({ url, name })} proxyFn={proxyUrl} />
)}
+
+ {/* Boutons fixés en bas — EN DEHORS du scroll */}
+ {withActions && (
+
+ {
+ setMotifRefusInput('');
+ setModalValidation({ noteId: note.id, action: 'valider', onClose });
+ }}>
+ ✅ Valider
+
+ {
+ setMotifRefusInput('');
+ setModalValidation({ noteId: note.id, action: 'refuser', onClose });
+ }}>
+ ❌ Refuser
+
+
+ )}
);
@@ -2025,6 +2307,83 @@ const Dashboard = (): JSX.Element => {
aside.ndf-aside { transform: none !important; }
.sidebar-overlay { display: none !important; }
}
+
+ @media (max-width: 1024px) {
+ /* Grille stats : 3 colonnes sur tablette */
+ .stat-grid { grid-template-columns: repeat(3,1fr) !important; }
+
+ /* Grille profil : 2 colonnes */
+ .profil-grid { grid-template-columns: 1fr 1fr !important; }
+ }
+
+ @media (max-width: 768px) {
+ aside.ndf-aside { transform: translateX(-100%); transition: transform 0.3s ease, width 0.3s ease !important; width: 260px !important; z-index: 150 !important; }
+ aside.ndf-aside.open { transform: translateX(0) !important; }
+ .sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
+ main.ndf-main { margin-left: 0 !important; padding: 16px 14px !important; padding-top: 56px !important; }
+
+ /* Stats : 2 colonnes sur mobile */
+ .stat-grid { grid-template-columns: repeat(2,1fr) !important; gap: 8px !important; }
+
+ /* Header : empiler sur mobile */
+ .ndf-header { flex-direction: column !important; align-items: flex-start !important; gap: 10px !important; }
+ .ndf-header-actions { width: 100%; justify-content: flex-end; }
+
+ /* Tableau : scroll horizontal */
+ .table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
+
+ /* Panneau détail : plein écran sur mobile */
+ .detail-panel {
+ position: fixed !important;
+ inset: 0 !important;
+ width: 100% !important;
+ max-height: 100vh !important;
+ z-index: 500 !important;
+ border-radius: 0 !important;
+ }
+
+ /* Grille paiements/profil : 1 colonne */
+ .paiements-grid { grid-template-columns: 1fr !important; }
+ .profil-grid {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto !important;
+ }
+ .profil-col3 { grid-column: 1 !important; grid-row: auto !important; }
+ .profil-col12 { grid-column: 1 !important; grid-row: auto !important; }
+
+ /* Validation 3 colonnes : empiler */
+ .validation-layout { flex-direction: column !important; height: auto !important; }
+ .validation-list { flex: none !important; max-width: 100% !important; max-height: 300px !important; }
+ }
+
+ @keyframes spin { to { transform: rotate(360deg); } }
+
+ @media (max-width: 1440px) and (min-width: 1025px) {
+ main.ndf-main {
+ padding: 18px 20px !important;
+ }
+ .stat-grid {
+ grid-template-columns: repeat(3, 1fr) !important;
+ gap: 8px !important;
+ }
+ }
+
+ /* Panneau liste notes : pas de largeur fixe */
+ .notes-list-col { min-width: 0 !important; }
+
+ /* Tableau mesnotes */
+ .notes-table th:nth-child(3),
+ .notes-table td:nth-child(3) { display: none; }
+ }
+
+ @media (max-width: 1024px) {
+ /* Section validation : réduire la col liste */
+ .validation-list-col { flex: 0 0 200px !important; }
+
+ /* Masquer colonnes secondaires */
+ .notes-table th:nth-child(4),
+ .notes-table td:nth-child(4) { display: none; }
+ }
`}
{/* ── TOGGLE ── */}
@@ -2072,7 +2431,7 @@ const Dashboard = (): JSX.Element => {
{/* ── MAIN ── */}
-
+
{/* Header */}
@@ -2114,7 +2473,7 @@ const Dashboard = (): JSX.Element => {
{/* ── STAT CARDS ── */}
-
+
{statCards.map((c, i) => (
{c.value}
@@ -2490,6 +2849,28 @@ const Dashboard = (): JSX.Element => {
)}
+ {/* Indicateur config débiteur */}
+ {selectedNoteIds.length > 0 && (() => {
+ // Trouver le campus dominant des notes sélectionnées
+ const selectedNotes = notesApprouvees.filter(n => selectedNoteIds.includes(n.id));
+ const campusCounts: Record
= {};
+ selectedNotes.forEach(n => {
+ const code = normalizeCampus(n.campus || '') || n.campus || 'global';
+ campusCounts[code] = (campusCounts[code] || 0) + 1;
+ });
+ const campusDominant = Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
+ return (
+
+ 🏦 Compte débiteur : {campusDominant || 'global'}
+
+ );
+ })()}
+
{notesVerifiees.length > 0 && (
nav('paiements')}
@@ -2691,35 +3072,37 @@ const Dashboard = (): JSX.Element => {
{/* MES NOTES */}
{/* ════════════════════════════════════════ */}
{section === 'mesnotes' && (
-
-
+
+
+ {/* ── COL GAUCHE : liste des notes (50%) ── */}
+
+
Mes notes de frais
-
- {/* Filtre par statut */}
setMesNotesFiltreStatut(e.target.value)}
style={{
- padding: '6px 10px',
- borderRadius: 8,
+ padding: '6px 10px', borderRadius: 8,
border: '1.5px solid var(--border-divider)',
- fontSize: 13,
- background: 'var(--bg-input)',
- color: 'var(--text-primary)',
- cursor: 'pointer',
- outline: 'none',
- }}
- >
+ fontSize: 12, background: 'var(--bg-input)',
+ color: 'var(--text-primary)', cursor: 'pointer', outline: 'none',
+ }}>
Tous les statuts
- Brouillon
+
En attente
Validé N1
Approuvé
@@ -2729,49 +3112,41 @@ const Dashboard = (): JSX.Element => {
Refusée
Non conforme
-
- {/* Bouton reset visible uniquement si filtre actif */}
{mesNotesFiltreStatut && (
- setMesNotesFiltreStatut('')}
- style={{
- padding: '6px 12px',
- borderRadius: 8,
- border: '1.5px solid #fca5a5',
- background: '#fef2f2',
- color: '#dc2626',
- fontSize: 12,
- fontWeight: 600,
- cursor: 'pointer',
- fontFamily: 'inherit',
- }}
- >
- ✕ Réinitialiser
-
+ setMesNotesFiltreStatut('')} style={{
+ padding: '5px 10px', borderRadius: 7,
+ border: '1.5px solid #fca5a5', background: '#fef2f2',
+ color: '#dc2626', fontSize: 12, fontWeight: 600,
+ cursor: 'pointer', fontFamily: 'inherit',
+ }}>✕
)}
- {(() => {
- const notesFiltrees = mesNotesFiltreStatut
- ? notes.filter(note => normalizeStatut(note.statut) === mesNotesFiltreStatut)
- : notes;
+ {/* Liste scrollable */}
+
+ {(() => {
+ const notesFiltrees = notes
+ .filter(note => normalizeStatut(note.statut) !== 'brouillon')
+ .filter(note => !mesNotesFiltreStatut || normalizeStatut(note.statut) === mesNotesFiltreStatut);
- if (notesFiltrees.length === 0) return (
-
-
- {mesNotesFiltreStatut
- ? 'Aucune note ne correspond à ce statut.'
- : 'Aucune note de frais'}
-
-
- );
+ if (notesFiltrees.length === 0) return (
+
+
📋
+
+ {mesNotesFiltreStatut ? 'Aucune note pour ce statut.' : 'Aucune note de frais'}
+
+
nav('nouvelle')} style={{ ...btnPrimary, margin: '12px auto 0', fontSize: 13 }}>
+ + Nouvelle note
+
+
+ );
- return (
-
- {notesFiltrees.map(note => {
- const isSelected = selectedNote?.id === note.id;
- return (
-
{
+ return notesFiltrees.map(note => {
+ const isSelected = selectedNote?.id === note.id;
+ return (
+
{
if (note.statut === 'brouillon') {
const b = {
id: note.id as unknown as number,
@@ -2787,34 +3162,231 @@ const Dashboard = (): JSX.Element => {
} else {
setSelectedNote(isSelected ? null : note);
}
- }} style={{
- border: `2px solid ${note.statut === 'brouillon' ? '#fde68a' : isSelected ? '#6366f1' : '#e2e8f0'}`,
- borderRadius: 12,
- padding: '14px 16px',
- background: note.statut === 'brouillon' ? '#fffbeb' : isSelected ? '#eef2ff' : 'var(--bg-card)',
- cursor: 'pointer',
- transition: 'all 0.15s'
- }}>
-
-
-
-
{note.reference || `#${note.id}`}
-
{note.libelle}
-
{note.categorie} · {note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '-'}
+ }}
+ style={{
+ border: `2px solid ${note.statut === 'brouillon' ? '#fde68a'
+ : isSelected ? '#6366f1'
+ : 'var(--border-card)'
+ }`,
+ borderRadius: 12, padding: '12px 14px',
+ background: note.statut === 'brouillon' ? '#fffbeb'
+ : isSelected ? '#eef2ff'
+ : 'var(--bg-card)',
+ cursor: 'pointer', transition: 'all 0.15s',
+ flexShrink: 0,
+ }}
+ onMouseEnter={e => {
+ if (!isSelected) e.currentTarget.style.borderColor = '#6366f1';
+ }}
+ onMouseLeave={e => {
+ if (!isSelected) e.currentTarget.style.borderColor =
+ note.statut === 'brouillon' ? '#fde68a' : 'var(--border-card)';
+ }}
+ >
+
+
+
+
+ {note.reference || `#${note.id}`}
-
-
{fmt(note.montant || 0)}
-
{tagStatut(note.statut || 'brouillon')}
+
+ {note.libelle}
+
+
+ {note.categorie && {note.categorie} · }
+ {note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '-'}
+
+
+ {fmt(note.montant || 0)}
+
+ {tagStatut(note.statut || 'brouillon')}
+
- );
- })}
-
- );
- })()}
+
+ );
+ });
+ })()}
+
+
+ {/* Bouton bas fixe */}
+
+
nav('nouvelle')} style={{ ...btnPrimary, width: '100%', justifyContent: 'center', fontSize: 13 }}>
+ Nouvelle note
+
+
+
+
+ {/* ── COL DROITE : détail note (50%) ── */}
+
+ {selectedNote ? (
+ <>
+ {/* Header panneau détail */}
+
+
+
+ {selectedNote.libelle || 'Détail de la note'}
+
+
+ {selectedNote.reference || `#${selectedNote.id}`}
+
+
+
setSelectedNote(null)} style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ fontSize: 18, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 12,
+ }}>✕
+
+
+ {/* Corps scrollable */}
+
+
+
+ {/* Info grid */}
+
+ {[
+ { label: 'Référence', value: selectedNote.reference || `#${selectedNote.id}`, mono: true },
+ { label: 'Statut', value: tagStatut(selectedNote.statut || 'brouillon'), isNode: true },
+ { label: 'Catégorie', value: selectedNote.categorie },
+ { label: 'Date', value: selectedNote.date ? new Date(selectedNote.date).toLocaleDateString('fr-FR') : '-' },
+ { label: 'Montant TTC', value: fmt(selectedNote.montant || 0), bold: true },
+ { label: 'Collaborateur', value: (selectedNote as any).collaborateur },
+ ].filter(i => i.value).map(({ label, value, mono, bold, isNode }: any) => (
+
+
{label}
+ {isNode
+ ?
{value}
+ :
{value || '—'}
+ }
+
+ ))}
+
+
+ {/* Refusée */}
+ {normalizeStatut(selectedNote.statut || '') === 'refuse' && (
+
+
+ ❌ Note refusée — action requise
+
+ {selectedNote.motifRefus && (
+
+ Motif : {selectedNote.motifRefus}
+
+ )}
+
handleModifierNote(selectedNote)} style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px',
+ background: 'linear-gradient(135deg,#ef4444,#dc2626)',
+ color: '#fff', border: 'none', borderRadius: 8,
+ cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
+ }}>✏️ Corriger et resoumettre
+
+ )}
+ {/* Refusée par Finance */}
+ {normalizeStatut(selectedNote.statut || '') === 'refuse_verif' && (
+
+
+ ❌ Note refusée par la Finance — action requise
+
+ {selectedNote.commentaireVerification && (
+
+ {selectedNote.commentaireVerification}
+
+ )}
+
+ Le Vérificateur Finance a refusé certaines lignes. Corrigez-les puis resoumettez.
+
+
handleModifierNote(selectedNote)} style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px',
+ background: 'linear-gradient(135deg,#ef4444,#dc2626)',
+ color: '#fff', border: 'none', borderRadius: 8,
+ cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
+ boxShadow: '0 4px 12px rgba(239,68,68,.3)',
+ }}>✏️ Corriger et resoumettre
+
+ )}
+
+ {/* Modifiable si en attente */}
+ {['enattente', 'en_attente', 'en attente'].includes(normalizeStatut(selectedNote.statut || '')) && (
+
+
+
✏️ Note modifiable
+
En attente — vous pouvez encore la modifier.
+
+
handleModifierNote(selectedNote)} style={{ ...btnPrimary, fontSize: 12, padding: '8px 16px' }}>
+ ✏️ Modifier
+
+
+ )}
+
+ {/* Commentaires */}
+ {selectedNote.commentaireN1 && (
+
+ 💬 Commentaire N1 : {selectedNote.commentaireN1}
+
+ )}
+
+ {/* Date paiement */}
+ {selectedNote.datePaiement && (
+
+ 💶 Payée le {new Date(selectedNote.datePaiement).toLocaleDateString('fr-FR')}
+
+ )}
+
+ {/* Lignes détail */}
+
setPreviewUrl({ url: proxyUrl(url), name })}
+ />
+
+ {/* Fichiers globaux */}
+ {['approuve', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee'].includes(normalizeStatut(selectedNote.statut || '')) && (
+ setPreviewUrl({ url, name })}
+ proxyFn={proxyUrl}
+ />
+ )}
+
+ >
+ ) : (
+ /* État vide — aucune note sélectionnée */
+
+
📋
+
+ Sélectionnez une note
+
+
+ Cliquez sur une note à gauche pour afficher son détail, son statut et ses justificatifs ici.
+
+
+ )}
- {selectedNote && renderNoteDetailPanel(selectedNote, () => setSelectedNote(null), false)}
)}
@@ -2822,42 +3394,154 @@ const Dashboard = (): JSX.Element => {
{/* VALIDATION */}
{/* ════════════════════════════════════════ */}
{section === 'validation' && canValidate && (
-
-
-
-
- Notes en attente
- {pending.length > 0 && {pending.length} }
-
+
+
+ {/* COL 1 : liste notes */}
+
+
+
+ À valider
+ {pending.length > 0 && {pending.length} }
+
- {pending.length === 0
- ?
- :
- {pending.map(note => {
+
+ {pending.length === 0
+ ?
Aucune note en attente
+ : pending.map(note => {
const isSelected = noteDetail?.id === note.id;
return (
-
setNoteDetail(isSelected ? null : note)} style={{ border: `2px solid ${isSelected ? '#6366f1' : '#e2e8f0'}`, borderRadius: 10, padding: '14px 16px', background: isSelected ? '#eef2ff' : '#fafafa', cursor: 'pointer', transition: 'all 0.15s' }}>
+
{ setNoteDetail(isSelected ? null : note); setInlinePreview(null); }} style={{
+ padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
+ border: `1.5px solid ${isSelected ? '#6366f1' : 'var(--border-card)'}`,
+ background: isSelected ? '#eef2ff' : 'var(--bg-card)',
+ transition: 'all 0.15s',
+ }}>
+
{note.reference || `#${note.id}`}
+
{note.libelle}
+
{note.collaborateur}
-
-
{note.reference || `#${note.id}`}
-
{note.libelle}
-
{note.collaborateur} · {note.categorie}
-
-
-
{fmt(note.montant || 0)}
-
{tagStatut(note.statut || 'brouillon')}
-
+
{normalizeStatut(note.statut || '') === 'enattente' ? 'N1' : 'N2'}
+
{fmt(note.montant || 0)}
);
- })}
-
- }
+ })
+ }
+
- {noteDetail && renderNoteDetailPanel(noteDetail, () => setNoteDetail(null), true)}
+
+ {/* COL 2 + COL 3 : détail + justificatif */}
+ {noteDetail ? (
+ <>
+ {/* COL 2 : dépenses */}
+
+
+
+
+ {noteDetail.collaborateur} · {fmt(noteDetail.montant || 0)}
+
+
{noteDetail.libelle}
+
+
setNoteDetail(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: 'var(--text-muted)', flexShrink: 0 }}>✕
+
+
+
+ {(noteDetail as any).noteRefuseeId && (
+
+
🔄 Correction d'une note refusée
+
Ancienne référence : {(noteDetail as any).ancienneReference || `#${(noteDetail as any).noteRefuseeId}`}
+
+ )}
+
+ {[
+ { label: 'Référence', value: noteDetail.reference || `#${noteDetail.id}`, mono: true },
+ { label: 'Statut', value: tagStatut(noteDetail.statut || 'brouillon'), isNode: true },
+ { label: 'Date', value: noteDetail.date ? new Date(noteDetail.date).toLocaleDateString('fr-FR') : '-' },
+ { label: 'Montant TTC', value: fmt(noteDetail.montant || 0), bold: true },
+ ].map(({ label, value, mono, bold, isNode }: any) => (
+
+
{label}
+ {isNode ?
{value}
+ :
{value}
}
+
+ ))}
+
+ {noteDetail.commentaireN1 && (
+
+ 💬 Commentaire N1 : {noteDetail.commentaireN1}
+
+ )}
+
setInlinePreview({ url, name })}
+ />
+
+ {/* Boutons fixes en bas */}
+
+ { setMotifRefusInput(''); setModalValidation({ noteId: noteDetail.id, action: 'valider', onClose: () => setNoteDetail(null) }); }}>
+ ✅ Valider
+
+ { setMotifRefusInput(''); setModalValidation({ noteId: noteDetail.id, action: 'refuser', onClose: () => setNoteDetail(null) }); }}>
+ ❌ Refuser
+
+
+
+
+ {/* COL 3 : aperçu justificatif inline */}
+ {/* COL 3 : aperçu justificatif inline */}
+
+
+
+
+ {inlinePreview ? inlinePreview.name : 'Justificatif'}
+
+ {inlinePreview && (
+
+
+ ↗ Ouvrir
+
+
setInlinePreview(null)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 16, color: 'var(--text-muted)' }}>✕
+
+ )}
+
+
+ {inlinePreview
+ ?
+ : (
+
+
📎
+
Cliquez sur "Voir" dans une dépense pour afficher le justificatif ici
+
+ )
+ }
+
+
+ >
+ ) : (
+
+
+
+ )}
)}
-
{/* ════════════════════════════════════════ */}
{/* MES VALIDATIONS */}
{/* ════════════════════════════════════════ */}
@@ -2941,53 +3625,54 @@ const Dashboard = (): JSX.Element => {
{/* ════════════════════════════════════════ */}
{section === 'paiements' && (isRHAdmin || isValidateurFinance) && (() => {
- const campusBruts = [
- ...notesApprouvees.map(n => n.campus),
- ...notesEnAttenteXml.map(n => n.campus)
- ].filter(Boolean) as string[];
-
- // Dédupliquer par code normalisé
- const campusMap = new Map
();
- campusBruts.forEach(c => {
- const code = normalizeCampus(c) || c;
- if (!campusMap.has(code)) campusMap.set(code, code);
- });
// ── Listes campus/sociétés disponibles ──
const campusPaiements = filtresDisponibles.campus;
- const societesPaiements = filtresDisponibles.societes;
- // ── Notes filtrées ──
- // ── Notes filtrées ──
+
+ // ── Mapping campus → sociétés (inline, pas de useMemo ici) ──
+ const campusSocieteMapping: Record = {
+ 'SQY': ['ENSUP', 'ENSUP SOLUTIONS ET SUPPORT'], // ← SOLUTIONS avec S
+ 'CGY': ['ENSUP', 'ENSUP SOLUTIONS ET SUPPORT'], // ← SOLUTIONS avec S
+ 'NTE': ['ENSUP NTE'],
+ 'MRS': ['ENSUP MRS'],
+ };
+
+ const societesFiltrablesPaiements = (() => {
+ if (!paiementFiltreCampus) return filtresDisponibles.societes;
+ // Utiliser la map serveur — données exactes de la BDD
+ return filtresDisponibles.societeParCampus[paiementFiltreCampus] || filtresDisponibles.societes;
+ })();
+
+ // ── Filtrage des notes ──
const filtrerNote = (n: Note) => {
const campusNorm = normalizeCampus(n.campus || '');
const matchCampus = !paiementFiltreCampus ||
campusNorm === paiementFiltreCampus ||
- normalizeCampus(n.campus || '') === normalizeCampus(paiementFiltreCampus);
+ (n.campus || '').toUpperCase().includes(paiementFiltreCampus);
const matchSociete = !paiementFiltreSociete ||
- (n.societe || '') === paiementFiltreSociete;
+ (n.societe || '').trim().toUpperCase() === paiementFiltreSociete.trim().toUpperCase();
return matchCampus && matchSociete;
};
const notesFiltreesPaiements = notesApprouvees.filter(filtrerNote);
const notesFiltreesXml = notesEnAttenteXml.filter(filtrerNote);
-
const hasFiltre = paiementFiltreCampus || paiementFiltreSociete;
return (
- {/* Bandeau rôle ValidateurFinance */}
-
-
{/* ── Barre de filtres globale ── */}
🔍 Filtrer
+
+ {/* Campus */}
{
setPaiementFiltreCampus(e.target.value);
+ setPaiementFiltreSociete(''); // ← reset société quand campus change
setSelectedNoteIds([]);
setSelectedNoteIdsPaye([]);
}}
@@ -2999,6 +3684,8 @@ const Dashboard = (): JSX.Element => {
))}
+
+ {/* Société — filtrée selon le campus */}
{
@@ -3008,10 +3695,23 @@ const Dashboard = (): JSX.Element => {
}}
style={{ ...inputStyle, width: 220 }}>
Toutes les sociétés
- {societesPaiements.map(s => (
+ {societesFiltrablesPaiements.map(s => (
{s}
))}
+
+ {/* Indicateur compte débiteur */}
+ {paiementFiltreCampus && (
+
+ 🏦 Compte : {paiementFiltreCampus}
+
+ )}
+
{hasFiltre && (
<>
{
- {/* Notes vérifiées à payer (ValidateurFinance) ou approuvées (Finance) */}
+ {/* Notes approuvées à régler */}
{isValidateurFinance && !isRHAdmin ? 'Notes vérifiées — à régler' : 'Notes approuvées — à régler'}
{notesFiltreesPaiements.length}
- {hasFiltre && notesApprouvees.length !== notesFiltreesPaiements.length &&
- / {notesApprouvees.length}
- }
{
if (!selectedNoteIds.length) return;
@@ -3058,13 +3762,9 @@ const Dashboard = (): JSX.Element => {
});
if (!res.ok) {
const e = await res.json();
- // ── Affichage détaillé si données manquantes ──
if (res.status === 422 && e.details?.length) {
- const msg = `❌ ${e.error}\n\n${e.details.map((d: string) => `• ${d}`).join('\n')}`;
- alert(msg);
- } else {
- throw new Error(e.error);
- }
+ alert(`❌ ${e.error}\n\n${e.details.map((d: string) => `• ${d}`).join('\n')}`);
+ } else throw new Error(e.error);
return;
}
const blob = await res.blob();
@@ -3074,24 +3774,22 @@ const Dashboard = (): JSX.Element => {
a.download = `virements-ndf-${new Date().toISOString().split('T')[0]}.xml`;
a.click();
URL.revokeObjectURL(url);
- showToast(`XML généré — ${selectedNoteIds.length} virement(s)`, 'success');
+ showToast(`✅ XML généré — ${selectedNoteIds.length} virement(s)`, 'success');
setSelectedNoteIds([]);
await refreshNotesPaiements();
} catch (e: any) { showToast(e.message, 'error'); }
finally { setPaiementLoading(false); }
}}>
- 🏦 Générer XML banque ({selectedNoteIds.length})
+ {paiementLoading
+ ? <>⏳ Génération... >
+ : <>🏦 Générer XML ({selectedNoteIds.length}) >
+ }
{notesFiltreesPaiements.length === 0
?
- {hasFiltre
- ? `Aucune note pour les filtres sélectionnés`
- : isValidateurFinance && !isRHAdmin
- ? 'Aucune note vérifiée en attente de règlement'
- : 'Aucune note approuvée en attente de règlement'
- }
+ {hasFiltre ? 'Aucune note pour les filtres sélectionnés' : 'Aucune note approuvée en attente de règlement'}
:
@@ -3117,8 +3815,7 @@ const Dashboard = (): JSX.Element => {
-
- {['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Montant', 'Remise XML', 'PDF'].map(h => (
+ {['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Montant', 'Statut'].map(h => (
{h}
))}
@@ -3127,47 +3824,18 @@ const Dashboard = (): JSX.Element => {
{notesFiltreesPaiements.map(n => {
const isChecked = selectedNoteIds.includes(n.id);
return (
- setSelectedNoteIds(isChecked ? selectedNoteIds.filter(id => id !== n.id) : [...selectedNoteIds, n.id])}>
{ }} style={{ width: 16, height: 16 }} />
{n.reference ?? `#${n.id}`}
{n.collaborateur}
-
- {n.campus ? (
-
- {normalizeCampus(n.campus)}
-
- ) : '-'}
+
+ {n.campus ? {normalizeCampus(n.campus)} : '-'}
-
- {(n as any).societe || '-'}
-
- {n.libelle}
+ {n.societe || '-'}
{fmt(n.montant ?? 0)}
{tagStatut(n.statut ?? 'brouillon')}
- e.stopPropagation()}>
- {(() => {
- let fichiers: { fileName: string; uploadUrl: string }[] = [];
- try { fichiers = n.fichiers ? JSON.parse(n.fichiers as string) : []; } catch { }
- const recapPaiement = fichiers.find(f => f.fileName?.includes('recap-paiement'));
- const signeApprouve = fichiers.find(f => f.fileName?.includes('signe') && f.fileName?.includes('approuve'));
- const recap = fichiers.find(f => f.fileName?.includes('recap'));
- const best = recapPaiement || signeApprouve || recap || fichiers[0];
- if (!best) return — ;
- return (
-
- setPreviewUrl({ url: proxyUrl(best.uploadUrl), name: best.fileName })}
- style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '5px 10px', background: recapPaiement ? '#dcfce7' : '#eef2ff', border: `1px solid ${recapPaiement ? '#86efac' : '#c7d2fe'}`, borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', color: recapPaiement ? '#15803d' : '#6366f1', fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>
- {recapPaiement ? '📋 Récap complet' : '📄 PDF'}
-
- {fichiers.length > 1 && (
- {fichiers.length} fichiers
- )}
-
- );
- })()}
-
);
})}
@@ -3481,7 +4149,7 @@ const Dashboard = (): JSX.Element => {
{notesFiltrees.length === 0
?
:
-
+
{['Référence', 'Collaborateur', 'Département', 'Libellé', 'Date', 'Montant', 'Statut'].map(h => (
@@ -3564,7 +4232,7 @@ const Dashboard = (): JSX.Element => {
{/* ════════════════════════════════════════ */}
{/* PROFIL */}
{/* ════════════════════════════════════════ */}
- {section === 'profil' && (
+ {section === 'profil' && (
{/* ── En-tête profil ── */}
@@ -3652,7 +4320,7 @@ const Dashboard = (): JSX.Element => {
textTransform: 'uppercase', letterSpacing: '0.6px',
display: 'flex', alignItems: 'center', gap: 6,
}}>
- 🏠 Adresse & Société
+ 🏠 Votre Adresse postale & Société
{(!profile?.adresse_rue || !profile?.adresse_cp || !profile?.adresse_ville || !profile?.adresse_pays) && (
{
setIbanForm(f => ({ ...f, iban: e.target.value }))}
+ onChange={e => setIbanForm(f => ({ ...f, iban: e.target.value.replace(/\s/g, '').toUpperCase() }))}
+
placeholder="FR76 3000..." maxLength={34}
/>
@@ -4889,7 +5558,7 @@ const Dashboard = (): JSX.Element => {
))}
setPreviewUrl({ url: proxyUrl(url), name })}
/>
@@ -4956,7 +5625,7 @@ const Dashboard = (): JSX.Element => {
style={{
width: '100%', padding: '10px 14px',
border: `1.5px solid ${modalValidation.action === 'refuser' && !motifRefusInput.trim()
- ? '#fca5a5' : 'var(--border-input)'
+ ? '#fca5a5' : 'var(--border-input)'
}`,
borderRadius: 8, resize: 'vertical',
background: 'var(--bg-input)',
@@ -5032,6 +5701,7 @@ const Dashboard = (): JSX.Element => {
)}
+
{/* ── URL PREVIEW ── */}
{previewUrl && setPreviewUrl(null)} />}
diff --git a/ndf/src/pages/NdfChatbot.tsx b/ndf/src/pages/NdfChatbot.tsx
new file mode 100644
index 0000000..b8a3f59
--- /dev/null
+++ b/ndf/src/pages/NdfChatbot.tsx
@@ -0,0 +1,903 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+
+// ── TYPES ──────────────────────────────────────────────
+interface Contact {
+ label: string;
+ email: string;
+ desc: string;
+ icon: string;
+ color: string;
+ bg: string;
+ border: string;
+}
+
+interface Message {
+ id: string | number;
+ role: "user" | "assistant";
+ content: string;
+ showContacts?: boolean;
+ topic?: string | null;
+ score?: number;
+ followUps?: string[];
+}
+
+interface FeedbackEntry {
+ msgId: string | number;
+ type: "up" | "down";
+ content: string;
+ timestamp: number;
+}
+
+interface QuestionEntry {
+ question: string;
+ timestamp: number;
+}
+
+// ── CONFIG ─────────────────────────────────────────────
+const CONTACTS: Contact[] = [
+ {
+ label: "Service Finance",
+ email: "servicesgeneraux-sqy@ensup.eu",
+ desc: "Questions sur un paiement, justificatif, vérification",
+ icon: "💶", color: "#15803d", bg: "#f0fdf4", border: "#86efac",
+ },
+ {
+ label: "Support informatique",
+ email: "support@ensup.eu",
+ desc: "Connexion, compte bloqué",
+ icon: "💻", color: "#0369a1", bg: "#f0f9ff", border: "#bae6fd",
+ },
+];
+
+const QUICK_QUESTIONS: string[] = [
+ "Comment soumettre une note ?",
+ "Ajouter un justificatif sur mobile",
+ "Ma note a été refusée",
+ "Configurer mon IBAN",
+ "Calculer mes frais km",
+ "Contacter le support",
+];
+
+// ── BASE DE CONNAISSANCES ENRICHIE ─────────────────────
+interface KnowledgeEntry {
+ id: string;
+ keywords: string[];
+ answer: string;
+ showContacts?: boolean;
+ topic: string;
+ followUps: string[];
+}
+
+const KNOWLEDGE_BASE: KnowledgeEntry[] = [
+ {
+ id: "soumettre",
+ keywords: ["soumettre", "créer", "nouvelle note", "comment soumettre", "envoyer", "nouvelle", "creer"],
+ answer: "Pour soumettre une note de frais :\n1. Clique sur **Nouvelle note** dans le menu\n2. Renseigne le libellé global\n3. Ajoute tes dépenses ligne par ligne (date, catégorie, montant, justificatif)\n4. Clique sur **Soumettre**\n\n⚠️ Prérequis : IBAN + BIC + adresse complète dans Mon profil.",
+ topic: "soumission",
+ followUps: ["Configurer mon IBAN", "Ajouter un justificatif sur mobile", "Quel statut après soumission ?"],
+ },
+ {
+ id: "justificatif",
+ keywords: ["justificatif", "photo", "mobile", "qr", "qr code", "scanner", "telephone", "téléphone", "ajouter justificatif"],
+ answer: "Pour ajouter un justificatif sur mobile :\n1. Dans le formulaire, clique sur **Générer QR Code** sur la ligne concernée\n2. Scanne le QR avec l'appareil photo natif de ton téléphone\n3. Prends la photo du justificatif\n4. Il est associé automatiquement ✅\n\nLe QR Code est valable **24h**. Utilise bien l'appli photo native (pas une app tierce).",
+ topic: "justificatif",
+ followUps: ["QR Code expiré que faire ?", "Justificatif en PDF ?", "Soumettre ma note"],
+ },
+ {
+ id: "refusee",
+ keywords: ["refusée", "refus", "refusé", "motif", "corriger", "correction", "resoumettre", "rejetee", "rejetée"],
+ answer: "Si ta note est refusée :\n1. Ouvre-la dans **Mes notes**\n2. Lis le motif de refus (affiché en rouge)\n3. Clique sur **✏️ Corriger et resoumettre**\n4. Corrige les informations demandées\n5. Resoumets\n\nLa note repart au début du circuit de validation.",
+ topic: "refus",
+ followUps: ["Qui valide ma note ?", "Délai de re-soumission ?", "Contacter le support"],
+ },
+ {
+ id: "iban",
+ keywords: ["iban", "bic", "coordonnées bancaires", "bancaire", "banque", "virement", "configurer iban", "rib"],
+ answer: "Pour configurer ton IBAN :\n1. Va dans **Mon profil** (menu gauche)\n2. Section **RIB / Coordonnées bancaires**\n3. Clique sur **Saisir mon IBAN**\n4. Entre ton IBAN (ex: FR76...) et ton BIC (ex: BNPAFRPP)\n5. Clique sur **Enregistrer**\n\n⚠️ IBAN et BIC sont obligatoires pour soumettre une note.",
+ topic: "iban",
+ followUps: ["Changer mon IBAN", "Virement non reçu ?", "Comment soumettre une note ?"],
+ },
+ {
+ id: "km",
+ keywords: ["km", "kilométrique", "kilomètre", "voiture", "véhicule", "barème", "indemnité", "kilometrique", "kilom"],
+ answer: "Pour les frais kilométriques :\n- Sélectionne la catégorie **Kilométrique** dans ta dépense\n- Saisis le nombre de km\n- Le montant est calculé automatiquement selon le barème fiscal :\n - 3 CV : 0,529 €/km\n - 5 CV : 0,636 €/km\n - 7 CV+ : 0,697 €/km\n\nConfigure ton véhicule dans **Mon profil → Véhicule personnel** pour un calcul automatique.",
+ topic: "kilometrique",
+ followUps: ["Ajouter ma carte grise", "Plusieurs trajets en une note ?", "Ajouter un justificatif sur mobile"],
+ },
+ {
+ id: "support",
+ keywords: ["support", "contact", "aide", "problème", "bug", "erreur", "contacter", "help", "assistance"],
+ answer: "Voici les contacts disponibles selon ton besoin :",
+ showContacts: true,
+ topic: "support",
+ followUps: ["Problème de connexion ?", "Compte bloqué ?"],
+ },
+ {
+ id: "statut",
+ keywords: ["statut", "état", "où en est", "validation", "validé", "approuvé", "payée", "circuit", "en attente", "valide"],
+ answer: "Le circuit de validation d'une note :\n1. **En attente** → validation N1 en cours\n2. **Validé N1** → approuvé par le manager\n3. **Validé N2** → second niveau validé\n4. **Approuvé** → validé hiérarchiquement\n5. **Vérifié** → contrôle Finance OK\n6. **Paiement en attente** → XML SEPA généré\n7. **Payée** → remboursement effectué 💶",
+ topic: "statut",
+ followUps: ["Délai moyen de remboursement ?", "Note bloquée en validation ?", "Contacter le support"],
+ },
+ {
+ id: "paiement",
+ keywords: ["paiement", "remboursement", "payé", "quand", "délai", "virement", "non reçu", "pas reçu"],
+ answer: "Si tu n'as pas reçu ton remboursement :\n1. Vérifie que le statut est bien **Payée** dans Mes notes\n2. Vérifie ton IBAN dans Mon profil\n3. Si tout est correct, contacte la Finance avec la référence de ta note.\n\nLe paiement est effectué par virement SEPA après confirmation par la Finance.",
+ topic: "paiement",
+ followUps: ["Vérifier mon IBAN", "Contacter le support", "Délai bancaire SEPA ?"],
+ },
+ {
+ id: "adresse",
+ keywords: ["adresse", "postale", "profil", "rue", "ville", "code postal", "modifier adresse"],
+ answer: "Pour renseigner ton adresse postale :\n1. Va dans **Mon profil**\n2. Section **Adresse postale**\n3. Clique sur **Modifier**\n4. Remplis rue, code postal, ville, pays\n5. Clique sur **Enregistrer**\n\n⚠️ L'adresse complète est obligatoire pour soumettre une note.",
+ topic: "profil",
+ followUps: ["Comment soumettre une note ?", "Configurer mon IBAN"],
+ },
+ {
+ id: "brouillon",
+ keywords: ["brouillon", "sauvegarder", "sauvegarde", "automatique", "perdre"],
+ answer: "Les brouillons sont sauvegardés **automatiquement toutes les 3 secondes** pendant que tu saisis ta note.\n\nPour retrouver un brouillon :\n- Clique sur **Nouvelle note**\n- Tes brouillons apparaissent en haut du formulaire\n- Clique sur l'un d'eux pour le reprendre.",
+ topic: "soumission",
+ followUps: ["Comment soumettre une note ?", "Ajouter un justificatif sur mobile"],
+ },
+ {
+ id: "documents",
+ keywords: ["document", "carte grise", "permis", "rib", "valider", "finance"],
+ answer: "Les documents obligatoires dans Mon profil :\n- **RIB** : ton IBAN + BIC (obligatoire pour tout remboursement)\n- **Carte grise** : obligatoire pour les notes kilométriques\n- **Permis de conduire** : obligatoire pour les notes kilométriques\n\nCes documents sont soumis à validation par la Finance. Upload dans **Mon profil → Documents**.",
+ topic: "profil",
+ followUps: ["Configurer mon IBAN", "Calculer mes frais km"],
+ },
+ {
+ id: "tva",
+ keywords: ["tva", "taxe", "hors taxe", "ht", "ttc"],
+ answer: "Pour la TVA dans tes dépenses :\n- Sélectionne le **taux de TVA** correspondant à ta dépense (0%, 5,5%, 10%, 20%)\n- Le montant HT est calculé automatiquement\n- Si pas de TVA (ex: frais kilométriques), laisse vide\n\nLes taux disponibles sont configurés par la Finance.",
+ topic: "depenses",
+ followUps: ["Calculer mes frais km", "Frais de repas plafond ?"],
+ },
+ {
+ id: "repas",
+ keywords: ["repas", "restaurant", "plafond", "25", "nourriture", "déjeuner", "dîner", "dejeuner", "diner"],
+ answer: "Pour les frais de repas :\n- Catégorie : **Repas / Restaurant**\n- Plafond appliqué par la Finance : **25 € par personne**\n- Si ton repas dépasse ce plafond, le montant sera proratisé automatiquement\n- Indique le nombre de participants si c'est un repas d'équipe.",
+ topic: "depenses",
+ followUps: ["Ajouter le justificatif restaurant", "Plafond hôtel ?", "Comment soumettre une note ?"],
+ },
+ {
+ id: "hotel",
+ keywords: ["hébergement", "hotel", "hôtel", "nuit", "nuits", "hebergement"],
+ answer: "Pour les frais d'hébergement :\n- Catégorie : **Hébergement / Hôtel**\n- Indique le **nombre de nuits** dans le champ prévu\n- Joins la facture de l'hôtel comme justificatif\n- Le montant par nuit est calculé automatiquement.",
+ topic: "depenses",
+ followUps: ["Ajouter la facture hôtel", "Frais de repas plafond ?", "Comment soumettre une note ?"],
+ },
+];
+
+// ── RÉPONSES SOCIALES ──────────────────────────────────
+interface SocialEntry {
+ patterns: string[];
+ responses: string[];
+}
+
+const SOCIAL_RESPONSES: SocialEntry[] = [
+ {
+ patterns: ["merci", "merc", "thanks", "thank you", "super merci", "mercii"],
+ responses: [
+ "Avec plaisir 😊",
+ "Je t'en prie !",
+ "Avec plaisir. Si tu veux, je peux aussi t'aider sur les justificatifs, l'IBAN ou les remboursements.",
+ "Pas de souci 😊",
+ ],
+ },
+ {
+ patterns: ["bonjour", "salut", "hello", "bonsoir", "coucou"],
+ responses: [
+ "Bonjour 👋 Comment puis-je t'aider sur la note de frais ?",
+ "Salut 👋 Je suis là pour t'aider sur la plateforme NDF.",
+ "Bonjour ! Tu peux me poser une question sur les justificatifs, le remboursement, l'IBAN ou le statut d'une note.",
+ ],
+ },
+ {
+ patterns: ["au revoir", "bye", "a bientot", "à bientot", "bonne journée", "bonne journee"],
+ responses: ["À bientôt 👋", "Bonne journée 😊", "À bientôt, et bon courage pour ta note de frais !"],
+ },
+ {
+ patterns: ["ok", "d accord", "dac", "ça marche", "ca marche", "parfait"],
+ responses: ["Parfait 👍", "Très bien 😊", "D'accord, je reste là si besoin."],
+ },
+];
+
+// ── UTILITAIRES ────────────────────────────────────────
+function normalizeText(input: string): string {
+ return input
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^a-z0-9\s]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function pickRandom(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+// ── FUZZY MATCHING (distance de Levenshtein) ───────────
+function levenshtein(a: string, b: string): number {
+ const m = a.length, n = b.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
+ Array.from({ length: n + 1 }, (__, j) => (i === 0 ? j : j === 0 ? i : 0))
+ );
+ for (let i = 1; i <= m; i++)
+ for (let j = 1; j <= n; j++)
+ dp[i][j] = a[i - 1] === b[j - 1]
+ ? dp[i - 1][j - 1]
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
+ return dp[m][n];
+}
+
+function fuzzyScore(inputNorm: string, keyword: string): number {
+ const normKw = normalizeText(keyword);
+ // Correspondance exacte (pondérée par longueur)
+ if (inputNorm.includes(normKw)) return normKw.split(" ").length * 3;
+ // Début de mot (min 4 chars)
+ if (inputNorm.split(" ").some((w) => w.startsWith(normKw) && normKw.length >= 4)) return 2;
+ // Fuzzy tolérant aux fautes de frappe
+ const words = inputNorm.split(" ");
+ const kwWords = normKw.split(" ");
+ let score = 0;
+ for (const kw of kwWords) {
+ if (kw.length < 4) continue;
+ const tolerance = kw.length <= 5 ? 1 : 2;
+ if (words.some((w) => levenshtein(w, kw) <= tolerance)) score += 1.5;
+ }
+ return score;
+}
+
+// ── MOTEUR DE RÉPONSE ──────────────────────────────────
+interface BotResponse {
+ answer: string;
+ showContacts?: boolean;
+ topic?: string | null;
+ score?: number;
+ followUps?: string[];
+}
+
+function getBotResponse(input: string, conversationHistory: Message[]): BotResponse {
+ const normalized = normalizeText(input);
+
+ // 1) Réponses sociales
+ for (const social of SOCIAL_RESPONSES) {
+ if (social.patterns.some((p) => normalized.includes(normalizeText(p)))) {
+ return { answer: pickRandom(social.responses), topic: null, score: 10, followUps: QUICK_QUESTIONS.slice(0, 3) };
+ }
+ }
+
+ // 2) Contexte : sujet du dernier échange
+ const lastTopic = [...conversationHistory].reverse().find((h) => h.topic)?.topic ?? null;
+
+ // 3) Scoring fuzzy sur toute la KB
+ let bestMatch: KnowledgeEntry | null = null;
+ let bestScore = 0;
+
+ for (const entry of KNOWLEDGE_BASE) {
+ let score = 0;
+ for (const kw of entry.keywords) {
+ score += fuzzyScore(normalized, kw);
+ }
+ // Bonus contextuel : sujet précédent lié
+ if (lastTopic && entry.topic === lastTopic) score += 0.5;
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestMatch = entry;
+ }
+ }
+
+ if (bestMatch && bestScore >= 1.5) {
+ return {
+ answer: bestMatch.answer,
+ showContacts: bestMatch.showContacts,
+ topic: bestMatch.topic,
+ score: bestScore,
+ followUps: bestMatch.followUps,
+ };
+ }
+
+ // 4) Réponse par défaut
+ return {
+ answer:
+ "Je n'ai pas bien compris ta question 😅\n\nJe peux t'aider sur :\n- Soumettre une note\n- Ajouter un justificatif\n- Frais kilométriques\n- Configurer l'IBAN\n- Suivre le statut d'une note\n- Contacter le support\n\nTu peux reformuler avec une phrase simple.",
+ topic: null,
+ score: 0,
+ followUps: QUICK_QUESTIONS,
+ };
+}
+
+// ── CONTACT CARDS ──────────────────────────────────────
+function ContactCards() {
+ return (
+
+ );
+}
+
+// ── RENDU MARKDOWN ─────────────────────────────────────
+function renderContent(text: string): React.ReactNode[] {
+ return text.split("\n").map((line, i) => {
+ if (!line.trim()) return ;
+
+ const parseBold = (str: string): React.ReactNode[] =>
+ str.split(/\*\*(.*?)\*\*/g).map((part, j) =>
+ j % 2 === 1 ? {part} : part
+ );
+
+ const isBullet = line.startsWith("- ");
+ const isNum = /^\d+\. /.test(line);
+
+ if (isBullet || isNum) {
+ const content = line.replace(/^- /, "").replace(/^\d+\. /, "");
+ return (
+
+
+ {isBullet ? "•" : line.match(/^(\d+)\./)?.[1] + "."}
+
+ {parseBold(content)}
+
+ );
+ }
+ return (
+
+ {parseBold(line)}
+
+ );
+ });
+}
+
+// ── FEEDBACK BUTTON ────────────────────────────────────
+interface FeedbackRowProps {
+ msgId: string | number;
+ content: string;
+ onNegative: () => void;
+}
+
+function FeedbackRow({ msgId, content, onNegative }: FeedbackRowProps) {
+ const [voted, setVoted] = useState<"up" | "down" | null>(null);
+
+ const handleVote = (type: "up" | "down") => {
+ if (voted) return;
+ setVoted(type);
+ // Persistance locale
+ const stored: FeedbackEntry[] = JSON.parse(localStorage.getItem("ndf_feedback") || "[]");
+ stored.push({ msgId, type, content, timestamp: Date.now() });
+ localStorage.setItem("ndf_feedback", JSON.stringify(stored.slice(-50)));
+ if (type === "down") onNegative();
+ };
+
+ return (
+
+ {voted === null ? (
+ <>
+ handleVote("up")}
+ style={{
+ background: "none", border: "1.5px solid #e2e8f0", borderRadius: 20,
+ padding: "2px 8px", fontSize: 13, cursor: "pointer", color: "#94a3b8",
+ transition: "all 0.15s", fontFamily: "inherit",
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "#22c55e"; e.currentTarget.style.color = "#16a34a"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "#e2e8f0"; e.currentTarget.style.color = "#94a3b8"; }}
+ >👍
+ handleVote("down")}
+ style={{
+ background: "none", border: "1.5px solid #e2e8f0", borderRadius: 20,
+ padding: "2px 8px", fontSize: 13, cursor: "pointer", color: "#94a3b8",
+ transition: "all 0.15s", fontFamily: "inherit",
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = "#ef4444"; e.currentTarget.style.color = "#dc2626"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "#e2e8f0"; e.currentTarget.style.color = "#94a3b8"; }}
+ >👎
+ Utile ?
+ >
+ ) : (
+
+ {voted === "up" ? "Merci 😊" : "Noté, on va améliorer ça !"}
+
+ )}
+
+ );
+}
+
+// ── BARRE DE CONFIANCE ─────────────────────────────────
+
+
+// ── MESSAGE BUBBLE ─────────────────────────────────────
+interface MessageBubbleProps {
+ msg: Message;
+ onNegativeFeedback: (msg: Message) => void;
+}
+
+function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
+ const isBot = msg.role === "assistant";
+ return (
+
+ {isBot && (
+
🤖
+ )}
+
+
+ {renderContent(msg.content)}
+ {msg.showContacts && }
+
+ {/* Feedback + barre confiance uniquement sur les messages bot */}
+ {isBot && msg.id !== "welcome" && (
+
+
+ onNegativeFeedback(msg)}
+ />
+
+ )}
+
+
+ );
+}
+
+// ── TYPING INDICATOR ───────────────────────────────────
+function TypingIndicator() {
+ return (
+
+
🤖
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ );
+}
+
+// ── MEMORY BADGE ───────────────────────────────────────
+function MemoryBadge({ topics }: { topics: string[] }) {
+ if (topics.length === 0) return null;
+ return (
+
+ 🧠 Contexte : {topics.slice(-2).join(" → ")}
+
+ );
+}
+
+// ── SUGGESTIONS DYNAMIQUES ─────────────────────────────
+interface SuggestionsBarProps {
+ suggestions: string[];
+ label: string;
+ onSelect: (q: string) => void;
+ disabled: boolean;
+}
+
+function SuggestionsBar({ suggestions, label, onSelect, disabled }: SuggestionsBarProps) {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {suggestions.map((q, i) => (
+ onSelect(q)}
+ disabled={disabled}
+ style={{
+ padding: "4px 9px",
+ background: "var(--bg-card,#fff)",
+ border: "1.5px solid var(--border-card,#e2e8f0)",
+ borderRadius: 20, cursor: "pointer",
+ fontSize: 10.5, fontWeight: 600,
+ color: "var(--text-secondary,#64748b)",
+ fontFamily: "inherit", transition: "all 0.15s",
+ animation: `ndfFade 0.2s ease ${i * 0.05}s both`,
+ }}
+ >{q}
+ ))}
+
+
+ );
+}
+
+// ── COMPOSANT PRINCIPAL ────────────────────────────────
+export default function NDFChatbot() {
+ const [open, setOpen] = useState(false);
+ const [showWelcomeBubble, setShowWelcomeBubble] = useState(true);
+ const [displayMsgs, setDisplayMsgs] = useState([
+ {
+ id: "welcome",
+ role: "assistant",
+ content: "Bonjour ! 👋 Je suis l'assistant NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
+ topic: null,
+ },
+ ]);
+ const [currentSuggestions, setCurrentSuggestions] = useState(QUICK_QUESTIONS);
+ const [suggestionsLabel, setSuggestionsLabel] = useState("Questions fréquentes");
+ const [input, setInput] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [hasNotif, setHasNotif] = useState(true);
+ const bottomRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Sujets uniques de la conversation pour le badge mémoire
+ const conversationTopics = displayMsgs
+ .filter((m) => m.topic)
+ .map((m) => m.topic as string)
+ .filter((v, i, a) => a.indexOf(v) === i);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [displayMsgs, loading, open]);
+
+ useEffect(() => {
+ if (open) {
+ setHasNotif(false);
+ setTimeout(() => inputRef.current?.focus(), 120);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open && showWelcomeBubble) {
+ const timer = setTimeout(() => setShowWelcomeBubble(false), 6000);
+ return () => clearTimeout(timer);
+ }
+ }, [open, showWelcomeBubble]);
+
+ useEffect(() => {
+ if (open) setShowWelcomeBubble(false);
+ }, [open]);
+
+ // Réponse au feedback négatif
+ const handleNegativeFeedback = useCallback((originalMsg: Message) => {
+ setTimeout(() => {
+ setDisplayMsgs((prev) => [...prev, {
+ id: Date.now(),
+ role: "assistant",
+ content: "Désolé que cette réponse ne t'ait pas aidé 😕\n\nTu peux :\n- **Reformuler** ta question différemment\n- Contacter directement le **support**\n\nQue cherchais-tu exactement ?",
+ topic: originalMsg.topic,
+ score: undefined,
+ }]);
+ setCurrentSuggestions(["Contacter le support", ...QUICK_QUESTIONS.slice(0, 3)]);
+ setSuggestionsLabel("Que faire ?");
+ }, 400);
+ }, []);
+
+ const sendMessage = useCallback((text: string) => {
+ const userContent = text.trim();
+ if (!userContent || loading) return;
+
+ // Log de la question en localStorage
+ const qLog: QuestionEntry[] = JSON.parse(localStorage.getItem("ndf_questions") || "[]");
+ qLog.push({ question: userContent, timestamp: Date.now() });
+ localStorage.setItem("ndf_questions", JSON.stringify(qLog.slice(-100)));
+
+ setDisplayMsgs((prev) => [...prev, { id: Date.now(), role: "user", content: userContent }]);
+ setInput("");
+ setLoading(true);
+ setCurrentSuggestions([]);
+
+ setTimeout(() => {
+ setDisplayMsgs((prev) => {
+ const { answer, showContacts, topic, score, followUps } = getBotResponse(userContent, prev);
+ const newMsg: Message = {
+ id: Date.now() + 1,
+ role: "assistant",
+ content: answer,
+ showContacts,
+ topic,
+ score,
+ followUps,
+ };
+ // Mettre à jour suggestions après le message
+ setTimeout(() => {
+ if (followUps && followUps.length > 0) {
+ setCurrentSuggestions(followUps);
+ setSuggestionsLabel("Questions liées 💡");
+ } else {
+ setCurrentSuggestions(QUICK_QUESTIONS.slice(0, 4));
+ setSuggestionsLabel("Tu peux aussi me demander…");
+ }
+ }, 50);
+ return [...prev, newMsg];
+ });
+ setLoading(false);
+ }, 600 + Math.random() * 300);
+ }, [loading]);
+
+ const resetConversation = () => {
+ setDisplayMsgs([{
+ id: "reset-" + Date.now(),
+ role: "assistant",
+ content: "Conversation réinitialisée 🔄\n\nComment puis-je t'aider ?",
+ topic: null,
+ }]);
+ setCurrentSuggestions(QUICK_QUESTIONS);
+ setSuggestionsLabel("Questions fréquentes");
+ };
+
+ return (
+ <>
+
+
+ {/* ── BULLE DE BIENVENUE ── */}
+ {showWelcomeBubble && !open && (
+
+
setShowWelcomeBubble(false)}
+ aria-label="Fermer le message d'accueil"
+ style={{
+ position: "absolute", top: 6, right: 6,
+ width: 20, height: 20, border: "none", background: "transparent",
+ cursor: "pointer", color: "#94a3b8", fontSize: 12, lineHeight: 1,
+ }}
+ >✕
+
Hello 👋
+
+ Je suis NDF BOT , je peux t'aider si besoin.
+
+
+
+ )}
+
+ {/* ── BOUTON FLOTTANT ── */}
+ setOpen((o) => !o)}
+ aria-label="Ouvrir l'assistant NDF"
+ style={{
+ position: "fixed", bottom: 28, right: 28,
+ width: 52, height: 52, borderRadius: "50%",
+ background: open
+ ? "linear-gradient(135deg,#6b7280,#4b5563)"
+ : "linear-gradient(135deg,#6366f1,#4f46e5)",
+ border: "none", cursor: "pointer",
+ display: "flex", alignItems: "center", justifyContent: "center",
+ fontSize: 21, color: "#fff",
+ animation: !open ? "ndfPulse 2.8s ease-in-out infinite" : "none",
+ zIndex: 9999, transition: "background 0.2s",
+ }}
+ >
+ {open ? "✕" : "💬"}
+ {hasNotif && !open && (
+
+ )}
+
+
+ {/* ── FENÊTRE CHAT ── */}
+ {open && (
+
+
+ {/* HEADER */}
+
+
🤖
+
+
Assistant NDF
+
+
+ ENSUP Group · Toujours disponible
+
+
+
🗑
+
setOpen(false)}
+ style={{
+ background: "rgba(255,255,255,0.15)", border: "none", borderRadius: 7,
+ width: 28, height: 28, cursor: "pointer", color: "#fff",
+ fontSize: 17, display: "flex", alignItems: "center", justifyContent: "center",
+ }}
+ >✕
+
+
+ {/* BADGE MÉMOIRE CONTEXTUELLE */}
+
+
+ {/* MESSAGES */}
+
+ {displayMsgs.map((msg) => (
+
+ ))}
+ {loading &&
}
+
+
+
+ {/* SUGGESTIONS DYNAMIQUES */}
+ {currentSuggestions.length > 0 && (
+
+ )}
+
+
+
+ {/* INPUT */}
+
+
+
+ Assistant NDF · ENSUP Group
+
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/ndf/src/pages/NouvelleNote.tsx b/ndf/src/pages/NouvelleNote.tsx
index 8b4dec5..b39fa5d 100644
--- a/ndf/src/pages/NouvelleNote.tsx
+++ b/ndf/src/pages/NouvelleNote.tsx
@@ -3,8 +3,15 @@ import QRCode from "react-qr-code";
// ── TYPES ─────────────────────────────────────────────
interface TvaItem {
- taux: string;
+ taux: string; // "0", "5.5", "10", "20", ou "MIXED" pour ligne multi-TVA
montantTTC: string;
+ // ✅ Répartition TVA quand la ligne a plusieurs taux remplis
+ tvaBreakdown?: {
+ tva21?: string;
+ tva55?: string;
+ tva10?: string;
+ tva20?: string;
+ };
}
interface Participant { nom: string; prenom: string; societe?: string; }
interface Depense {
@@ -17,6 +24,7 @@ interface Depense {
qrNoteRef?: string;
filesMeta?: FileMeta[];
qrFiles?: QrFileMeta[];
+ nuits: string;
}
interface BrouillonServeur {
id: number; libelle: string; lignesJson: string;
@@ -32,6 +40,12 @@ interface TvaItemBackend {
taux: string;
montantTTC: string;
montantHT: string;
+ tvaBreakdown?: {
+ tva21?: string;
+ tva55?: string;
+ tva10?: string;
+ tva20?: string;
+ };
}
interface NouvelleNoteProps {
onSubmit: (
@@ -95,8 +109,49 @@ const ttcToTva = (ttc: number, taux: number): number => {
return parseFloat((ttc - ht).toFixed(2));
};
+// ✅ Helpers pour le cas MIXED
+const sumTvaBreakdown = (bd?: TvaItem['tvaBreakdown']): number => {
+ if (!bd) return 0;
+ return (parseFloat(bd.tva21 || "0") || 0)
+ + (parseFloat(bd.tva55 || "0") || 0)
+ + (parseFloat(bd.tva10 || "0") || 0)
+ + (parseFloat(bd.tva20 || "0") || 0);
+};
+
+const itemToHt = (item: TvaItem): number => {
+ const ttc = parseFloat(item.montantTTC) || 0;
+ if (ttc <= 0) return 0;
+ if (item.taux === "MIXED" && item.tvaBreakdown) {
+ return parseFloat((ttc - sumTvaBreakdown(item.tvaBreakdown)).toFixed(2));
+ }
+ const taux = parseFloat(item.taux) || 0;
+ return ttcToHt(ttc, taux);
+};
+
+const itemToTva = (item: TvaItem): number => {
+ const ttc = parseFloat(item.montantTTC) || 0;
+ if (ttc <= 0) return 0;
+ if (item.taux === "MIXED" && item.tvaBreakdown) {
+ return parseFloat(sumTvaBreakdown(item.tvaBreakdown).toFixed(2));
+ }
+ const taux = parseFloat(item.taux) || 0;
+ return ttcToTva(ttc, taux);
+};
+
const tvaItemToBackend = (item: TvaItem): TvaItemBackend => {
const ttc = parseFloat(item.montantTTC) || 0;
+
+ // ✅ Cas MIXED : HT = TTC - somme TVA du breakdown
+ if (item.taux === "MIXED" && item.tvaBreakdown) {
+ const totTva = sumTvaBreakdown(item.tvaBreakdown);
+ return {
+ taux: "MIXED",
+ montantTTC: item.montantTTC,
+ montantHT: String((ttc - totTva).toFixed(2)),
+ tvaBreakdown: item.tvaBreakdown,
+ };
+ }
+
const taux = parseFloat(item.taux) || 0;
return {
taux: item.taux,
@@ -106,6 +161,14 @@ const tvaItemToBackend = (item: TvaItem): TvaItemBackend => {
};
const normalizeTvaItem = (it: any): TvaItem => {
+ // ✅ Cas MIXED — préserver le breakdown si présent
+ if (it.taux === "MIXED" && it.tvaBreakdown) {
+ return {
+ taux: "MIXED",
+ montantTTC: String(it.montantTTC || ""),
+ tvaBreakdown: { ...it.tvaBreakdown },
+ };
+ }
if (it.montantTTC !== undefined && it.montantTTC !== "") {
return { taux: String(it.taux ?? "20"), montantTTC: String(it.montantTTC) };
}
@@ -159,6 +222,7 @@ function newDepense(defaultChevaux = 7): Depense {
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", montantTTC: "" }],
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
+ nuits: "",
};
}
@@ -448,6 +512,9 @@ select.nn-input {
.nn-repas-info.alert {
background: rgba(239,68,68,.07); border-color: rgba(239,68,68,.4); color: #dc2626;
}
+.nn-repas-info.warn {
+ background: rgba(245,158,11,.08); border-color: rgba(245,158,11,.4); color: #b45309;
+}
.nn-repas-info-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
/* Boutons +/- participant */
@@ -570,6 +637,19 @@ select.nn-input {
border-radius: 9px; color: #dc2626; font-size: 12px; font-weight: 600;
}
+.nn-nuits-box {
+ background: rgba(99,102,241,.05); border: 1.5px solid rgba(99,102,241,.2);
+ border-radius: 8px; padding: 10px 13px;
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
+ margin-bottom: 2px;
+}
+.nn-nuits-label { font-size: 11px; font-weight: 700; color: #4f46e5; }
+.nn-nuits-par-nuit {
+ font-size: 12px; font-weight: 800; font-family: 'DM Mono', monospace;
+ color: #7c3aed; background: rgba(124,58,237,.08);
+ border-radius: 6px; padding: 3px 9px;
+}
+
.nn-btn-sm { font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; border: 1px solid; }
.nn-btn-sm.indigo { background: rgba(99,102,241,.1); color: #6366f1; border-color: rgba(99,102,241,.25); }
.nn-btn-sm.green { background: rgba(22,163,74,.1); color: #16a34a; border-color: rgba(22,163,74,.25); }
@@ -707,7 +787,6 @@ function FileGrid({ files, ghostMetas, qrFiles, onRemoveFile, onRemoveMeta, onRe
}
// ── TVA EXCEL TABLE — saisie manuelle de toutes les colonnes ─
-// Colonnes : TTC (saisi) | TVA 2,1% (saisi) | TVA 5,5% (saisi) | TVA 10% (saisi) | TVA 20% (saisi) | HT (calculé = TTC - somme TVA)
interface ExcelRow {
ttc: string;
tva21: string;
@@ -720,46 +799,93 @@ function TvaExcelTable({ tvaItems, onUpdate }: {
tvaItems: TvaItem[];
onUpdate: (items: TvaItem[]) => void;
}) {
- // On stocke les données de la table Excel dans un état local de lignes
- // On initialise depuis tvaItems existants (compatibilité brouillons)
+ // ✅ Reconstruction des ExcelRow depuis les TvaItem
+ // 1 item = 1 ligne du tableau Excel
const initRows = (): ExcelRow[] => {
- // Essaie de reconstruire une ligne depuis les tvaItems sauvegardés
- if (tvaItems.length === 0) return [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
+ if (tvaItems.length === 0) {
+ return [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
+ }
- // Cas brouillon : on reconstruit une ligne agrégeant les taux connus
- const byTaux: Record = {};
- let totalTTC = 0;
- tvaItems.forEach(it => {
- const ttc = parseFloat(it.montantTTC) || 0;
- byTaux[it.taux] = it.montantTTC;
- totalTTC += ttc;
- });
- return [{
- ttc: totalTTC > 0 ? String(totalTTC) : "",
- tva21: byTaux["2.1"] || "",
- tva55: byTaux["5.5"] || "",
- tva10: byTaux["10"] || "",
- tva20: byTaux["20"] || "",
- }];
+ const rows = tvaItems
+ .filter(it => parseFloat(it.montantTTC || "0") > 0 || it.montantTTC === "")
+ .map(it => {
+ const row: ExcelRow = { ttc: it.montantTTC || "", tva21: "", tva55: "", tva10: "", tva20: "" };
+
+ if (it.taux === "MIXED" && it.tvaBreakdown) {
+ // ✅ Item MIXED : on a la répartition complète
+ if (it.tvaBreakdown.tva21) row.tva21 = it.tvaBreakdown.tva21;
+ if (it.tvaBreakdown.tva55) row.tva55 = it.tvaBreakdown.tva55;
+ if (it.tvaBreakdown.tva10) row.tva10 = it.tvaBreakdown.tva10;
+ if (it.tvaBreakdown.tva20) row.tva20 = it.tvaBreakdown.tva20;
+ } else {
+ // Item simple : on calcule la TVA depuis le taux + TTC
+ const ttc = parseFloat(it.montantTTC) || 0;
+ const taux = parseFloat(it.taux) || 0;
+ if (ttc > 0 && taux > 0) {
+ const ht = ttc / (1 + taux / 100);
+ const tva = ttc - ht;
+ const tvaStr = tva.toFixed(2);
+ if (it.taux === "2.1") row.tva21 = tvaStr;
+ else if (it.taux === "5.5") row.tva55 = tvaStr;
+ else if (it.taux === "10") row.tva10 = tvaStr;
+ else if (it.taux === "20") row.tva20 = tvaStr;
+ }
+ }
+ return row;
+ });
+
+ return rows.length > 0 ? rows : [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
};
const [rows, setRows] = useState(initRows);
- // Synchronise vers tvaItems parent à chaque changement
+ // ✅ FIX : 1 ligne du tableau = 1 SEUL item dans tvaItems
const syncToParent = (newRows: ExcelRow[]) => {
const items: TvaItem[] = [];
+
newRows.forEach(row => {
- if (row.tva21 && parseFloat(row.tva21) > 0) items.push({ taux: "2.1", montantTTC: row.ttc });
- if (row.tva55 && parseFloat(row.tva55) > 0) items.push({ taux: "5.5", montantTTC: row.ttc });
- if (row.tva10 && parseFloat(row.tva10) > 0) items.push({ taux: "10", montantTTC: row.ttc });
- if (row.tva20 && parseFloat(row.tva20) > 0) items.push({ taux: "20", montantTTC: row.ttc });
+ const ttc = parseFloat(row.ttc) || 0;
+ if (ttc <= 0 && !row.ttc) return; // ligne vide → on saute
+
+ const t21 = parseFloat(row.tva21) || 0;
+ const t55 = parseFloat(row.tva55) || 0;
+ const t10 = parseFloat(row.tva10) || 0;
+ const t20 = parseFloat(row.tva20) || 0;
+
+ // Compter combien de taux ont une valeur > 0
+ const tauxRemplis = [
+ { val: t21, tauxStr: "2.1", key: "tva21" },
+ { val: t55, tauxStr: "5.5", key: "tva55" },
+ { val: t10, tauxStr: "10", key: "tva10" },
+ { val: t20, tauxStr: "20", key: "tva20" },
+ ].filter(t => t.val > 0);
+
+ if (tauxRemplis.length === 0) {
+ // Aucune TVA → ligne TTC pure (taux 0)
+ items.push({ taux: "0", montantTTC: row.ttc });
+ } else if (tauxRemplis.length === 1) {
+ // Un seul taux → format simple
+ items.push({ taux: tauxRemplis[0].tauxStr, montantTTC: row.ttc });
+ } else {
+ // ✅ Plusieurs taux → UN seul item avec breakdown
+ items.push({
+ taux: "MIXED",
+ montantTTC: row.ttc,
+ tvaBreakdown: {
+ ...(t21 > 0 && { tva21: row.tva21 }),
+ ...(t55 > 0 && { tva55: row.tva55 }),
+ ...(t10 > 0 && { tva10: row.tva10 }),
+ ...(t20 > 0 && { tva20: row.tva20 }),
+ },
+ });
+ }
});
- // Au minimum on passe le TTC total avec taux 0 si pas de TVA renseignée
+
if (items.length === 0) {
- const firstTTC = newRows[0]?.ttc || "";
- if (firstTTC) items.push({ taux: "0", montantTTC: firstTTC });
+ items.push({ taux: "20", montantTTC: "" });
}
- onUpdate(items.length ? items : [{ taux: "20", montantTTC: "" }]);
+
+ onUpdate(items);
};
const updateRow = (idx: number, field: keyof ExcelRow, val: string) => {
@@ -781,7 +907,6 @@ function TvaExcelTable({ tvaItems, onUpdate }: {
syncToParent(newRows);
};
- // Calculs totaux
const fmtN = (v: number) =>
v !== 0
? new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v)
@@ -947,6 +1072,17 @@ function TvaExcelTable({ tvaItems, onUpdate }: {
);
}
+// ── Helper — détecte si une dépense repas est de type "événementiel" ──────────
+function isRepasEvenementiel(libelle: string, description: string): boolean {
+ const PATTERN = /\b(ev[eè]nement|[eé]v[eè]nement|[eé]v[eè]nements)\b/i;
+ const START_PATTERN = /^even/i;
+ return (
+ PATTERN.test(libelle) ||
+ START_PATTERN.test(libelle.trim()) ||
+ PATTERN.test(description || "")
+ );
+}
+
// ── DEPENSE CARD ──────────────────────────────────────
const DepenseCard = React.memo(({
depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule
@@ -974,31 +1110,27 @@ const DepenseCard = React.memo(({
const tvaItems = depense.tvaItems?.length ? depense.tvaItems : [{ taux: "20", montantTTC: "" }];
- // Totaux pour l'en-tête de la carte
+ // ✅ Calcul TTC : 1 item = 1 TTC (plus de double-comptage)
const ttcTotal = isKm
? ind
: tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
- // Alerte repas > 25 € par personne
- const nbPersonnes = Math.max(1, (parseInt(depense.nombreParticipants) || 0) + 1); // +1 = la personne elle-même
+ // Calcul alerte repas
+ const nbPersonnes = Math.max(1, (parseInt(depense.nombreParticipants) || 0) + 1);
const ttcParPersonne = isRepas && ttcTotal > 0 ? ttcTotal / nbPersonnes : 0;
- const repasAlerte = isRepas && ttcParPersonne > 25;
+ // Détection repas événementiel — désactive l'alerte 25€
+ const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
+ const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
+
+ // ✅ Calcul HT — gestion du cas MIXED
const htTotal = isKm
? ind
- : tvaItems.reduce((s, item) => {
- const ttc = parseFloat(item.montantTTC) || 0;
- const taux = parseFloat(item.taux) || 0;
- return s + ttcToHt(ttc, taux);
- }, 0);
+ : tvaItems.reduce((s, item) => s + itemToHt(item), 0);
- const tvaTotal = tvaItems.reduce((s, item) => {
- const ttc = parseFloat(item.montantTTC) || 0;
- const taux = parseFloat(item.taux) || 0;
- return s + ttcToTva(ttc, taux);
- }, 0);
+ // ✅ Calcul TVA — gestion du cas MIXED
+ const tvaTotal = tvaItems.reduce((s, item) => s + itemToTva(item), 0);
- // Handlers TVA classique (Autre)
const updTva = (idx: number, field: keyof TvaItem, val: string) => {
const copy = tvaItems.map((it, i) => i === idx ? { ...it, [field]: val } : it);
set("tvaItems", copy);
@@ -1079,12 +1211,11 @@ const DepenseCard = React.memo(({
{expanded && (
- {/* Bandeau repas visible dès la sélection de la catégorie */}
{isRepas && (
🍽️
- Catégorie Repas sélectionnée — plafond 25 € / personne (sauf clause repas événementiel).
+ Catégorie Repas sélectionnée — plafond 25 € / personne (sauf repas événementiel).
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
@@ -1136,7 +1267,6 @@ const DepenseCard = React.memo(({
où vous choisirez le
« trajet le plus rapide » , en favorisant les trajets sans section à péage.
- {/* Cheval fiscal : depuis profil ou sélecteur manuel */}
{profilVehicule ? (
)}
- {/* ── TVA EXCEL — Repas / Hébergement / Transport ── */}
+ {/* ── TVA EXCEL ── */}
{!isKm && isExcel && (
)}
- {/* ── TVA CLASSIQUE — Autre ── */}
+ {/* ── NUITS (Hébergement) ── */}
+ {depense.categorie.toLowerCase().includes("hebergement") && (
+
+
+
🌙 Nombre de nuits
+ {ttcTotal > 0 && (parseInt(depense.nuits) || 0) > 0 && (
+
+ Soit
+ {fmt(ttcTotal / (parseInt(depense.nuits) || 1))}
+ / nuit
+
+ )}
+
+
+ set("nuits", String(Math.max(1, (parseInt(depense.nuits) || 1) - 1)))}>
+ −
+
+ set("nuits", e.target.value)}
+ placeholder="1"
+ />
+ set("nuits", String((parseInt(depense.nuits) || 0) + 1))}>
+ +
+
+
+ nuit{(parseInt(depense.nuits) || 0) > 1 ? "s" : ""}
+
+
+
+ )}
+
+ {/* ── TVA CLASSIQUE ── */}
{!isKm && !isExcel && (
@@ -1269,115 +1436,175 @@ const DepenseCard = React.memo(({
)}
- {/* ── REPAS — bandeau info + participants + alerte 25€ ── */}
- {isRepas && (
+ {/* ── REPAS — participants + alertes ── */}
+ {isRepas && (
🍽️ Participants
- {/* Bandeau info toujours visible dès que catégorie = Repas */}
ℹ️
- Un repas professionnel ne doit pas dépasser 25 € par personne (sauf clause repas événementiel).
- Indiquez le nombre de convives ci-dessous — le montant par personne est calculé automatiquement.
+ Un repas professionnel ne doit pas dépasser 25 € par personne (sauf repas événementiel).
- {/* Alerte si dépassement */}
- {repasAlerte && (
-
- ⚠️
-
- Plafond dépassé ! Le repas revient à{" "}
- {new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)} {" "}
- par personne ({nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""}).
- Seule la clause repas événementiel autorise le dépassement des 25 €.
-
-
- )}
-
- {/* Contrôle nombre de participants */}
-
-
- Nombre de convives invités{" "}
-
- (0 = repas seul — vous êtes toujours compté dans le total)
-
-
-
- {/* Bouton − */}
+ {/* ── Choix seul ou accompagné ── */}
+
+
Type de repas *
+
handleNombreParticipants(String(Math.max(0, (parseInt(depense.nombreParticipants) || 0) - 1)))}
- >−
-
- handleNombreParticipants(e.target.value)}
- placeholder="0"
- />
-
- {/* Bouton + */}
+ onClick={() => handleNombreParticipants("0")}
+ style={{
+ flex: 1, padding: "10px 14px", borderRadius: 8, cursor: "pointer",
+ fontFamily: "inherit", fontSize: 12, fontWeight: 700,
+ border: (parseInt(depense.nombreParticipants) === 0 && depense.nombreParticipants !== "")
+ ? "2px solid #d97706"
+ : "1.5px solid rgba(0,0,0,.12)",
+ background: (parseInt(depense.nombreParticipants) === 0 && depense.nombreParticipants !== "")
+ ? "rgba(251,191,36,.18)"
+ : "rgba(0,0,0,.02)",
+ color: (parseInt(depense.nombreParticipants) === 0 && depense.nombreParticipants !== "")
+ ? "#92400e"
+ : "var(--text-secondary, #6b7280)",
+ transition: "all .15s",
+ }}>
+ 🧑 Repas seul
+
handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 0) + 1))}
- >+
-
- {/* Résumé convives */}
- {(parseInt(depense.nombreParticipants) || 0) === 0 ? (
- 🧑 Repas seul
- ) : (
-
- {nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""} au total
- {ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
-
- )}
+ onClick={() => {
+ // Si on clique "accompagné" et qu'on était à 0 ou vide, mettre à 1
+ const current = parseInt(depense.nombreParticipants) || 0;
+ if (current === 0 || depense.nombreParticipants === "") {
+ handleNombreParticipants("1");
+ }
+ }}
+ style={{
+ flex: 1, padding: "10px 14px", borderRadius: 8, cursor: "pointer",
+ fontFamily: "inherit", fontSize: 12, fontWeight: 700,
+ border: (parseInt(depense.nombreParticipants) > 0)
+ ? "2px solid #d97706"
+ : "1.5px solid rgba(0,0,0,.12)",
+ background: (parseInt(depense.nombreParticipants) > 0)
+ ? "rgba(251,191,36,.18)"
+ : "rgba(0,0,0,.02)",
+ color: (parseInt(depense.nombreParticipants) > 0)
+ ? "#92400e"
+ : "var(--text-secondary, #6b7280)",
+ transition: "all .15s",
+ }}>
+ 👥 Accompagné
+
- {/* Liste des participants */}
- {depense.participants.length > 0 && (
-
-
-
Nom *
-
Prénom *
-
Société
-
-
- {depense.participants.map((p, i) => (
-
-
updP(i, "nom", e.target.value)}
- placeholder={`Nom ${i + 1} *`} />
-
updP(i, "prenom", e.target.value)}
- placeholder="Prénom *" />
-
updP(i, "societe", e.target.value)} placeholder="Société" />
- {/* + ajouter après cette ligne */}
-
{
- const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
- set("participants", newList);
- set("nombreParticipants", String(newList.length));
- }}>+
- {/* − supprimer cette ligne */}
-
{
- const newList = depense.participants.filter((_, j) => j !== i);
- set("participants", newList);
- set("nombreParticipants", String(newList.length));
- }}>−
+ {/* ── Si accompagné : sélecteur nombre + champs ── */}
+ {depense.nombreParticipants !== "" && parseInt(depense.nombreParticipants) > 0 && (
+ <>
+
+
+ Nombre de convives invités{" "}
+
+ (vous êtes toujours compté dans le total)
+
+
+
+ handleNombreParticipants(String(Math.max(1, (parseInt(depense.nombreParticipants) || 1) - 1)))}
+ >−
+
+ {
+ const v = Math.max(1, parseInt(e.target.value) || 1);
+ handleNombreParticipants(String(v));
+ }}
+ placeholder="1"
+ />
+
+ handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 1) + 1))}
+ >+
+
+
+ {nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""} au total
+ {ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
+
- ))}
+
+
+ {/* Alertes */}
+ {repasAlerte && (
+
+ ⚠️
+
+ Attention : ce repas revient à{" "}
+ {new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)} {" "}
+ par personne, au-delà du plafond de 25 €.
+ La soumission reste possible — mentionnez le contexte dans le commentaire.
+
+
+ )}
+ {isEvenementiel && ttcParPersonne > 25 && (
+
+ 🎉
+ Repas événementiel détecté — le plafond de 25 €/personne ne s'applique pas.
+
+ )}
+
+ {/* Champs participants */}
+
+
+
Nom *
+
Prénom *
+
Société
+
+
+ {depense.participants.map((p, i) => (
+
+ updP(i, "nom", e.target.value)}
+ placeholder={`Nom ${i + 1} *`} />
+ updP(i, "prenom", e.target.value)}
+ placeholder="Prénom *" />
+ updP(i, "societe", e.target.value)}
+ placeholder="Société" />
+ {
+ const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
+ set("participants", newList);
+ set("nombreParticipants", String(newList.length));
+ }}>+
+ {
+ const newList = depense.participants.filter((_, j) => j !== i);
+ if (newList.length === 0) {
+ handleNombreParticipants("0");
+ } else {
+ set("participants", newList);
+ set("nombreParticipants", String(newList.length));
+ }
+ }}>−
+
+ ))}
+
+ >
+ )}
+
+ {/* ── Si repas seul : badge confirmation ── */}
+
+
+ {/* ── Aucun choix encore ── */}
+ {depense.nombreParticipants === "" && (
+
+ ↑ Choisissez si vous étiez seul ou accompagné
)}
@@ -1490,11 +1717,12 @@ export default function NouvelleNote({
const [dateFin, setDateFin] = useState("");
const [commentaire, setComment] = useState(commentaireInitial || "");
const [depenses, setDepenses] = useState
(() => initDepenses(7));
- // Profil véhicule — chargé depuis l'API
const [profilVehicule, setProfilVehicule] = useState(null);
const [expandedId, setExpandedId] = useState(() => {
const init = initDepenses(); return init.length > 0 ? init[0].id : null;
});
+ const [selectedBrouillonIds, setSelectedBrouillonIds] = useState>(new Set());
+ const [deletingAll, setDeletingAll] = useState(false);
const [brouillons, setBrouillons] = useState([]);
const [activeBrouillonId, setActiveBrouillonId] = useState(initialBrouillonId);
@@ -1509,7 +1737,6 @@ export default function NouvelleNote({
const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef(initialBrouillonId);
- // ── Charger le profil véhicule depuis l'API au montage ──
useEffect(() => {
if (!authToken) return;
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
@@ -1519,7 +1746,6 @@ export default function NouvelleNote({
.then(data => {
if (data?.configured && data.vehicule) {
setProfilVehicule(data.vehicule);
- // Mettre à jour le cheval fiscal sur toutes les dépenses kilométriques
setDepenses(prev => prev.map(d => ({
...d,
chevaux: (d.categorie || "").toLowerCase().includes("kilom")
@@ -1528,7 +1754,7 @@ export default function NouvelleNote({
})));
}
})
- .catch(() => { /* profil véhicule non configuré — pas bloquant */ });
+ .catch(() => { });
}, [authToken, apiBaseUrl]);
useEffect(() => { if (depenses.length > 0 && expandedId === null) setExpandedId(depenses[0].id); }, []);
@@ -1573,12 +1799,65 @@ export default function NouvelleNote({
} catch { }
}, [apiBaseUrl, getHeaders]);
- const fetchBrouillons = useCallback(async () => {
- setLoadingBrouillons(true);
- try { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { headers: getHeaders() }); if (res.ok) setBrouillons(await res.json()); } catch { }
- setLoadingBrouillons(false);
- }, [apiBaseUrl, getHeaders]);
+
+ const handleDeleteSelected = async () => {
+ const ids = Array.from(selectedBrouillonIds);
+ if (ids.length === 0) return;
+ if (!window.confirm(`Supprimer ${ids.length} brouillon(s) ?`)) return;
+ setDeletingAll(true);
+ try {
+ await Promise.all(ids.map(id =>
+ fetch(`${apiBaseUrl}/api/notes/brouillons/${id}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` }
+ })
+ ));
+ if (activeBrouillonIdRef.current && ids.includes(activeBrouillonIdRef.current)) {
+ const d = newDepense();
+ setTitre(""); setDateDebut(""); setDateFin(""); setComment("");
+ setDepenses([d]); setExpandedId(d.id);
+ activeBrouillonIdRef.current = null;
+ setActiveBrouillonId(null);
+ isFirstRender.current = true;
+ }
+ setSelectedBrouillonIds(new Set());
+ await fetchBrouillons();
+ } catch { }
+ setDeletingAll(false);
+ };
+
+ const handleDeleteAll = async () => {
+ if (brouillons.length === 0) return;
+ if (!window.confirm(`Supprimer tous les ${brouillons.length} brouillons ?`)) return;
+ setDeletingAll(true);
+ try {
+ await Promise.all(brouillons.map(b =>
+ fetch(`${apiBaseUrl}/api/notes/brouillons/${b.id}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${authToken}` }
+ })
+ ));
+ const d = newDepense();
+ setTitre(""); setDateDebut(""); setDateFin(""); setComment("");
+ setDepenses([d]); setExpandedId(d.id);
+ activeBrouillonIdRef.current = null;
+ setActiveBrouillonId(null);
+ setShowBrouillonList(false);
+ isFirstRender.current = true;
+ setSelectedBrouillonIds(new Set());
+ await fetchBrouillons();
+ } catch { }
+ setDeletingAll(false);
+ };
+
+
+
+ const fetchBrouillons = useCallback(async () => {
+ setLoadingBrouillons(true);
+ try { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { headers: getHeaders() }); if (res.ok) setBrouillons(await res.json()); } catch { }
+ setLoadingBrouillons(false);
+ }, [apiBaseUrl, getHeaders]);
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
useEffect(() => {
@@ -1709,6 +1988,7 @@ export default function NouvelleNote({
setExpandedId(d.id); return;
}
}
+ // Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
}
}
setSubmitting(true);
@@ -1723,7 +2003,7 @@ export default function NouvelleNote({
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
};
- // Totaux sidebar — pour Excel on utilise le TTC saisi directement
+ // ✅ Calcul des totaux globaux — gestion du cas MIXED
const { totalTTC, totalHT, totalTVA } = useMemo(() => {
let ttc = 0, ht = 0;
depenses.forEach(d => {
@@ -1734,9 +2014,9 @@ export default function NouvelleNote({
} else {
d.tvaItems.forEach(item => {
const ttcVal = parseFloat(item.montantTTC) || 0;
- const taux = parseFloat(item.taux) || 0;
+ if (ttcVal <= 0) return;
ttc += ttcVal;
- ht += ttcToHt(ttcVal, taux);
+ ht += itemToHt(item);
});
}
});
@@ -1781,30 +2061,92 @@ export default function NouvelleNote({
)}
-
+
{loadingBrouillons ? "Chargement…" : `${brouillons.length} brouillon(s)`}
{!activeBrouillon && }
-
+
+ {/* Supprimer la sélection — visible si liste ouverte et items sélectionnés */}
+ {showBrouillonList && selectedBrouillonIds.size > 0 && (
+
+ {deletingAll ? "⏳" : "🗑"} Supprimer ({selectedBrouillonIds.size})
+
+ )}
+ {/* Tout supprimer — visible si aucune sélection active */}
+ {brouillons.length > 0 && selectedBrouillonIds.size === 0 && (
+
+ {deletingAll ? "⏳" : "🗑"} Tout supprimer
+
+ )}
{brouillons.length > 0 && (
- setShowBrouillonList(v => !v)}>
+ {
+ setShowBrouillonList(v => !v);
+ if (showBrouillonList) setSelectedBrouillonIds(new Set());
+ }}>
{showBrouillonList ? "▲ Réduire" : "▼ Voir"}
)}
+ Nouvelle note
+
+ {/* Ligne "Tout sélectionner" */}
+ {showBrouillonList && brouillons.length > 1 && (
+
+ 0}
+ ref={el => {
+ if (el) el.indeterminate =
+ selectedBrouillonIds.size > 0 && selectedBrouillonIds.size < brouillons.length;
+ }}
+ onChange={e => setSelectedBrouillonIds(
+ e.target.checked ? new Set(brouillons.map(b => b.id)) : new Set()
+ )}
+ style={{ width: 14, height: 14, cursor: "pointer" }}
+ />
+
+ Tout sélectionner
+
+ {selectedBrouillonIds.size > 0 && (
+
+ — {selectedBrouillonIds.size} sélectionné(s)
+
+ )}
+
+ )}
+
+ {/* Liste des brouillons */}
{showBrouillonList && brouillons.map(b => (
+
{
+ const next = new Set(selectedBrouillonIds);
+ e.target.checked ? next.add(b.id) : next.delete(b.id);
+ setSelectedBrouillonIds(next);
+ }}
+ onClick={e => e.stopPropagation()}
+ style={{ width: 14, height: 14, cursor: "pointer", flexShrink: 0 }}
+ />
{b.libelle || "Sans titre"}
{b.id === activeBrouillonId && EN COURS }
-
{new Date(b.DateModification).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })}
+
+ {new Date(b.DateModification).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })}
+
- {b.id !== activeBrouillonId && loadBrouillon(b)}>Ouvrir }
+ {b.id !== activeBrouillonId && (
+ loadBrouillon(b)}>Ouvrir
+ )}
handleDeleteBrouillon(b.id)}>🗑
@@ -1825,7 +2167,6 @@ export default function NouvelleNote({
{submitError &&
⚠️ {submitError}
}
- {/* ── META — 3 colonnes (Titre / Date / Commentaire) — montant déclaré supprimé ── */}
Titre *
@@ -1900,4 +2241,4 @@ export default function NouvelleNote({
>
);
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/ndf/src/pages/VerificateurFinanceLight.tsx b/ndf/src/pages/VerificateurFinanceLight.tsx
index ab8c60b..2ad84be 100644
--- a/ndf/src/pages/VerificateurFinanceLight.tsx
+++ b/ndf/src/pages/VerificateurFinanceLight.tsx
@@ -1,65 +1,74 @@
-import { useState, useEffect } from 'react';
-import { Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText, Car, Utensils, Package, MapPin } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import {
+ Eye, ChevronDown, ChevronUp, Clock, CheckCircle, XCircle, History, FileText,
+ AlertTriangle, Check, X, ShieldCheck, Paperclip, ListChecks, Receipt
+} from 'lucide-react';
-// ─── Types ────────────────────────────────────────────────────────────────────
interface Fichier { fileName: string; uploadUrl: string; }
+interface Participant { nom: string; prenom: string; societe?: string; }
+interface TvaItem { taux: string; montantTTC: string; }
interface LigneDepense {
qrNoteRef?: string; categorie?: string; libelle?: string;
date?: string; montant?: string; km?: string; chevaux?: string;
tauxTVA?: string; description?: string;
- nombreParticipants?: string; participants?: { nom: string; prenom: string; societe?: string }[];
- tvaItems?: { taux: string; montantTTC: string }[];
+ nombreParticipants?: string; participants?: Participant[];
+ tvaItems?: TvaItem[];
+ qrFiles?: { fileName: string; uploadUrl: string; origin?: string }[];
}
-
-interface NonConforme {
- fileName: string;
- motif: string;
- statut: string;
- dateSignalement: string;
-}
-
+interface NonConforme { fileName: string; motif: string; statut: string; dateSignalement: string; }
interface NoteAVerifier {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; date?: string; montant?: number;
statut?: string; lignesJson?: string; fichiers?: string;
nonConformes?: NonConforme[];
+ lignesRefusees?: { index: number; motif: string }[];
}
-interface JustifState { status: 'ok' | 'nok' | 'pending'; comment?: string; }
-interface ModalNok { noteId: number; fileKey: string; fileName: string; }
+interface ModalNok { noteId: number; ligneIndex: number; ligneLabel: string; }
interface VerifHistorique {
id: number; reference?: string; libelle?: string; collaborateur?: string;
campus?: string; departement?: string; montant?: number;
- dateVerification: string; commentaire?: string;
- nbJustifs: number; nbConformes: number; nbNonConformes: number;
+ dateVerification: string; commentaire?: string; statut?: 'VERIFIEE' | 'REFUSEE';
+ nbLignes: number; nbLignesOk: number; nbLignesRefusees: number;
lignesJson?: string; fichiers?: string;
- nonConformes?: NonConforme[];
+ lignesRefusees?: { index: number; motif: string }[];
}
+interface LigneState { status: 'ok' | 'refused' | 'pending'; motif?: string; }
-// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
-
const getIndemniteKm = (km: number, cv: number) => {
const BAREME: Record
= { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
};
-
-const buildKey = (noteId: number, scope: string, idx: number) => `${noteId}-${scope}-${idx}`;
-
+const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
const isApprovalFile = (f: Fichier) => {
const n = (f.fileName ?? '').toLowerCase();
return (n.includes('signe') || n.includes('signé')) && (n.includes('approuve') || n.includes('approuvé'));
};
-
const getCatMeta = (cat?: string) => {
const c = (cat || '').toLowerCase();
- if (c.includes('kilom')) return { icon: Car, color: '#7c3aed', bg: '#ede9fe', emoji: '🚗' };
- if (c.includes('repas') || c.includes('restaurant')) return { icon: Utensils, color: '#d97706', bg: '#fef3c7', emoji: '🍽️' };
- if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { icon: MapPin, color: '#0369a1', bg: '#e0f2fe', emoji: '🚆' };
- if (c.includes('hebergement') || c.includes('hotel')) return { icon: Package, color: '#059669', bg: '#dcfce7', emoji: '🏨' };
- return { icon: Package, color: '#6366f1', bg: '#eef2ff', emoji: '📋' };
+ if (c.includes('kilom')) return { color: '#7c3aed', bg: '#ede9fe', light: '#f5f3ff', emoji: '🚗', label: 'Kilométrique' };
+ if (c.includes('repas') || c.includes('restaurant')) return { color: '#d97706', bg: '#fef3c7', light: '#fffbeb', emoji: '🍽️', label: 'Repas' };
+ if (c.includes('transport') || c.includes('avion') || c.includes('train')) return { color: '#0369a1', bg: '#dbeafe', light: '#eff6ff', emoji: '🚆', label: 'Transport' };
+ if (c.includes('hebergement') || c.includes('hotel')) return { color: '#059669', bg: '#dcfce7', light: '#f0fdf4', emoji: '🏨', label: 'Hébergement' };
+ return { color: '#6366f1', bg: '#eef2ff', light: '#f5f3ff', emoji: '📋', label: 'Autre' };
+};
+
+// Group lines by category
+const groupByCategory = (lignes: LigneDepense[]) => {
+ const groups: Record = {};
+ lignes.forEach((l, i) => {
+ const cat = l.categorie || 'Autre';
+ if (!groups[cat]) groups[cat] = { cat, indices: [], total: 0 };
+ groups[cat].indices.push(i);
+ const isKm = cat.toLowerCase().includes('kilom');
+ const km = parseFloat(l.km || '0') || 0;
+ const cv = parseInt(l.chevaux || '7') || 7;
+ groups[cat].total += isKm ? getIndemniteKm(km, cv) : (parseFloat(l.montant || '0') || 0);
+ });
+ return Object.values(groups);
};
interface Props {
@@ -73,315 +82,553 @@ interface Props {
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
+*{box-sizing:border-box;}
+.vf-root{font-family:'DM Sans',system-ui,sans-serif;--accent:#5b21b6;--accent-bg:#ede9fe;--green:#15803d;--green-bg:#dcfce7;--red:#dc2626;--red-bg:#fee2e2;--amber:#d97706;--amber-bg:#fef3c7;--border:#e5e7eb;--border2:#f3f4f6;--muted:#6b7280;--text:#111827;--bg:#fff;--bg2:#f9fafb;--mono:'DM Mono',monospace;--panel-border:1px solid #e5e7eb;}
-.vf2-root {
- --purple:#5b21b6;--purple-light:#ede9fe;--purple-xlight:#f5f3ff;
- --green:#15803d;--green-light:#dcfce7;--red:#dc2626;--red-light:#fee2e2;
- --border:#e5e7eb;--muted:#6b7280;--text:#111827;--card:#ffffff;
- font-family:'DM Sans',system-ui,sans-serif;display:flex;flex-direction:column;gap:0;
-}
-.vf2-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:12px;padding:4px;margin-bottom:20px;}
-.vf2-tab{flex:1;padding:9px 16px;border:none;border-radius:9px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s;}
-.vf2-tab.active{background:#fff;color:var(--purple);box-shadow:0 1px 4px rgba(0,0,0,.1);}
-.vf2-tab-badge{background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:20px;}
-.vf2-tab.active .vf2-tab-badge{background:var(--purple);}
-.vf2-banner{display:flex;align-items:center;gap:14px;padding:14px 18px;background:linear-gradient(135deg,#f5f3ff,#ede9fe);border-radius:14px;border:1px solid #d8b4fe;margin-bottom:16px;}
-.vf2-banner-icon{width:40px;height:40px;border-radius:12px;background:var(--purple);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
-.vf2-banner-title{font-size:13px;font-weight:700;color:var(--purple);}
-.vf2-banner-sub{font-size:12px;color:#6d28d9;margin-top:2px;}
-.vf2-banner-count{margin-left:auto;background:var(--purple);color:#fff;font-size:13px;font-weight:700;padding:4px 14px;border-radius:20px;white-space:nowrap;}
-.vf2-card{background:var(--card);border-radius:14px;border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 6px rgba(0,0,0,.06);transition:box-shadow .2s;margin-bottom:12px;}
-.vf2-card:hover{box-shadow:0 4px 16px rgba(91,33,182,.1);}
-.vf2-card-trigger{width:100%;display:flex;align-items:center;padding:0;background:none;border:none;cursor:pointer;text-align:left;font-family:inherit;}
-.vf2-card-header{flex:1;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
-.vf2-card-chevron{padding:16px 18px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
-.vf2-ref-badge{font-size:11px;font-weight:700;color:var(--purple);background:var(--purple-light);padding:2px 9px;border-radius:20px;font-family:'DM Mono',monospace;display:inline-block;margin-bottom:5px;}
-.vf2-card-title{font-size:15px;font-weight:700;color:var(--text);}
-.vf2-card-meta{font-size:12px;color:var(--muted);margin-top:3px;}
-.vf2-card-amount{font-size:22px;font-weight:800;color:var(--purple);text-align:right;}
-.vf2-card-depcount{font-size:11px;color:var(--muted);text-align:right;margin-top:2px;}
-.vf2-progress-wrap{padding:10px 20px;border-top:1px solid #f3f4f6;background:var(--purple-xlight);}
-.vf2-progress-bar{height:5px;background:#e5e7eb;border-radius:99px;overflow:hidden;margin-top:6px;}
-.vf2-progress-fill{height:100%;border-radius:99px;transition:width .4s ease;}
-.vf2-pills{display:flex;gap:6px;flex-wrap:wrap;}
-.vf2-pill{font-size:10px;font-weight:700;padding:2px 9px;border-radius:20px;white-space:nowrap;}
-.vf2-pill-ok{background:var(--green-light);color:var(--green);}
-.vf2-pill-nok{background:var(--red-light);color:var(--red);}
-.vf2-pill-wait{background:#f3f4f6;color:var(--muted);}
-.vf2-body{padding:12px 16px 16px;display:flex;flex-direction:column;gap:12px;}
-.vf2-section-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;padding-bottom:6px;border-bottom:1px solid #f0f0f0;margin-bottom:8px;}
+/* ── Tabs ── */
+.vf-tabs{display:flex;gap:2px;background:#f3f4f6;border-radius:10px;padding:3px;margin-bottom:14px;}
+.vf-tab{flex:1;padding:7px 12px;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:600;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;gap:5px;transition:all .15s;}
+.vf-tab.active{background:#fff;color:var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.1);}
+.vf-tab-badge{background:#ef4444;color:#fff;font-size:9px;font-weight:700;padding:1px 5px;border-radius:20px;}
+.vf-tab.active .vf-tab-badge{background:var(--accent);}
-/* ── Bloc dépense ── */
-.vfc-bloc{border:1px solid rgba(0,0,0,.08);border-radius:10px;overflow:hidden;margin-bottom:0;}
-.vfc-bloc.all-ok{border-color:#86efac;}
-.vfc-bloc.has-nok{border-color:#fca5a5;}
-.vfc-dep-row{display:flex;align-items:center;gap:8px;padding:9px 12px;background:#f9fafb;}
-.vfc-num{width:20px;height:20px;border-radius:50%;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
-.vfc-icon{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
-.vfc-info{flex:1;min-width:0;}
-.vfc-label{font-size:12px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
-.vfc-sub{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
-.vfc-price{font-size:13px;font-weight:800;flex-shrink:0;font-family:'DM Mono',monospace;}
-.vfc-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
-.vfc-badge-km{background:#ede9fe;color:#7c3aed;}
-.vfc-badge-miss{background:#fee2e2;color:#dc2626;}
+/* ── Note card (collapsed) ── */
+.vf-note{border:1px solid var(--border);border-radius:11px;overflow:hidden;margin-bottom:9px;background:var(--bg);}
+.vf-note-trigger{width:100%;display:flex;align-items:stretch;background:none;border:none;cursor:pointer;padding:0;font-family:inherit;text-align:left;}
+.vf-note-bar{width:4px;flex-shrink:0;background:var(--accent);}
+.vf-note-hd{flex:1;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}
+.vf-note-chev{padding:0 13px;display:flex;align-items:center;border-left:1px solid var(--border);color:var(--muted);}
+.vf-ref{font-size:9px;font-weight:700;color:var(--accent);background:var(--accent-bg);padding:1px 7px;border-radius:20px;font-family:var(--mono);display:inline-block;margin-bottom:2px;}
+.vf-note-title{font-size:13px;font-weight:700;color:var(--text);}
+.vf-note-meta{font-size:10px;color:var(--muted);margin-top:1px;}
+.vf-note-amt{font-size:17px;font-weight:800;color:var(--accent);font-family:var(--mono);}
+.vf-note-sub{font-size:9px;color:var(--muted);text-align:right;}
-/* ── Justifs inline (toujours visibles) ── */
-.vfc-justifs{border-top:1px solid #f0f0f0;display:flex;flex-direction:column;}
-.vfc-jrow{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid #f5f5f5;}
-.vfc-jrow:last-child{border-bottom:none;}
-.vfc-jrow.ok{background:#f0fdf4;}
-.vfc-jrow.nok{background:#fff5f5;}
-.vfc-jrow.pending{background:#fff;}
-.vfc-jicon{font-size:14px;flex-shrink:0;}
-.vfc-jinfo{flex:1;min-width:0;}
-.vfc-jname{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
-.vfc-jname.ok{color:var(--green);}
-.vfc-jname.nok{color:var(--red);}
-.vfc-jname.pending{color:var(--text);}
-.vfc-jreason{font-size:10px;color:var(--red);font-style:italic;margin-top:1px;}
-.vfc-jstatus{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;flex-shrink:0;}
-.vfc-jstatus.ok{background:var(--green-light);color:var(--green);}
-.vfc-jstatus.nok{background:var(--red-light);color:var(--red);}
-.vfc-jstatus.pending{background:#f3f4f6;color:var(--muted);}
-.vfc-no-km{font-size:11px;color:var(--green);background:var(--green-light);padding:6px 12px;font-weight:600;}
+/* ── Progress strip ── */
+.vf-prog-strip{padding:4px 14px 6px;border-top:1px solid var(--border2);background:#f5f3ff;display:flex;align-items:center;gap:8px;}
+.vf-prog-track{flex:1;height:3px;background:#e5e7eb;border-radius:99px;overflow:hidden;}
+.vf-prog-fill{height:100%;border-radius:99px;transition:width .3s;}
+.vf-pills{display:flex;gap:3px;flex-wrap:wrap;}
+.vf-pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:20px;white-space:nowrap;}
+.vf-pill-ok{background:var(--green-bg);color:var(--green);}
+.vf-pill-nok{background:var(--red-bg);color:var(--red);}
+.vf-pill-wait{background:#f3f4f6;color:var(--muted);}
+.vf-pill-adj{background:var(--amber-bg);color:var(--amber);}
-/* Boutons */
-.vf2-btn-voir{display:flex;align-items:center;gap:3px;padding:4px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:5px;cursor:pointer;font-family:inherit;font-size:10px;font-weight:600;color:#374151;flex-shrink:0;white-space:nowrap;}
-.vf2-btn-voir:hover{background:#f0f0f0;}
-.vf2-btn-check{width:26px;height:26px;border-radius:6px;flex-shrink:0;border:1.5px solid;cursor:pointer;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:all .12s;background:none;}
-.vf2-btn-ok-on{border-color:#16a34a;background:#f0fdf4 !important;color:#16a34a;}
-.vf2-btn-ok-off{border-color:#d1d5db;color:#d1d5db;}
-.vf2-btn-nok-on{border-color:var(--red);background:#fff5f5 !important;color:var(--red);}
-.vf2-btn-nok-off{border-color:#d1d5db;color:#d1d5db;}
+/* ── 5-panel grid ── */
+.vf-4grid{display:grid;grid-template-columns:175px 155px 200px 1fr 205px;border-top:1px solid var(--border);min-height:520px;}
+.vf-panel{border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
+.vf-panel:last-child{border-right:none;}
+.vf-panel-hd{padding:7px 10px;border-bottom:1px solid var(--border);background:var(--bg2);display:flex;align-items:center;gap:5px;flex-shrink:0;}
+.vf-panel-label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);}
+.vf-panel-body{padding:8px;flex:1;display:flex;flex-direction:column;gap:6px;overflow-y:auto;}
-/* Approbation */
-.vf2-approval-box{border:1px solid #d8b4fe;border-radius:10px;overflow:hidden;}
-.vf2-approval-head{display:flex;align-items:center;gap:10px;padding:9px 14px;background:linear-gradient(90deg,#f5f3ff,#ede9fe);border-bottom:1px solid #e9d5ff;}
-.vf2-approval-label{font-size:12px;font-weight:700;color:var(--purple);}
-.vf2-approval-files{padding:6px 10px;display:flex;flex-direction:column;gap:4px;}
+/* ── Panel 3: compact ligne list ── */
+.vf-lrow{display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg);transition:all .12s;}
+.vf-lrow:hover{border-color:#c4b5fd;background:#faf5ff;}
+.vf-lrow.active{border-color:#7c3aed;background:#f5f3ff;box-shadow:0 0 0 2px rgba(124,58,237,.1);}
+.vf-lrow.ok{border-color:#86efac;background:#f0fdf4;}
+.vf-lrow.ok.active{border-color:#16a34a;box-shadow:0 0 0 2px rgba(22,163,74,.1);}
+.vf-lrow.refused{border-color:#fca5a5;background:#fff5f5;}
+.vf-lrow.refused.active{border-color:#dc2626;box-shadow:0 0 0 2px rgba(220,38,38,.1);}
+.vf-lrow-num{width:20px;height:20px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;flex-shrink:0;}
+.vf-lrow-body{flex:1;min-width:0;}
+.vf-lrow-name{font-size:10px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-lrow-meta{font-size:8px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-lrow-right{display:flex;flex-direction:column;align-items:flex-end;flex-shrink:0;gap:2px;}
+.vf-lrow-price{font-size:10px;font-weight:800;font-family:var(--mono);}
+.vf-lrow-arrow{font-size:9px;color:var(--muted);}
-/* Footer */
-.vf2-footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 16px;border-top:1.5px solid #f3f4f6;background:var(--purple-xlight);}
-.vf2-footer-msg{font-size:12px;color:var(--muted);}
-.vf2-footer-msg.ok{color:var(--green);font-weight:700;}
-.vf2-footer-msg.nok{color:var(--red);font-weight:700;}
-.vf2-btn-submit{padding:10px 20px;background:var(--purple);color:#fff;border:none;border-radius:9px;font-family:inherit;font-size:13px;font-weight:700;white-space:nowrap;box-shadow:0 4px 14px rgba(91,33,182,.3);cursor:pointer;transition:all .15s;}
-.vf2-btn-submit:hover:not(:disabled){background:#4c1d95;transform:translateY(-1px);}
-.vf2-btn-submit:disabled{opacity:.35;cursor:not-allowed;}
+/* ── Panel 4: ligne detail ── */
+.vf-detail-hd{display:flex;align-items:center;gap:8px;padding:10px 11px;border-bottom:1px solid var(--border);}
+.vf-detail-num{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;}
+.vf-detail-title{font-size:12px;font-weight:700;color:var(--text);flex:1;}
+.vf-detail-price{font-size:14px;font-weight:800;font-family:var(--mono);flex-shrink:0;}
+.vf-detail-section{border-bottom:1px solid var(--border2);}
+.vf-detail-section-hd{padding:5px 11px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:var(--bg2);}
+.vf-detail-section-body{padding:8px 11px;display:flex;flex-direction:column;gap:5px;}
+.vf-detail-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;}
+.vf-detail-row-lbl{color:var(--muted);}
+.vf-detail-row-val{font-weight:700;font-family:var(--mono);color:var(--text);}
+.vf-detail-alert{margin:8px 11px;padding:6px 9px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;font-size:9px;color:#92400e;font-weight:600;display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
+.vf-detail-justif-file{display:flex;align-items:center;gap:7px;padding:7px 11px;border-bottom:1px solid var(--border2);cursor:pointer;}
+.vf-detail-justif-file:last-child{border-bottom:none;}
+.vf-detail-justif-file:hover{background:var(--bg2);}
+.vf-detail-justif-icon{width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg2);border:1px solid var(--border);flex-shrink:0;}
+.vf-detail-justif-name{flex:1;font-size:10px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-detail-justif-btn{padding:3px 8px;font-size:9px;font-weight:700;border-radius:4px;border:1px solid var(--border);background:var(--bg);cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:3px;color:#374151;flex-shrink:0;}
+.vf-detail-justif-btn:hover{background:#f3f4f6;}
+.vf-detail-actions{padding:8px 11px;display:flex;gap:6px;border-top:1px solid var(--border);background:var(--bg2);}
+.vf-detail-missing{padding:10px 11px;display:flex;align-items:center;gap:6px;font-size:10px;color:var(--red);font-weight:600;}
+.vf-detail-km{margin:8px 11px;padding:7px 10px;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:7px;font-size:9px;color:#5b21b6;display:flex;flex-direction:column;gap:3px;}
+.vf-detail-km-row{display:flex;justify-content:space-between;align-items:center;}
+.vf-detail-km-lbl{color:#7c3aed;font-weight:600;}
+.vf-detail-km-val{font-weight:800;font-family:var(--mono);color:#4c1d95;}
-.vf2-modal-backdrop{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);padding:1rem;}
-.vf2-modal{background:#fff;border-radius:16px;width:100%;max-width:460px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.18);}
-.vf2-empty{background:#fafafa;border:1px solid var(--border);border-radius:14px;padding:64px 24px;text-align:center;}
-.vf2-hist-card{background:#fff;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);}
-.vf2-hist-head{padding:14px 18px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;background:#fafafa;border-bottom:1px solid #f0f0f0;}
-.vf2-hist-body{padding:12px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;}
-.vf2-hist-stat{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;}
-.vf2-hist-comment{font-size:12px;color:var(--muted);font-style:italic;padding:8px 11px;background:#f9fafb;border-radius:8px;border:1px solid #f0f0f0;margin-top:6px;}
+/* ── Panel 1: Note summary ── */
+.vf-summary{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
+.vf-summary-top{height:3px;background:linear-gradient(90deg,#7c3aed,#a78bfa);}
+.vf-summary-body{padding:9px 11px;}
+.vf-sum-price-lbl{font-size:9px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
+.vf-sum-price{font-size:20px;font-weight:800;font-family:var(--mono);color:var(--text);line-height:1.1;}
+.vf-sum-price.adj{color:var(--green);}
+.vf-sum-adj{font-size:9px;color:var(--green);font-weight:600;margin-top:1px;}
+.vf-sum-div{height:1px;background:var(--border);margin:7px 0;}
+.vf-sum-meta{font-size:10px;color:var(--muted);line-height:1.7;}
+.vf-sum-prog{padding:5px 11px 9px;}
+.vf-appro{border:1px solid #d8b4fe;border-radius:7px;overflow:hidden;margin-top:6px;}
+.vf-appro-hd{display:flex;align-items:center;gap:5px;padding:5px 9px;background:#f5f3ff;border-bottom:1px solid #e9d5ff;font-size:9px;font-weight:700;color:var(--accent);}
+.vf-appro-body{padding:4px 7px;display:flex;flex-direction:column;gap:3px;}
+
+/* ── Panel 2: Category list ── */
+.vf-cat-item{border:1px solid var(--border);border-radius:7px;overflow:hidden;cursor:pointer;transition:all .15s;background:var(--bg);}
+.vf-cat-item:hover{border-color:#c4b5fd;background:#faf5ff;}
+.vf-cat-item.active{border-color:#7c3aed;background:#f5f3ff;box-shadow:0 0 0 2px rgba(124,58,237,.12);}
+.vf-cat-hd{display:flex;align-items:center;gap:7px;padding:7px 9px;}
+.vf-cat-emoji{font-size:14px;flex-shrink:0;}
+.vf-cat-info{flex:1;min-width:0;}
+.vf-cat-name{font-size:10px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-cat-sub{font-size:9px;color:var(--muted);}
+.vf-cat-right{text-align:right;flex-shrink:0;}
+.vf-cat-amt{font-size:11px;font-weight:800;font-family:var(--mono);}
+.vf-cat-progress{height:2px;background:var(--border2);}
+.vf-cat-progress-fill{height:100%;border-radius:0 0 7px 7px;transition:width .3s;}
+.vf-cat-status-strip{display:flex;gap:2px;padding:3px 9px;border-top:1px solid var(--border2);flex-wrap:wrap;}
+
+/* ── Panel 3: Ligne detail ── */
+.vf-empty-panel{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:24px;color:var(--muted);text-align:center;}
+.vf-ligne-card{border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--bg);}
+.vf-ligne-card.ok{border-color:#86efac;background:#f0fdf4;}
+.vf-ligne-card.refused{border-color:#fca5a5;background:#fff5f5;}
+.vf-ligne-hd{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid rgba(0,0,0,.06);}
+.vf-ligne-num{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;flex-shrink:0;}
+.vf-ligne-name{font-size:11px;font-weight:700;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-ligne-date{font-size:9px;color:var(--muted);flex-shrink:0;}
+.vf-ligne-price{font-size:13px;font-weight:800;font-family:var(--mono);flex-shrink:0;}
+.vf-ligne-details{padding:5px 10px;display:flex;gap:10px;flex-wrap:wrap;border-bottom:1px solid rgba(0,0,0,.04);background:rgba(0,0,0,.015);}
+.vf-det{display:flex;align-items:center;gap:3px;}
+.vf-det-l{font-size:9px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;}
+.vf-det-v{font-size:9px;font-weight:700;color:var(--text);font-family:var(--mono);}
+.vf-ligne-desc{padding:4px 10px;font-size:9px;color:#78350f;font-style:italic;background:#fffbeb;border-left:2px solid #f59e0b;border-bottom:1px solid #fde68a;}
+.vf-ligne-participants{padding:4px 10px;display:flex;flex-wrap:wrap;gap:3px;align-items:center;border-bottom:1px solid rgba(0,0,0,.04);}
+.vf-ligne-justifs{padding:5px 10px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;border-bottom:1px solid rgba(0,0,0,.04);}
+.vf-jlabel{font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;flex-shrink:0;}
+.vf-jbtn{display:flex;align-items:center;gap:3px;padding:2px 7px;background:var(--bg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-family:inherit;font-size:9px;font-weight:600;color:#374151;white-space:nowrap;}
+.vf-jbtn:hover{background:#f3f4f6;}
+.vf-jkm{font-size:9px;color:var(--accent);font-weight:600;}
+.vf-jmissing{font-size:9px;color:var(--red);font-weight:600;}
+.vf-ligne-alert{display:flex;align-items:center;gap:5px;padding:4px 10px;background:#fffbeb;border-left:2px solid #f59e0b;border-bottom:1px solid #fde68a;font-size:9px;color:#92400e;font-weight:600;flex-wrap:wrap;}
+.vf-prorata-btn{padding:1px 7px;border-radius:4px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:9px;font-weight:700;border:1px solid #6366f1;color:#6366f1;background:var(--bg);}
+.vf-prorata-restore{padding:1px 7px;border-radius:4px;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:9px;font-weight:700;border:1px solid #d1d5db;color:var(--muted);background:var(--bg);}
+.vf-prorata-result{padding:3px 10px;font-size:9px;color:var(--green);font-weight:600;background:#f0fdf4;border-bottom:1px solid #86efac;display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
+.vf-ligne-actions{display:flex;align-items:center;gap:5px;padding:5px 10px;background:rgba(0,0,0,.015);}
+.vf-status{flex:1;font-size:9px;font-weight:700;display:flex;align-items:center;gap:3px;}
+.vf-status.ok{color:var(--green);}
+.vf-status.refused{color:var(--red);}
+.vf-status.pending{color:var(--muted);}
+.vf-motif{flex:1;font-size:9px;font-style:italic;color:var(--red);background:#fff5f5;border:1px dashed #fca5a5;padding:2px 6px;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-btn-edit{border:none;background:none;color:var(--accent);font-family:inherit;font-size:9px;font-weight:600;cursor:pointer;padding:1px 4px;text-decoration:underline;}
+.vf-btn-ok{padding:3px 9px;font-family:'DM Sans',sans-serif;font-size:10px;font-weight:700;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:3px;border:1.5px solid #16a34a;color:#16a34a;background:var(--bg);transition:all .12s;}
+.vf-btn-ok:hover{background:#f0fdf4;}
+.vf-btn-ok.active{background:#16a34a;color:#fff;}
+.vf-btn-refuse{padding:3px 9px;font-family:'DM Sans',sans-serif;font-size:10px;font-weight:700;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:3px;border:1.5px solid var(--red);color:var(--red);background:var(--bg);transition:all .12s;}
+.vf-btn-refuse:hover{background:#fff5f5;}
+.vf-btn-refuse.active{background:var(--red);color:#fff;}
+
+/* ── Panel 4: Decision ── */
+.vf-dec{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
+.vf-dec-hd{padding:5px 9px;background:var(--bg2);border-bottom:1px solid var(--border);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;align-items:center;gap:4px;}
+.vf-dec-body{padding:6px 8px;display:flex;flex-direction:column;gap:4px;}
+.vf-ls-row{display:flex;align-items:center;gap:5px;padding:3px 7px;border-radius:4px;border:1px solid var(--border);}
+.vf-ls-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0;}
+.vf-ls-label{flex:1;font-size:9px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-ls-tag{font-size:8px;font-weight:700;padding:1px 5px;border-radius:20px;white-space:nowrap;}
+.vf-ls-tag.ok{background:var(--green-bg);color:var(--green);}
+.vf-ls-tag.wait{background:var(--bg2);color:var(--muted);}
+.vf-ls-tag.refused{background:var(--red-bg);color:var(--red);}
+.vf-ls-tag.adj{background:var(--amber-bg);color:var(--amber);}
+.vf-dec-total{display:flex;justify-content:space-between;align-items:center;padding:6px 9px;background:var(--bg2);border-top:1px solid var(--border);}
+.vf-dec-total-lbl{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);}
+.vf-dec-total-val{font-size:13px;font-weight:800;font-family:var(--mono);}
+.vf-dec-final{padding:7px 9px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
+.vf-dec-hint{font-size:10px;color:var(--muted);}
+.vf-dec-hint.ok{color:var(--green);font-weight:600;}
+.vf-dec-hint.nok{color:var(--red);font-weight:600;}
+.vf-btn-validate{width:100%;padding:7px 0;background:var(--green);color:#fff;border:none;border-radius:6px;font-family:'DM Sans',sans-serif;font-size:11px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;}
+.vf-btn-validate:hover:not(:disabled){background:#166534;}
+.vf-btn-validate:disabled{opacity:.4;cursor:not-allowed;}
+.vf-btn-reject{width:100%;padding:7px 0;background:var(--bg);color:var(--red);border:1.5px solid var(--red);border-radius:6px;font-family:'DM Sans',sans-serif;font-size:11px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;}
+.vf-btn-reject:hover:not(:disabled){background:var(--red-bg);}
+.vf-btn-reject:disabled{opacity:.4;cursor:not-allowed;}
+.vf-notify{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted);cursor:pointer;}
+.vf-jcard{border:1px solid var(--border);border-radius:7px;overflow:hidden;}
+.vf-jcard-hd{padding:5px 9px;background:var(--bg2);border-bottom:1px solid var(--border);font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;align-items:center;gap:4px;}
+.vf-jfile{display:flex;align-items:center;gap:5px;padding:5px 9px;border-bottom:1px solid var(--border);}
+.vf-jfile:last-child{border-bottom:none;}
+.vf-jfile-icon{width:22px;height:22px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--bg2);flex-shrink:0;}
+.vf-jfile-name{font-size:9px;flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.vf-jfile-see{font-size:9px;font-weight:600;color:var(--muted);border:1px solid var(--border);background:var(--bg);border-radius:3px;padding:1px 6px;cursor:pointer;font-family:inherit;white-space:nowrap;}
+
+/* ── History ── */
+.vf-banner{display:flex;align-items:center;gap:9px;padding:9px 13px;border-radius:9px;border:1px solid;margin-bottom:11px;}
+.vf-hist-card{background:var(--bg);border:1px solid var(--border);border-radius:9px;overflow:hidden;margin-bottom:7px;}
+.vf-hist-hd{padding:9px 13px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:7px;background:var(--bg2);border-bottom:1px solid var(--border2);}
+.vf-hist-body{padding:7px 13px;display:flex;gap:7px;flex-wrap:wrap;align-items:center;}
+.vf-hist-stat{display:flex;align-items:center;gap:4px;font-size:10px;font-weight:600;}
+.vf-hist-comment{font-size:9px;color:var(--muted);font-style:italic;padding:4px 8px;background:var(--bg2);border-radius:5px;border:1px solid var(--border2);margin-top:3px;}
+.vf-hist-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;}
+.vf-hist-badge.ok{background:var(--green-bg);color:var(--green);}
+.vf-hist-badge.refused{background:var(--red-bg);color:var(--red);}
+.vf-empty{background:var(--bg2);border:1px solid var(--border);border-radius:11px;padding:44px 24px;text-align:center;}
+
+/* ── Modals ── */
+.vf-modal-bg{position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:1rem;}
+.vf-modal{background:var(--bg);border-radius:13px;width:100%;max-width:450px;border:1px solid var(--border);overflow:hidden;box-shadow:0 20px 56px rgba(0,0,0,.18);}
+.vf-mnt-modal{background:var(--bg);border-radius:15px;width:100%;max-width:410px;overflow:hidden;box-shadow:0 26px 65px rgba(0,0,0,.2);}
+.vf-mnt-hd{padding:16px 20px 13px;background:#6366f1;color:#fff;}
+.vf-mnt-hd-icon{width:38px;height:38px;border-radius:9px;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:17px;margin-bottom:9px;}
+.vf-mnt-hd-title{font-size:14px;font-weight:800;margin-bottom:2px;}
+.vf-mnt-hd-sub{font-size:10px;opacity:.85;}
+.vf-mnt-body{padding:16px 20px;}
+.vf-mnt-recap{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:14px;}
+.vf-mnt-recap-item{background:var(--bg2);border:1px solid var(--border);border-radius:7px;padding:7px 9px;}
+.vf-mnt-recap-lbl{font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px;}
+.vf-mnt-recap-val{font-size:13px;font-weight:800;font-family:var(--mono);}
+.vf-mnt-input-wrap{position:relative;margin-bottom:5px;}
+.vf-mnt-input{width:100%;padding:11px 40px 11px 13px;font-size:17px;font-weight:800;font-family:var(--mono);border:2px solid #6366f1;border-radius:7px;outline:none;color:var(--text);background:var(--bg);box-sizing:border-box;}
+.vf-mnt-input:focus{box-shadow:0 0 0 3px rgba(99,102,241,.15);}
+.vf-mnt-input.error{border-color:#ef4444;}
+.vf-mnt-currency{position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:15px;font-weight:800;color:#6366f1;pointer-events:none;}
+.vf-mnt-hint{font-size:10px;color:var(--muted);margin-bottom:13px;}
+.vf-mnt-hint.error{color:#ef4444;font-weight:600;}
+.vf-mnt-preset{padding:3px 9px;background:#eef2ff;color:#6366f1;border:1px solid #c7d2fe;border-radius:20px;cursor:pointer;font-size:10px;font-weight:700;font-family:inherit;margin-bottom:14px;display:inline-block;}
+.vf-mnt-preset:hover,.vf-mnt-preset.active{background:#6366f1;color:#fff;}
+.vf-mnt-footer{padding:10px 20px;border-top:1px solid var(--border);display:flex;gap:7px;justify-content:flex-end;background:var(--bg2);}
+.vf-mnt-btn-cancel{padding:7px 14px;background:var(--bg);border:1.5px solid var(--border);border-radius:7px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--muted);}
+.vf-mnt-btn-confirm{padding:7px 16px;background:#6366f1;color:#fff;border:none;border-radius:7px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:700;display:flex;align-items:center;gap:4px;}
+.vf-mnt-btn-confirm:disabled{opacity:.4;cursor:not-allowed;}
+
+/* ── All-lines bulk validation strip ── */
+.vf-bulk-strip{display:flex;gap:5px;padding:5px 8px;border-top:1px solid var(--border2);background:#f5f3ff;flex-wrap:wrap;}
+.vf-bulk-btn{padding:3px 10px;font-size:9px;font-weight:700;border-radius:4px;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:3px;}
+.vf-bulk-ok{background:#f0fdf4;color:var(--green);border:1.5px solid #86efac;}
+.vf-bulk-ok:hover{background:#dcfce7;}
`;
-export default function VerificateurFinanceLight({
+export default function VerificateurFinance4Panels({
notesAVerifier: initialNotes, onVerified, API, hdrs,
proxyUrl = u => u, setPreviewUrl,
}: Props) {
const [notes, setNotes] = useState(initialNotes);
- const [justifStates, setJustifStates] = useState>({});
+ const [ligneStates, setLigneStates] = useState>({});
const [modalNok, setModalNok] = useState(null);
const [nokReason, setNokReason] = useState('');
- const [nokSending, setNokSending] = useState(false);
- const [notifyOnNok, setNotifyOnNok] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [notifyOnReject, setNotifyOnReject] = useState(true);
const [expandedNotes, setExpandedNotes] = useState>(new Set());
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [history, setHistory] = useState([]);
const [historyLoaded, setHistoryLoaded] = useState(false);
- const [expandedHistory, setExpandedHistory] = useState>(new Set());
- const toggleHistory = (id: number) => setExpandedHistory(prev => {
- const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
- });
- // ✅ Restaurer les états non-conformes depuis la BDD au chargement
+
+ const [montantsModifies, setMontantsModifies] = useState>({});
+ const [modalCommentaire, setModalCommentaire] = useState<{
+ noteId: number; lignesData: LigneDepense[];
+ modifs: { ligneIndex: number; montantOriginal: number; montantRetenu: number }[];
+ } | null>(null);
+ const [commentaireInput, setCommentaireInput] = useState('');
+ const [modalMontant, setModalMontant] = useState<{
+ noteId: number; ligneIndex: number; ligneLabel: string;
+ montantOriginal: number; montantProrataMax: number; valeurSaisie: string;
+ } | null>(null);
+
+ // Panel 2 → 3: selected category per note
+ const [selectedCat, setSelectedCat] = useState>({});
+ // Panel 3 → 4: selected ligne index per note
+ const [selectedLigne, setSelectedLigne] = useState>({});
+
useEffect(() => {
setNotes(initialNotes);
-
- if (!initialNotes.length) return;
-
- const newStates: Record = {};
-
- for (const note of initialNotes) {
- const ncs = note.nonConformes;
- if (!ncs || ncs.length === 0) continue;
-
- let tousLesFichiers: Fichier[] = [];
- try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
-
- const justificatifsReels = tousLesFichiers.filter(f =>
- !SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw))
- );
-
- let lignesData: LigneDepense[] = [];
- try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
-
- const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
- const fichiersGlobaux = allQrRefs.length > 0
- ? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
- : justificatifsReels;
-
- for (const nc of ncs) {
- // Chercher dans les fichiers globaux (distribués par ligne)
- const idxGlobal = fichiersGlobaux.findIndex(f => f.fileName === nc.fileName);
- if (idxGlobal !== -1) {
- for (let li = 0; li < lignesData.length; li++) {
- const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
- const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
- if (idxGlobal >= start && idxGlobal < end) {
- newStates[buildKey(note.id, `global${li}`, idxGlobal - start)] = { status: 'nok', comment: nc.motif };
- break;
- }
- }
- continue;
- }
-
- // Chercher dans les fichiers par ligne (scope ligne{li})
- for (let li = 0; li < lignesData.length; li++) {
- const qr = lignesData[li].qrNoteRef || '';
- if (!qr) continue;
- const filesLigne = justificatifsReels.filter(f =>
- f.fileName?.includes(qr) || f.uploadUrl?.includes(qr)
- );
- const idxLigne = filesLigne.findIndex(f => f.fileName === nc.fileName);
- if (idxLigne !== -1) {
- newStates[buildKey(note.id, `ligne${li}`, idxLigne)] = { status: 'nok', comment: nc.motif };
- break;
- }
- }
-
- // Chercher dans les fichiers d'approbation
- const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
- const idxApprob = fichierApprobation.findIndex(f => f.fileName === nc.fileName);
- if (idxApprob !== -1) {
- newStates[buildKey(note.id, 'approbation', idxApprob)] = { status: 'nok', comment: nc.motif };
- }
- }
- }
-
- if (Object.keys(newStates).length > 0) {
- setJustifStates(prev => ({ ...prev, ...newStates }));
- }
+ const s: Record = {};
+ for (const note of initialNotes)
+ (note.lignesRefusees || []).forEach(r => { s[ligneKey(note.id, r.index)] = { status: 'refused', motif: r.motif }; });
+ if (Object.keys(s).length) setLigneStates(prev => ({ ...prev, ...s }));
}, [initialNotes]);
- const toggleNote = (id: number) => setExpandedNotes(prev => {
- const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
- });
- const setJustif = (key: string, status: JustifState['status'], comment?: string) =>
- setJustifStates(prev => ({ ...prev, [key]: { status, comment } }));
- const openNok = (noteId: number, fileKey: string, fileName: string) => {
- setModalNok({ noteId, fileKey, fileName });
- setNokReason(justifStates[fileKey]?.comment || '');
+ const toggleNote = (id: number) => setExpandedNotes(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
+ const setLigne = (key: string, status: LigneState['status'], motif?: string) => setLigneStates(prev => ({ ...prev, [key]: { status, motif } }));
+ const openRefuse = (noteId: number, ligneIndex: number, ligneLabel: string) => {
+ setModalNok({ noteId, ligneIndex, ligneLabel });
+ setNokReason(ligneStates[ligneKey(noteId, ligneIndex)]?.motif || '');
};
const loadHistory = async () => {
if (historyLoaded) return;
- try {
- const res = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs });
- if (res.ok) { const d = await res.json(); if (Array.isArray(d)) setHistory(d); }
- } catch { }
+ try { const r = await fetch(`${API}/api/verificateur/historique`, { headers: hdrs }); if (r.ok) { const d = await r.json(); if (Array.isArray(d)) setHistory(d); } } catch { }
setHistoryLoaded(true);
};
- const handleTabChange = (tab: 'pending' | 'history') => {
- setActiveTab(tab);
- if (tab === 'history') loadHistory();
+ const handleTabChange = (tab: 'pending' | 'history') => { setActiveTab(tab); if (tab === 'history') loadHistory(); };
+
+ const getFilesForLigne = (l: LigneDepense, li: number, jr: Fichier[], fg: Fichier[], qrRefs: string[], all: LigneDepense[]): Fichier[] => {
+ if (l.qrFiles?.length) return l.qrFiles.map(f => ({ fileName: f.fileName, uploadUrl: f.uploadUrl }));
+ const ref = l.qrNoteRef || '';
+ if (ref) { const found = jr.filter(f => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)); if (found.length) return found; }
+ if (!qrRefs.length) return fg.slice(Math.floor(li * fg.length / all.length), Math.floor((li + 1) * fg.length / all.length));
+ return [];
};
- // ── Justificatif inline toujours visible ─────────────────────────────────
- const renderJRow = (f: Fichier, stateKey: string, noteId: number) => {
- const state = justifStates[stateKey] ?? { status: 'pending' };
- const cls = state.status;
- const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
- const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
- const label = cls === 'ok' ? 'Conforme' : cls === 'nok' ? 'Non conforme' : 'En attente';
- return (
-
-
{isImg ? '🖼️' : '📄'}
-
-
{short}
- {cls === 'nok' && state.comment &&
↳ {state.comment}
}
+ const submitValidation = async (noteId: number, lignesData: LigneDepense[], modifs: { ligneIndex: number; montantOriginal: number; montantRetenu: number }[], commentaire: string) => {
+ setSubmitting(true);
+ try {
+ const r = await fetch(`${API}/api/verificateur/notes/${noteId}/verifier`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire, montantsModifies: modifs }) });
+ const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Erreur');
+ onVerified(noteId); setNotes(prev => prev.filter(n => n.id !== noteId));
+ setLigneStates(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[ligneKey(noteId, i)]); return n; });
+ setMontantsModifies(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[`${noteId}-${i}`]); return n; });
+ setModalCommentaire(null); setHistoryLoaded(false); setHistory([]); setActiveTab('history'); handleTabChange('history');
+ } catch (e: any) { alert(e.message); } finally { setSubmitting(false); }
+ };
+
+ const submitDecision = async (noteId: number, action: 'validate' | 'reject', lignesData: LigneDepense[], lignesRefusees: { index: number; motif: string }[]) => {
+ if (action === 'validate') {
+ const modifs = Object.entries(montantsModifies).filter(([k]) => k.startsWith(`${noteId}-`))
+ .map(([k, v]) => { const idx = parseInt(k.split('-')[1]); return { ligneIndex: idx, montantOriginal: parseFloat(lignesData[idx]?.montant || '0'), montantRetenu: v }; });
+ setCommentaireInput(''); setModalCommentaire({ noteId, lignesData, modifs }); return;
+ }
+ setSubmitting(true);
+ try {
+ const r = await fetch(`${API}/api/verificateur/notes/${noteId}/refuser`, { method: 'POST', headers: hdrs, body: JSON.stringify({ lignesRefusees, notifier: notifyOnReject }) });
+ const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Erreur');
+ onVerified(noteId); setNotes(prev => prev.filter(n => n.id !== noteId));
+ setLigneStates(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[ligneKey(noteId, i)]); return n; });
+ setMontantsModifies(prev => { const n = { ...prev }; lignesData.forEach((_, i) => delete n[`${noteId}-${i}`]); return n; });
+ setHistoryLoaded(false); setHistory([]); setActiveTab('history'); handleTabChange('history');
+ } catch (e: any) { alert(e.message); } finally { setSubmitting(false); }
+ };
+
+ // Compact list for panel 3
+ const renderLignesPanel = (note: NoteAVerifier, lignesData: LigneDepense[], jr: Fichier[], fg: Fichier[], qrRefs: string[], catName: string | null) => {
+ if (!catName) {
+ return (
+
+
+
Sélectionnez une catégorie
+
Les lignes s'afficheront ici
-
{label}
- {setPreviewUrl && (
-
setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
- Voir
-
+ );
+ }
+ const indices = lignesData.map((l, i) => ({ l, i })).filter(({ l }) => (l.categorie || 'Autre') === catName);
+ const activeLi = selectedLigne[note.id] ?? null;
+ return (
+ <>
+ {indices.map(({ l, i: li }) => {
+ const isKm = (l.categorie || '').toLowerCase().includes('kilom');
+ const km = parseFloat(l.km || '0') || 0;
+ const cv = parseInt(l.chevaux || '7') || 7;
+ const montantOriginal = parseFloat(l.montant || '0') || 0;
+ const mKey = `${note.id}-${li}`;
+ const montantEff = montantsModifies[mKey] ?? (isKm ? getIndemniteKm(km, cv) : montantOriginal);
+ const cat = getCatMeta(l.categorie);
+ const key = ligneKey(note.id, li);
+ const state = ligneStates[key] ?? { status: 'pending' as const };
+ const cls = state.status;
+ const label = l.libelle || l.categorie || `Ligne ${li + 1}`;
+ const estModifie = montantsModifies[mKey] !== undefined;
+ const isActive = activeLi === li;
+ const isRepas = (l.categorie || '').toLowerCase().includes('repas');
+ const isEv = /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.libelle || '') || /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.description || '');
+ const nbConvives = Math.max(1, (parseInt(l.nombreParticipants || '0') || 0) + 1);
+ const ppp = isRepas && montantEff > 0 ? montantEff / nbConvives : 0;
+ const depasse = isRepas && !isEv && ppp > 25;
+
+ return (
+
setSelectedLigne(prev => ({ ...prev, [note.id]: isActive ? null : li }))}>
+
{li + 1}
+
+
{label}
+
+ {fmtDate(l.date)}
+ {depasse && ⚠ plafond }
+ {state.status === 'refused' && ✗ refusée }
+ {state.status === 'ok' && ✓ ok{estModifie ? ' ajusté' : ''} }
+
+
+
+
{fmt(montantEff)}
+
›
+
+
+ );
+ })}
+ {indices.length > 1 && (
+
+ Groupé
+ indices.forEach(({ i }) => setLigne(ligneKey(note.id, i), 'ok'))}>
+ Tout valider ({indices.length})
+
+
)}
-
setJustif(stateKey, 'ok')}>✓
-
openNok(noteId, stateKey, f.fileName)}>✗
-
+ >
);
};
- // ── Bloc dépense ─────────────────────────────────────────────────────────
- // ✅ FIX : lignesData est maintenant passé en paramètre
- const renderBloc = (
- l: LigneDepense, li: number, note: NoteAVerifier,
- justificatifsReels: Fichier[], fichiersGlobaux: Fichier[], allQrRefs: string[],
- lignesData: LigneDepense[] // ← paramètre ajouté
- ) => {
+ // Panel 4: full detail of selected ligne
+ const renderLigneDetail = (note: NoteAVerifier, lignesData: LigneDepense[], jr: Fichier[], fg: Fichier[], qrRefs: string[]) => {
+ const li = selectedLigne[note.id] ?? null;
+ if (li === null) {
+ return (
+
+
+
Sélectionnez une dépense
+
Détails et justificatifs ici
+
+ );
+ }
+ const l = lignesData[li];
+ if (!l) return null;
+
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km || '0') || 0;
const cv = parseInt(l.chevaux || '7') || 7;
- const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
+ const montantOriginal = parseFloat(l.montant || '0') || 0;
+ const mKey = `${note.id}-${li}`;
+ const isRepas = (l.categorie || '').toLowerCase().includes('repas');
+ const isEv = /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.libelle || '') || /\b(ev[eè]nement|[eé]v[eè]nement)\b/i.test(l.description || '');
+ const nbConvives = Math.max(1, (parseInt(l.nombreParticipants || '0') || 0) + 1);
+ const montantEff = montantsModifies[mKey] ?? (isKm ? getIndemniteKm(km, cv) : montantOriginal);
+ const ppp = isRepas && montantEff > 0 ? montantEff / nbConvives : 0;
+ const depasse = isRepas && !isEv && ppp > 25;
+ const prorataMax = parseFloat((25 * nbConvives).toFixed(2));
const taux = parseFloat(l.tauxTVA || '0') || 0;
- const ht = (!isKm && taux > 0) ? price / (1 + taux / 100) : null;
+ const ht = (!isKm && taux > 0) ? montantEff / (1 + taux / 100) : null;
+ const tva = ht !== null ? montantEff - ht : null;
const cat = getCatMeta(l.categorie);
-
- const qrRef = l.qrNoteRef || '';
-
- // ✅ FIX : distribution équitable des fichiers globaux entre les dépenses
- const files: Fichier[] = qrRef
- ? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
- : (allQrRefs.length === 0
- ? fichiersGlobaux.slice(
- Math.floor(li * fichiersGlobaux.length / lignesData.length),
- Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
- )
- : []);
-
- // ✅ FIX : scope différent par ligne même en mode global
- const scope = qrRef ? `ligne${li}` : `global${li}`;
-
- const depOk = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'ok').length;
- const depNok = files.filter((_, fi) => justifStates[buildKey(note.id, scope, fi)]?.status === 'nok').length;
- const blockCls = depNok > 0 ? 'has-nok' : (depOk === files.length && files.length > 0 ? 'all-ok' : '');
-
- const sub: string[] = [];
- if (l.categorie) sub.push(l.categorie);
- if (l.date) sub.push(fmtDate(l.date));
- if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
- if (!isKm && taux > 0 && ht !== null) sub.push(`HT ${fmt(ht)} · TVA ${taux}%`);
- if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
- if (l.description) sub.push(l.description);
+ const files = getFilesForLigne(l, li, jr, fg, qrRefs, lignesData);
+ const key = ligneKey(note.id, li);
+ const state = ligneStates[key] ?? { status: 'pending' as const };
+ const cls = state.status;
+ const label = l.libelle || l.categorie || `Ligne ${li + 1}`;
+ const estModifie = montantsModifies[mKey] !== undefined;
return (
-
-
-
{li + 1}
-
{cat.emoji}
-
-
{l.libelle || l.categorie || '—'}
-
{sub.join(' · ')}
+
+ {/* Header */}
+
+
{li + 1}
+
+
{label}
+
{cat.emoji} {l.categorie} · {fmtDate(l.date)}
+
+
+
{fmt(montantEff)}
+ {estModifie &&
{fmt(montantOriginal)}
}
-
{fmt(price)}
- {files.length === 0 && isKm &&
km }
- {files.length === 0 && !isKm &&
⚠ manquant }
- {files.length > 0 && (
-
- {files.map((f, fi) => renderJRow(f, buildKey(note.id, scope, fi), note.id))}
+
+
+ {/* Montants */}
+ {(isKm || taux > 0 || isRepas) && (
+
+
Montants
+ {isKm ? (
+
+
Distance {km} km
+
Puissance {cv} CV
+
Barème {(montantEff / (km || 1)).toFixed(3)} €/km
+
+
Indemnité {fmt(montantEff)}
+
+ ) : (
+
+
Montant TTC {fmt(montantEff)}
+ {ht !== null &&
Montant HT {fmt(ht)}
}
+ {tva !== null &&
TVA ({taux}%) {fmt(tva)}
}
+ {isRepas &&
Convives {nbConvives} pers.
}
+ {isRepas &&
Par personne {fmt(ppp)}
}
+
+ )}
+
+ )}
+
+ {/* Description */}
+ {l.description && (
+
+
Commentaire
+
💬 {l.description}
+
+ )}
+
+ {/* Participants */}
+ {l.participants && l.participants.length > 0 && (
+
+
Participants ({l.participants.length})
+
+ {l.participants.map((p, pi) => (
+
+
+ {(p.prenom[0] || '') + (p.nom[0] || '')}
+
+
+
{p.prenom} {p.nom}
+ {p.societe &&
{p.societe}
}
+
+
+ ))}
+
+
+ )}
+
+ {/* Alerte plafond */}
+ {depasse && (
+
+
+
+
Plafond dépassé : {fmt(ppp)}/pers. > 25 €
+ {estModifie &&
✅ Ajusté à {fmt(montantsModifies[mKey])}
}
+
+
setModalMontant({ noteId: note.id, ligneIndex: li, ligneLabel: label, montantOriginal, montantProrataMax: prorataMax, valeurSaisie: (montantsModifies[mKey] ?? prorataMax).toFixed(2) })}>✏️ Ajuster
+ {estModifie &&
setMontantsModifies(prev => { const n = { ...prev }; delete n[mKey]; return n; })}>↩ }
+
+ )}
+
+ {/* Justificatifs */}
+
+
+ {isKm ? (
+
✓ Aucun justificatif requis (frais kilométriques)
+ ) : files.length === 0 ? (
+
Aucun justificatif trouvé
+ ) : (
+ files.map((f, fi) => {
+ const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
+ return (
+
setPreviewUrl?.({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
+
{isImg ? '🖼️' : '📄'}
+
{f.fileName}
+
Voir
+
+ );
+ })
+ )}
- )}
- {files.length === 0 && isKm && (
-
✓ Indemnité kilométrique — aucun justificatif requis
- )}
+
+ {/* Refus motif si refusé */}
+ {cls === 'refused' && state.motif && (
+
+ ✗ Motif : {state.motif}
+
+ )}
+
+
+ {/* Actions */}
+
+ {cls === 'refused' && (
+
openRefuse(note.id, li, label)}>Modifier le motif
+ )}
+
+
setLigne(key, 'ok')}> OK
+
openRefuse(note.id, li, label)}> Refuser
+
);
};
@@ -389,465 +636,427 @@ export default function VerificateurFinanceLight({
return (
<>
-
- {/* Tabs */}
-
-
handleTabChange('pending')}>
- À vérifier
- {notes.length > 0 && {notes.length} }
+
+
+ handleTabChange('pending')}>
+ À vérifier {notes.length > 0 && {notes.length} }
- handleTabChange('history')}>
- Historique
- {history.length > 0 && {history.length} }
+ handleTabChange('history')}>
+ Historique {history.length > 0 && {history.length} }
- {/* ── PENDING ── */}
- {activeTab === 'pending' && (
- <>
-
+ {activeTab === 'pending' && (notes.length === 0 ? (
+
+
+
Aucune note en attente
+
Les notes approuvées apparaîtront ici
+
+ ) : notes.map(note => {
+ const isOpen = expandedNotes.has(note.id);
+ let lignesData: LigneDepense[] = []; try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
+ let tousLesFichiers: Fichier[] = []; try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
+ const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
+ const jr = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
+ const qrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
+ const fg = qrRefs.length ? jr.filter(f => !qrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref))) : jr;
+ const okCount = lignesData.filter((_, i) => ligneStates[ligneKey(note.id, i)]?.status === 'ok').length;
+ const refusedList = lignesData.map((_, i) => ({ i, s: ligneStates[ligneKey(note.id, i)] })).filter(x => x.s?.status === 'refused').map(x => ({ index: x.i, motif: x.s!.motif || '' }));
+ const refusedCount = refusedList.length;
+ const pendingCount = lignesData.length - okCount - refusedCount;
+ const pct = lignesData.length > 0 ? Math.round(((okCount + refusedCount) / lignesData.length) * 100) : 0;
+ const allChecked = lignesData.length > 0 && pendingCount === 0;
+ const canValidate = allChecked && refusedCount === 0;
+ const canReject = refusedCount > 0 && pendingCount === 0;
+ const nbModifs = Object.keys(montantsModifies).filter(k => k.startsWith(`${note.id}-`)).length;
+ const totalAjuste = lignesData.reduce((sum, l, i) => {
+ const isK = (l.categorie || '').toLowerCase().includes('kilom');
+ const km = parseFloat(l.km || '0') || 0; const cv = parseInt(l.chevaux || '7') || 7;
+ return sum + (montantsModifies[`${note.id}-${i}`] ?? (isK ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0));
+ }, 0);
- {notes.length === 0 ? (
-
-
-
Aucune note en attente
-
Les notes approuvées apparaîtront ici
-
- ) : notes.map(note => {
- const isOpen = expandedNotes.has(note.id);
+ const catGroups = groupByCategory(lignesData);
+ const activeCat = selectedCat[note.id] ?? null;
- let lignesData: LigneDepense[] = [];
- try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
+ return (
+
+ {/* Note header */}
+
toggleNote(note.id)}>
+
+
+
+
{note.reference}
+
{note.libelle}
+
{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
+
+
+
0 ? 'adj' : ''}`} style={{ color: nbModifs > 0 ? '#15803d' : undefined }}>{nbModifs > 0 ? fmt(totalAjuste) : fmt(note.montant || 0)}
+ {nbModifs > 0 &&
{fmt(note.montant || 0)}
}
+
{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}
+
+
+ {isOpen ? : }
+
- let tousLesFichiers: Fichier[] = [];
- try { tousLesFichiers = note.fichiers ? JSON.parse(note.fichiers as string) : []; } catch { }
+ {lignesData.length > 0 && (
+
+
+
0 ? '#ef4444' : '#5b21b6' }} />
+
+
{pct}%
+
+ {okCount > 0 && {okCount} OK }
+ {refusedCount > 0 && {refusedCount} refus. }
+ {pendingCount > 0 && {pendingCount} att. }
+ {nbModifs > 0 && ✂️ {nbModifs} }
+
+
+ )}
- const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
- const justificatifsReels = tousLesFichiers.filter(
- f => !isSystemFile(f) && !isApprovalFile(f)
- );
- const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
- const fichiersGlobaux = allQrRefs.length > 0
- ? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
- : justificatifsReels;
+ {isOpen && (
+
- // ✅ FIX : allKeys cohérent avec la distribution par ligne
- const allKeys: string[] = [];
- if (allQrRefs.length === 0) {
- lignesData.forEach((_, li) => {
- const start = Math.floor(li * fichiersGlobaux.length / lignesData.length);
- const end = Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length);
- fichiersGlobaux.slice(start, end).forEach((_, fi) =>
- allKeys.push(buildKey(note.id, `global${li}`, fi))
- );
- });
- } else {
- lignesData.forEach((l, li) => {
- const qr = l.qrNoteRef || '';
- if (qr) {
- justificatifsReels
- .filter(f => f.fileName?.includes(qr) || f.uploadUrl?.includes(qr))
- .forEach((_, fi) => allKeys.push(buildKey(note.id, `ligne${li}`, fi)));
- }
- });
- fichiersGlobaux.forEach((_, fi) => allKeys.push(buildKey(note.id, 'global0', fi)));
- }
- fichierApprobation.forEach((_, fi) => allKeys.push(buildKey(note.id, 'approbation', fi)));
-
- const okAll = allKeys.filter(k => justifStates[k]?.status === 'ok').length;
- const nokAll = allKeys.filter(k => justifStates[k]?.status === 'nok').length;
- const pendingAll = allKeys.length - okAll - nokAll;
- const pct = allKeys.length > 0 ? Math.round(((okAll + nokAll) / allKeys.length) * 100) : 0;
- const allChecked = allKeys.length > 0 && pendingAll === 0;
-
- return (
-
-
toggleNote(note.id)}>
-
-
-
{note.reference}
-
{note.libelle}
-
- {[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
-
-
-
-
{fmt(note.montant || 0)}
-
{lignesData.length} dépense{lignesData.length > 1 ? 's' : ''}
-
+ {/* ── PANEL 1 : La note ── */}
+
+
+
+ La note
-
- {isOpen ? : }
-
-
-
- {allKeys.length > 0 && (
-
-
-
- Conformité — {pct}%
-
-
- {okAll > 0 && {okAll} conforme{okAll > 1 ? 's' : ''} }
- {nokAll > 0 && {nokAll} non conforme{nokAll > 1 ? 's' : ''} }
- {pendingAll > 0 && {pendingAll} en attente }
-
-
-
-
0 ? '#ef4444' : '#5b21b6' }} />
-
-
- )}
-
- {isOpen && (
-
-
-
Dépenses & justificatifs
-
- {/* ✅ FIX : lignesData passé en dernier argument */}
- {lignesData.map((l, li) =>
- renderBloc(l, li, note, justificatifsReels, fichiersGlobaux, allQrRefs, lignesData)
- )}
-
- {lignesData.length > 1 && (
-
-
TOTAL — {lignesData.length} dépenses
-
{fmt(note.montant || 0)}
+
+
+
+
+
{note.reference}
+
{note.libelle}
+
+
Total demandé
+
0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}
+ {nbModifs > 0 &&
→ {fmt(totalAjuste)} après ajust.
}
+
+
+ {note.collaborateur &&
👤 {note.collaborateur}
}
+ {note.campus &&
🏢 {note.campus}
}
+ {note.departement &&
🗂 {note.departement}
}
+ {note.date &&
📅 {fmtDate(note.date)}
}
- )}
+
+
+
+ Progression
+ {pct}%
+
+
0 ? '#ef4444' : '#5b21b6' }} />
+
+ {okCount > 0 && {okCount} OK }
+ {refusedCount > 0 && {refusedCount} refus. }
+ {pendingCount > 0 && {pendingCount} att. }
+
+
{fichierApprobation.length > 0 && (
-
-
Document d'approbation
-
-
-
- PDF d'approbation signé
- {fichierApprobation.length} fichier{fichierApprobation.length > 1 ? 's' : ''}
-
-
- {fichierApprobation.map((f, fi) => renderJRow(f, buildKey(note.id, 'approbation', fi), note.id))}
-
+
+
Approbation signée
+
+ {fichierApprobation.map((f, fi) => (
+
+ 📄
+ {f.fileName.length > 22 ? f.fileName.slice(0, 20) + '…' : f.fileName}
+ setPreviewUrl?.({ url: proxyUrl(f.uploadUrl), name: f.fileName })}> Voir
+
+ ))}
)}
+
+
-
-
0 ? 'nok' : ''}`}>
- {allChecked && nokAll === 0 ? '✓ Tous les justificatifs sont conformes'
- : allChecked && nokAll > 0 ? `⚠️ ${nokAll} non conforme${nokAll > 1 ? 's' : ''} — vérification possible`
- : nokAll > 0 && pendingAll > 0 ? `${nokAll} non conforme${nokAll > 1 ? 's' : ''} · ${pendingAll} en attente`
- : pendingAll > 0 ? `${pendingAll} justificatif${pendingAll > 1 ? 's' : ''} encore en attente`
- : allKeys.length === 0 ? 'Aucun justificatif à vérifier' : ''}
+ {/* ── PANEL 2 : Catégories ── */}
+
+
+
+ Catégories
+ {catGroups.length}
+
+
+ {catGroups.map(({ cat, indices, total }) => {
+ const catMeta = getCatMeta(cat);
+ const okInCat = indices.filter(i => ligneStates[ligneKey(note.id, i)]?.status === 'ok').length;
+ const refInCat = indices.filter(i => ligneStates[ligneKey(note.id, i)]?.status === 'refused').length;
+ const pendInCat = indices.length - okInCat - refInCat;
+ const catPct = indices.length > 0 ? Math.round(((okInCat + refInCat) / indices.length) * 100) : 0;
+ const isActive = activeCat === cat;
+ return (
+
{
+ setSelectedCat(prev => ({ ...prev, [note.id]: isActive ? null : cat }));
+ setSelectedLigne(prev => ({ ...prev, [note.id]: null }));
+ }}>
+
+
{catMeta.emoji}
+
+
{cat}
+
{indices.length} ligne{indices.length > 1 ? 's' : ''}
+
+
+
{fmt(total)}
+
{catPct}%
+
+
+
+
0 ? '#ef4444' : catMeta.color }} />
+
+
+ {okInCat > 0 && {okInCat} OK }
+ {refInCat > 0 && {refInCat} refus. }
+ {pendInCat > 0 && {pendInCat} att. }
+
+
+ );
+ })}
+
+
+
+ {/* ── PANEL 3 : Lignes de la catégorie (liste compacte) ── */}
+
+
+
+
+ {activeCat ? activeCat : 'Dépenses'}
+
+ {activeCat && (
+
+ {lignesData.filter(l => (l.categorie || 'Autre') === activeCat).length}
+
+ )}
+
+
+ {renderLignesPanel(note, lignesData, jr, fg, qrRefs, activeCat)}
+
+
+
+ {/* ── PANEL 4 : Détail de la ligne sélectionnée ── */}
+
+
+
+
+ {selectedLigne[note.id] !== null && selectedLigne[note.id] !== undefined
+ ? `Ligne ${(selectedLigne[note.id] ?? 0) + 1} — détail`
+ : 'Détail & justificatifs'}
+
+
+
+ {renderLigneDetail(note, lignesData, jr, fg, qrRefs)}
+
+
+
+ {/* ── PANEL 5 : Décision ── */}
+
+
+
+ Décision
+
+
+
+
Statut lignes
+
+ {lignesData.map((l, li) => {
+ const cat = getCatMeta(l.categorie);
+ const st = ligneStates[ligneKey(note.id, li)]?.status ?? 'pending';
+ const adj = montantsModifies[`${note.id}-${li}`] !== undefined;
+ const lbl = l.libelle || l.categorie || `Ligne ${li + 1}`;
+ const isActiveCatRow = activeCat === (l.categorie || 'Autre');
+ return (
+
setSelectedCat(prev => ({ ...prev, [note.id]: l.categorie || 'Autre' }))}>
+
+ {lbl}
+ {st === 'ok' && adj && OK ajusté }
+ {st === 'ok' && !adj && OK }
+ {st === 'pending' && Attente }
+ {st === 'refused' && Refusée }
+
+ );
+ })}
+
+
+ Total retenu
+ 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}
+
+
+ {canValidate &&
Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}
}
+ {canReject &&
{refusedCount} refusée{refusedCount > 1 ? 's' : ''}
}
+ {!allChecked &&
{pendingCount} ligne{pendingCount > 1 ? 's' : ''} à vérifier
}
+ {canReject && (
+
+ setNotifyOnReject(e.target.checked)} style={{ accentColor: '#dc2626' }} />
+ Notifier collaborateur & N1
+
+ )}
+ {canValidate &&
submitDecision(note.id, 'validate', lignesData, [])}> {submitting ? 'Envoi…' : 'Valider — Notifier Finance'} }
+ {canReject &&
submitDecision(note.id, 'reject', lignesData, refusedList)}> {submitting ? 'Envoi…' : `Refuser (${refusedCount})`} }
+ {!allChecked &&
Vérifiez toutes les lignes}
-
0}
- onClick={async () => {
- const commentaire = window.prompt('Commentaire de vérification (optionnel)', '') ?? '';
- if (commentaire === null) return;
- try {
- const res = await fetch(`${API}/api/verificateur/notes/${note.id}/verifier`, {
- method: 'PUT', headers: hdrs, body: JSON.stringify({ commentaire }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error);
- onVerified(note.id);
- setNotes(prev => prev.filter(n => n.id !== note.id));
- setJustifStates(prev => {
- const next = { ...prev };
- allKeys.forEach(k => delete next[k]);
- return next;
- });
- setHistoryLoaded(false);
- setHistory([]);
- setActiveTab('history');
- handleTabChange('history');
- } catch (e: any) { alert(e.message); }
- }}>
- ✓ Marquer comme vérifié — Notifier Validateur Finance
-
- )}
+
+
+
+ )}
+
+ );
+ }))}
+
+ {/* History tab */}
+ {activeTab === 'history' && (
+
+
+
+
+
Historique des vérifications
+
Traçabilité complète de vos contrôles Finance
+
+ {history.length > 0 &&
{history.length}
}
+
+ {!historyLoaded ? (
+
+ ) : history.length === 0 ? (
+
Aucune vérification effectuée
Les notes traitées apparaîtront ici
+ ) : history.map((h, i) => {
+ const isRefusee = h.statut === 'REFUSEE' || (h.nbLignesRefusees ?? 0) > 0;
+ return (
+
+
+
+
+ {h.reference}
+ {isRefusee ? '✗ Refusée' : '✓ Validée'}
+
+
{h.libelle}
+
{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}
+
+
+
{fmt(h.montant || 0)}
+
{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+
+
{h.nbLignes} ligne{h.nbLignes > 1 ? 's' : ''}
+ {(h.nbLignesOk ?? 0) > 0 &&
{h.nbLignesOk} OK
}
+ {(h.nbLignesRefusees ?? 0) > 0 &&
{h.nbLignesRefusees} refus.
}
+ {h.commentaire &&
}
+
);
})}
- >
- )}
-
- {/* ── HISTORY ── */}
- {activeTab === 'history' && (
-
-
-
-
-
Historique des vérifications
-
Traçabilité complète de vos contrôles Finance
-
- {history.length > 0 &&
{history.length} vérifiée{history.length > 1 ? 's' : ''}
}
-
- {!historyLoaded ? (
-
-
-
Chargement de l'historique…
-
- ) : history.length === 0 ? (
-
-
-
Aucune vérification effectuée
-
Les notes vérifiées apparaîtront ici
-
- ) : history.map((h, i) => {
- const isOpen = expandedHistory.has(h.id);
-
- let lignesData: LigneDepense[] = [];
- try { if (h.lignesJson) lignesData = JSON.parse(h.lignesJson); } catch { }
-
- let tousLesFichiers: Fichier[] = [];
- try { tousLesFichiers = h.fichiers ? JSON.parse(h.fichiers as string) : []; } catch { }
-
- const fichierApprobation = tousLesFichiers.filter(f => isApprovalFile(f));
- const justificatifsReels = tousLesFichiers.filter(f => !isSystemFile(f) && !isApprovalFile(f));
- const allQrRefs = lignesData.map(l => l.qrNoteRef).filter(Boolean) as string[];
- const fichiersGlobaux = allQrRefs.length > 0
- ? justificatifsReels.filter(f => !allQrRefs.some(ref => f.fileName?.includes(ref) || f.uploadUrl?.includes(ref)))
- : justificatifsReels;
-
- return (
-
- {/* ── Header cliquable ── */}
-
toggleHistory(h.id)}
- style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0, fontFamily: 'inherit' }}>
-
-
-
- {h.reference}
-
- Vérifiée
-
-
{h.libelle}
-
{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}
-
-
-
{fmt(h.montant || 0)}
-
-
- {new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
-
-
- {isOpen ? : }
- {isOpen ? 'Réduire' : 'Voir les justificatifs'}
-
-
-
-
-
- {/* ── Stats toujours visibles ── */}
-
-
{h.nbJustifs} justificatif{h.nbJustifs > 1 ? 's' : ''}
- {h.nbConformes > 0 &&
{h.nbConformes} conforme{h.nbConformes > 1 ? 's' : ''}
}
- {h.nbNonConformes > 0 &&
{h.nbNonConformes} non conforme{h.nbNonConformes > 1 ? 's' : ''}
}
- {h.commentaire &&
}
-
-
- {/* ── Détail dépenses + justificatifs (accordéon) ── */}
- {isOpen && (
-
- {lignesData.length > 0 && (
-
-
Dépenses & justificatifs
-
- {lignesData.map((l, li) => {
- const isKm = (l.categorie || '').toLowerCase().includes('kilom');
- const km = parseFloat(l.km || '0') || 0;
- const cv = parseInt(l.chevaux || '7') || 7;
- const price = isKm ? getIndemniteKm(km, cv) : parseFloat(l.montant || '0') || 0;
- const cat = getCatMeta(l.categorie);
- const qrRef = l.qrNoteRef || '';
-
- const files: Fichier[] = qrRef
- ? justificatifsReels.filter(f => f.fileName?.includes(qrRef) || f.uploadUrl?.includes(qrRef))
- : (allQrRefs.length === 0
- ? fichiersGlobaux.slice(
- Math.floor(li * fichiersGlobaux.length / lignesData.length),
- Math.floor((li + 1) * fichiersGlobaux.length / lignesData.length)
- )
- : []);
-
- const nonConformesLigne = (h.nonConformes || []).filter(nc =>
- files.some(f => f.fileName === nc.fileName)
- );
-
- const sub: string[] = [];
- if (l.categorie) sub.push(l.categorie);
- if (l.date) sub.push(fmtDate(l.date));
- if (isKm && km > 0) sub.push(`${km} km · ${cv} CV`);
- if (l.nombreParticipants) sub.push(`${l.nombreParticipants} pers.`);
- if (l.description) sub.push(l.description);
-
- return (
-
0 ? 'has-nok' : files.length > 0 ? 'all-ok' : ''}`}>
-
-
{li + 1}
-
{cat.emoji}
-
-
{l.libelle || l.categorie || '—'}
-
{sub.join(' · ')}
-
-
{fmt(price)}
- {files.length === 0 && isKm &&
km }
- {files.length === 0 && !isKm &&
⚠ manquant }
-
- {files.length > 0 && (
-
- {files.map((f, fi) => {
- const nc = nonConformesLigne.find(nc => nc.fileName === f.fileName);
- const status = nc ? 'nok' : 'ok';
- const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
- const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
- return (
-
-
{isImg ? '🖼️' : '📄'}
-
-
{short}
- {nc &&
↳ {nc.motif}
}
-
-
{status === 'ok' ? 'Conforme' : 'Non conforme'}
- {setPreviewUrl && (
-
setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
- Voir
-
- )}
-
- );
- })}
-
- )}
- {files.length === 0 && isKm && (
-
✓ Indemnité kilométrique — aucun justificatif requis
- )}
-
- );
- })}
-
-
- {lignesData.length > 1 && (
-
- TOTAL — {lignesData.length} dépenses
- {fmt(h.montant || 0)}
-
- )}
-
- )}
-
- {fichierApprobation.length > 0 && (
-
-
Document d'approbation
-
-
-
- PDF d'approbation signé
-
-
- {fichierApprobation.map((f, fi) => {
- const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName);
- const short = f.fileName.length > 44 ? f.fileName.slice(0, 42) + '…' : f.fileName;
- return (
-
-
{isImg ? '🖼️' : '📄'}
-
-
Signé
- {setPreviewUrl && (
-
setPreviewUrl({ url: proxyUrl(f.uploadUrl), name: f.fileName })}>
- Voir
-
- )}
-
- );
- })}
-
-
-
- )}
-
- )}
-
- );
- })
- }
)}
- {/* ── Modal NOK ── */}
+ {/* Modal refus */}
{modalNok && (
-
{ setModalNok(null); setNokReason(''); }}>
-
e.stopPropagation()}>
-
-
⚠️
-
-
Justificatif non conforme
-
Précisez le motif pour notifier le collaborateur
-
+
{ setModalNok(null); setNokReason(''); }}>
+
e.stopPropagation()}>
+
+
⚠️
+
Refuser cette ligne
Le motif sera transmis au collaborateur
-
-
-
📄
-
-
Fichier concerné
-
{modalNok.fileName}
-
+
+
+
📋
+
Ligne
{modalNok.ligneLabel}
-
- Motif de non-conformité *
-
-
-
- setNotifyOnNok(e.target.checked)} style={{ marginTop: 2, accentColor: '#dc2626', cursor: 'pointer', flexShrink: 0 }} />
-
-
Notifier par email
-
Le collaborateur et son validateur N1 recevront un email avec le motif
-
-
+
💡 Le collaborateur ne corrigera que cette ligne .
-
-
{ setModalNok(null); setNokReason(''); }}
- style={{ padding: '8px 16px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, color: '#374151', fontWeight: 500 }}>
- Annuler
-
-
{
- if (!nokReason.trim() || !modalNok) return;
- setNokSending(true);
- try {
- const res = await fetch(`${API}/api/verificateur/notes/${modalNok.noteId}/non-conforme`, {
- method: 'POST',
- headers: hdrs,
- body: JSON.stringify({
- fileName: modalNok.fileName,
- motif: nokReason.trim(),
- notifier: notifyOnNok,
- }),
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || 'Erreur');
- setJustifStates(prev => ({ ...prev, [modalNok.fileKey]: { status: 'nok', comment: nokReason.trim() } }));
- setModalNok(null);
- setNokReason('');
- } catch (e: any) { alert(e.message); }
- finally { setNokSending(false); }
- }}
- style={{ padding: '8px 18px', background: nokReason.trim() && !nokSending ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 8, cursor: nokReason.trim() && !nokSending ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, transition: 'background .15s' }}>
- {nokSending ? 'Envoi…' : 'Confirmer non conforme'}
+
+ { setModalNok(null); setNokReason(''); }} style={{ padding: '5px 12px', background: '#fff', border: '1px solid #d1d5db', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, color: '#374151', fontWeight: 500 }}>Annuler
+ { if (!nokReason.trim() || !modalNok) return; setLigne(ligneKey(modalNok.noteId, modalNok.ligneIndex), 'refused', nokReason.trim()); setModalNok(null); setNokReason(''); }}
+ style={{ padding: '5px 13px', background: nokReason.trim() ? '#dc2626' : '#fca5a5', color: '#fff', border: 'none', borderRadius: 6, cursor: nokReason.trim() ? 'pointer' : 'not-allowed', fontFamily: 'inherit', fontSize: 11, fontWeight: 700 }}>Confirmer le refus
+
+
+
+ )}
+
+ {/* Modal montant */}
+ {modalMontant && (() => {
+ const valeur = parseFloat(modalMontant.valeurSaisie.replace(',', '.'));
+ const isValid = !isNaN(valeur) && valeur > 0 && valeur <= modalMontant.montantOriginal;
+ const isOver = !isNaN(valeur) && valeur > modalMontant.montantOriginal;
+ const eco = isValid ? modalMontant.montantOriginal - valeur : 0;
+ return (
+
setModalMontant(null)}>
+
e.stopPropagation()}>
+
+
✂️
+
Ajuster le montant remboursé
+
{modalMontant.ligneLabel}
+
+
+
+
Original
{fmt(modalMontant.montantOriginal)}
+
Plafond légal
{fmt(modalMontant.montantProrataMax)}
+
+
setModalMontant(prev => prev ? { ...prev, valeurSaisie: prev.montantProrataMax.toFixed(2) } : null)}>
+ ✓ Plafond exact — {fmt(modalMontant.montantProrataMax)}
+
+
Ou saisir un montant précis
+
+ setModalMontant(prev => prev ? { ...prev, valeurSaisie: e.target.value } : null)}
+ onKeyDown={e => { if (e.key === 'Enter' && isValid) { setMontantsModifies(prev => ({ ...prev, [`${modalMontant.noteId}-${modalMontant.ligneIndex}`]: valeur })); setModalMontant(null); } if (e.key === 'Escape') setModalMontant(null); }} />
+ €
+
+
{isOver ? `⛔ Max ${fmt(modalMontant.montantOriginal)}` : isValid && eco > 0 ? `Économie : ${fmt(eco)}` : `Entre 0,01 € et ${fmt(modalMontant.montantOriginal)}`}
+
+
+ setModalMontant(null)}>Annuler
+ { if (!isValid) return; setMontantsModifies(prev => ({ ...prev, [`${modalMontant.noteId}-${modalMontant.ligneIndex}`]: valeur })); setModalMontant(null); }}>
+ Confirmer — {isValid ? fmt(valeur) : '—'}
+
+
+
+
+ );
+ })()}
+
+ {/* Modal commentaire validation */}
+ {modalCommentaire && (
+
setModalCommentaire(null)}>
+
e.stopPropagation()} style={{ maxWidth: 440 }}>
+
+
✅
+
Valider la note
Commentaire (optionnel)
+
setModalCommentaire(null)} style={{ marginLeft: 'auto', background: 'rgba(255,255,255,.2)', border: 'none', borderRadius: 6, width: 26, height: 26, cursor: 'pointer', color: '#fff', fontSize: 13, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕
+
+
+ {modalCommentaire.modifs.length > 0 && (
+
+
✂️ {modalCommentaire.modifs.length} montant{modalCommentaire.modifs.length > 1 ? 's' : ''} ajusté{modalCommentaire.modifs.length > 1 ? 's' : ''}
+ {modalCommentaire.modifs.map((m, i) => { const l = modalCommentaire.lignesData[m.ligneIndex]; return (
{l?.libelle || `Ligne ${m.ligneIndex + 1}`} {fmt(m.montantOriginal)} → {fmt(m.montantRetenu)}
); })}
+
+ )}
+
+
Commentaire
+
setCommentaireInput(e.target.value)} placeholder="Ex : Dossier complet, bon pour accord…"
+ rows={3} style={{ width: '100%', padding: '7px 9px', border: '1.5px solid #d1d5db', borderRadius: 6, resize: 'vertical', fontFamily: 'DM Sans,sans-serif', fontSize: 11, color: '#111827', background: '#fff', outline: 'none', boxSizing: 'border-box' as const }}
+ onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitValidation(modalCommentaire.noteId, modalCommentaire.lignesData, modalCommentaire.modifs, commentaireInput.trim()); }} />
+ Ctrl+Entrée pour valider
+
+
+
+ setModalCommentaire(null)} style={{ padding: '7px 13px', background: '#fff', border: '1.5px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, fontWeight: 600, color: '#6b7280' }}>Annuler
+ submitValidation(modalCommentaire.noteId, modalCommentaire.lignesData, modalCommentaire.modifs, commentaireInput.trim())}
+ style={{ padding: '7px 16px', background: submitting ? '#86efac' : '#15803d', color: '#fff', border: 'none', borderRadius: 6, cursor: submitting ? 'not-allowed' : 'pointer', fontFamily: 'inherit', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 5 }}>
+ {submitting ? '⏳ Envoi…' : '✅ Confirmer'}
@@ -856,4 +1065,4 @@ export default function VerificateurFinanceLight({
>
);
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/ndf/tsconfig.app.json b/ndf/tsconfig.app.json
index 8f298ad..6d1cecf 100644
--- a/ndf/tsconfig.app.json
+++ b/ndf/tsconfig.app.json
@@ -23,5 +23,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": [ "src" ]
+ "include": [ "src/**/*" ]
}