Compare commits

...

4 Commits

Author SHA1 Message Date
oimer 42ab01ed41 resanomaliedoublejustifavecqrcode 2026-06-18 11:01:54 +02:00
oimer 3178e3bf40 popremplissgeprofil 2026-06-17 09:43:04 +02:00
oimer 0329dbc93a resolutionano 2026-06-15 15:37:23 +02:00
oimer 78516cb99f version_Rôle_President Version_Chatbot 2026-05-29 09:55:39 +02:00
14 changed files with 9331 additions and 3242 deletions
+241 -123
View File
@@ -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,47 +85,114 @@ 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 cv = parseInt(l.chevaux) || 7;
if (!isKm && taux > 0 && ttc > 0) {
const indemniteKm = isKm ? (() => {
const b = BAREME_KM[cv];
if (!b || km <= 0) return 0;
if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
return parseFloat((km * b.t3).toFixed(2));
})() : 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 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,
@@ -151,20 +200,22 @@ export function preparerLignesPDF(lignesParsed, tarifKm = TARIF_KM_DEFAULT) {
nature: l.categorie || '',
libelle: l.libelle || '',
km: isKm ? km : 0,
tarifKmVal: isKm ? tarifKm : 0, // ← valeur numérique tarif
tarifKmVal: isKm ? tarifKmAffiche : 0,
montantTTC: isKm ? 0 : ttc,
tva21, tva55, tva10, tva20,
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;
console.log('🔍 generateFicheSignee montantServeur reçu =', note.montant);
const tarifKm = parseFloat(note.tarifKm) || 0.697;
let lignesPDF = [];
if (note.lignesJson) {
@@ -176,16 +227,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,
}];
}
@@ -205,23 +260,22 @@ export async function generateFicheSignee(note, signatures = []) {
tarifKm,
signatures,
statut: note.statut || 'enattente',
montantServeur: note.montant ? parseFloat(note.montant) : null,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// _buildPDF — génère le Buffer PDF
// _buildPDF
// ─────────────────────────────────────────────────────────────────────────────
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures, montantServeur }) {
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 +284,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 +308,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 +333,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,14 +365,17 @@ 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
sousKm: sousKm > 0 ? f2(sousKm) : '',
tarifKm: tarif > 0 ? f3(tarif) : '',
sousKm: sousKm > 0 ? f2(sousKm) + ' €' : '',
ttc: f2(ttc),
tva21: f2(t21), tva55: f2(t55),
tva10: f2(t10), tva20: f2(t20),
@@ -331,34 +384,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,8 +433,8 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
const totMap = {
km: totKm > 0 ? f2(totKm) : '',
tarifKm: '', // pas de somme de tarifs
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
tarifKm: '',
sousKm: totSousKm > 0 ? f2(totSousKm) + ' €' : '',
ttc: f2(totTTC),
tva21: f2(totT21), tva55: f2(totT55),
tva10: f2(totT10), tva20: f2(totT20),
@@ -381,45 +445,96 @@ 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');
}
// ── Zone bas ─────────────────────────────────────────────────
const footY = totalY + ROW_H + 12;
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
const bw = 64;
// ✅ 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 }
);
}
// ── 7. ZONE BAS (simplifiée — les infos km sont dans le tableau) ──
const footY = totalY + ROW_H + 12;
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
const bw = 64;
const labelOffsetY = hasProrata ? 16 : 0;
// Montant à rembourser (simplifié)
// APRÈS
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 });
// Détail calcul km + dépenses
doc.font('Helvetica').fontSize(7.5).fillColor(C.grey)
.text(
totSousKm > 0 && totTTC > 0
? `${f2(totSousKm)} € (km) + ${f2(totTTC)} € (dépenses) =`
: totSousKm > 0
? `${f2(totSousKm)} € (indemnités kilométriques) =`
: `${f2(totTTC)} € (dépenses) =`,
MARGIN, footY + 17 + labelOffsetY,
{ lineBreak: false }
);
drawRect(doc, MARGIN + 220, footY + 13 + 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 + 222, footY + 16.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(
` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)}`,
MARGIN, footY + 34 + 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 +546,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 +555,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 +568,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 +593,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 });
}
}
+2417 -715
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -29,7 +29,7 @@
}
.login-header {
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
color: white;
padding: 40px 30px;
text-align: center;
+27
View File
@@ -64,6 +64,31 @@ const ROLE_CONFIG: Record<string, {
accent: '#7c3aed',
features: ['Voir toutes les notes de l\'organisation', 'Filtrer par collaborateur, statut, mois', 'Visualiser les montants globaux'],
},
VerificateurFinance: {
label: 'Vérificateur Finance',
sublabel: 'Contrôle & vérification des notes',
icon: '🔍',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
accent: '#f59e0b',
features: [
'Vérifier les justificatifs soumis',
'Contrôler la conformité des notes',
'Signaler les anomalies',
],
},
ValidateurFinance: {
label: 'Validateur Finance',
sublabel: 'Validation finale & paiements',
icon: '💳',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
accent: '#10b981',
features: [
'Valider les notes après vérification',
'Autoriser les virements',
'Export XML & reporting',
'Suivi des remboursements',
],
},
};
// Normalise un rôle vers sa clé de config
@@ -74,6 +99,8 @@ const normalizeRole = (role: string): string => {
validateur: 'Validateur',
validatrice: 'Validatrice',
finance: 'Finance',
verificateurfinance: 'VerificateurFinance',
validateurfinance: 'ValidateurFinance',
};
return map[role.toLowerCase()] ?? role;
};
+6 -2
View File
@@ -28,6 +28,7 @@ interface AuthContextType {
isSuperUser: boolean;
isVerificateurFinance: boolean;
isValidateurFinance: boolean;
isPresident: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -38,7 +39,7 @@ const VALID_ROLES = [
'Collaborateur', 'Collaboratrice',
'Validateur', 'Validatrice',
'Finance', 'VerificateurFinance', 'ValidateurFinance',
'superUtilisateur'
'superUtilisateur','President'
];
function parseRoles(input: string | string[]): string[] {
@@ -199,6 +200,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const isSuperUser = hasRole('superUtilisateur');
const isVerificateurFinance = hasRole('VerificateurFinance');
const isValidateurFinance = hasRole('ValidateurFinance');
const isPresident = hasRole('President');
return (
<AuthContext.Provider value={{
@@ -217,7 +220,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
isFinance,
isSuperUser,
isVerificateurFinance,
isValidateurFinance
isValidateurFinance,
isPresident
}}>
{children}
</AuthContext.Provider>
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -32,7 +32,7 @@ const AuthCallback = (): JSX.Element => {
justifyContent: 'center',
fontFamily: 'sans-serif',
fontSize: '18px',
color: '#f5f5dc',
}}>
Connexion en cours...
</div>
+2299 -523
View File
File diff suppressed because it is too large Load Diff
+900
View File
@@ -0,0 +1,900 @@
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<T>(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 (
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 7 }}>
{CONTACTS.map((c) => (
<a
key={c.email}
href={`mailto:${c.email}`}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 12px",
background: c.bg, border: `1.5px solid ${c.border}`,
borderRadius: 10, textDecoration: "none", transition: "transform 0.15s",
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = "translateY(-1px)")}
onMouseLeave={(e) => (e.currentTarget.style.transform = "none")}
>
<span style={{ fontSize: 18, flexShrink: 0 }}>{c.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: c.color }}>{c.label}</div>
<div style={{ fontSize: 10, color: "#64748b", marginTop: 1 }}>{c.desc}</div>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 2, flexShrink: 0 }}>
<span style={{ fontSize: 10, fontWeight: 700, color: c.color, background: "rgba(255,255,255,0.7)", padding: "2px 7px", borderRadius: 20 }}>
Écrire
</span>
<span style={{ fontSize: 9, color: "#94a3b8", fontFamily: "monospace" }}>{c.email}</span>
</div>
</a>
))}
</div>
);
}
// ── RENDU MARKDOWN ─────────────────────────────────────
function renderContent(text: string): React.ReactNode[] {
return text.split("\n").map((line, i) => {
if (!line.trim()) return <span key={i} style={{ display: "block", height: 4 }} />;
const parseBold = (str: string): React.ReactNode[] =>
str.split(/\*\*(.*?)\*\*/g).map((part, j) =>
j % 2 === 1 ? <strong key={j}>{part}</strong> : part
);
const isBullet = line.startsWith("- ");
const isNum = /^\d+\. /.test(line);
if (isBullet || isNum) {
const content = line.replace(/^- /, "").replace(/^\d+\. /, "");
return (
<div key={i} style={{ display: "flex", gap: 5, margin: "1px 0", alignItems: "flex-start" }}>
<span style={{ flexShrink: 0, opacity: 0.55, marginTop: 1, fontSize: 11 }}>
{isBullet ? "•" : line.match(/^(\d+)\./)?.[1] + "."}
</span>
<span>{parseBold(content)}</span>
</div>
);
}
return (
<p key={i} style={{ margin: "2px 0", lineHeight: 1.55 }}>
{parseBold(line)}
</p>
);
});
}
// ── 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 (
<div style={{ display: "flex", gap: 5, marginTop: 8, alignItems: "center" }}>
{voted === null ? (
<>
<button
onClick={() => 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"; }}
>👍</button>
<button
onClick={() => 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"; }}
>👎</button>
<span style={{ fontSize: 10, color: "#94a3b8" }}>Utile ?</span>
</>
) : (
<span style={{ fontSize: 10.5, color: voted === "up" ? "#16a34a" : "#dc2626" }}>
{voted === "up" ? "Merci 😊" : "Noté, on va améliorer ça !"}
</span>
)}
</div>
);
}
// ── BARRE DE CONFIANCE ─────────────────────────────────
// ── MESSAGE BUBBLE ─────────────────────────────────────
interface MessageBubbleProps {
msg: Message;
onNegativeFeedback: (msg: Message) => void;
}
function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
const isBot = msg.role === "assistant";
return (
<div style={{
display: "flex", flexDirection: isBot ? "row" : "row-reverse",
gap: 8, alignItems: "flex-end", marginBottom: 10,
animation: "ndfFade 0.2s ease",
}}>
{isBot && (
<img
src="/img/emma-avatar.jpg"
alt="Emma"
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
/>
)}
<div style={{ maxWidth: "83%" }}>
<div style={{
padding: "9px 13px",
borderRadius: isBot ? "4px 13px 13px 13px" : "13px 4px 13px 13px",
background: isBot ? "var(--bg-card,#fff)" : "linear-gradient(135deg,#6366f1,#4f46e5)",
border: isBot ? "1.5px solid var(--border-card,#e2e8f0)" : "none",
color: isBot ? "var(--text-primary,#1e293b)" : "#fff",
fontSize: 12.5,
boxShadow: isBot ? "0 1px 4px rgba(0,0,0,0.06)" : "0 4px 14px rgba(99,102,241,0.3)",
}}>
{renderContent(msg.content)}
{msg.showContacts && <ContactCards />}
</div>
{/* Feedback + barre confiance uniquement sur les messages bot */}
{isBot && msg.id !== "welcome" && (
<div style={{ paddingLeft: 2 }}>
<FeedbackRow
msgId={msg.id}
content={msg.content}
onNegative={() => onNegativeFeedback(msg)}
/>
</div>
)}
</div>
</div>
);
}
// ── TYPING INDICATOR ───────────────────────────────────
function TypingIndicator() {
return (
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
<img
src="/img/emma-avatar.png"
alt="Emma"
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
/>
<div style={{
padding: "11px 15px",
background: "var(--bg-card,#fff)",
border: "1.5px solid var(--border-card,#e2e8f0)",
borderRadius: "4px 13px 13px 13px",
display: "flex", gap: 4, alignItems: "center",
}}>
{[0, 1, 2].map((i) => (
<div key={i} style={{
width: 5, height: 5, borderRadius: "50%", background: "#6366f1",
animation: `ndfBounce 1.2s ease-in-out ${i * 0.2}s infinite`,
}} />
))}
</div>
</div>
);
}
// ── MEMORY BADGE ───────────────────────────────────────
function MemoryBadge({ topics }: { topics: string[] }) {
if (topics.length === 0) return null;
return (
<div style={{
margin: "6px 13px 0",
padding: "4px 10px",
background: "#eef2ff", border: "1px solid #c7d2fe",
borderRadius: 8, fontSize: 10, color: "#4f46e5",
display: "flex", alignItems: "center", gap: 5,
animation: "ndfFade 0.3s ease",
}}>
🧠 <span>Contexte : <strong>{topics.slice(-2).join(" → ")}</strong></span>
</div>
);
}
// ── SUGGESTIONS DYNAMIQUES ─────────────────────────────
interface SuggestionsBarProps {
suggestions: string[];
label: string;
onSelect: (q: string) => void;
disabled: boolean;
}
function SuggestionsBar({ suggestions, label, onSelect, disabled }: SuggestionsBarProps) {
return (
<div style={{ padding: "4px 13px 10px" }}>
{label && (
<div style={{ fontSize: 9.5, color: "#94a3b8", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 4 }}>
{label}
</div>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{suggestions.map((q, i) => (
<button
key={q}
className="ndf-quick"
onClick={() => 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}</button>
))}
</div>
</div>
);
}
// ── COMPOSANT PRINCIPAL ────────────────────────────────
export default function NDFChatbot() {
const [open, setOpen] = useState<boolean>(false);
const [showWelcomeBubble, setShowWelcomeBubble] = useState<boolean>(true);
const [displayMsgs, setDisplayMsgs] = useState<Message[]>([
{
id: "welcome",
role: "assistant",
content: "Bonjour ! 👋 Je suis Emma l'assistante 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<string[]>(QUICK_QUESTIONS);
const [suggestionsLabel, setSuggestionsLabel] = useState<string>("Questions fréquentes");
const [input, setInput] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [hasNotif, setHasNotif] = useState<boolean>(true);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<>
<style>{`
@keyframes ndfFade { from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none} }
@keyframes ndfBounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-4px)} }
@keyframes ndfSlideUp { from{opacity:0;transform:translateY(14px) scale(0.98)}to{opacity:1;transform:none} }
@keyframes ndfPulse { 0%,100%{box-shadow:0 6px 20px rgba(99,102,241,0.4)}50%{box-shadow:0 6px 28px rgba(99,102,241,0.65)} }
.ndf-quick:hover{background:#eef2ff!important;border-color:#6366f1!important;color:#4f46e5!important}
.ndf-inp:focus{border-color:#6366f1!important;box-shadow:0 0 0 3px rgba(99,102,241,0.12)!important;outline:none}
.ndf-send:hover:not(:disabled){background:#4f46e5!important}
.ndf-send:disabled{opacity:0.4!important;cursor:not-allowed!important}
.ndf-scroll::-webkit-scrollbar{width:4px}
.ndf-scroll::-webkit-scrollbar-track{background:transparent}
.ndf-scroll::-webkit-scrollbar-thumb{background:#c7d2fe;border-radius:4px}
`}</style>
{/* ── BULLE DE BIENVENUE ── */}
{showWelcomeBubble && !open && (
<div style={{
position: "fixed", bottom: 92, right: 28, maxWidth: 250,
background: "#ffffff", color: "#1e293b", border: "1px solid #e2e8f0",
borderRadius: 14, padding: "10px 12px",
boxShadow: "0 10px 30px rgba(0,0,0,0.12)",
zIndex: 9999, animation: "ndfSlideUp 0.25s ease",
}}>
<button
onClick={() => 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,
}}
></button>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
Je suis <strong>Emma</strong>, je peux t'aider si besoin.
</div>
<div style={{
position: "absolute", bottom: -8, right: 18,
width: 14, height: 14, background: "#ffffff",
borderRight: "1px solid #e2e8f0", borderBottom: "1px solid #e2e8f0",
transform: "rotate(45deg)",
}} />
</div>
)}
{/* ── BOUTON FLOTTANT ── */}
<button
onClick={() => 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 && (
<span style={{
position: "absolute", top: 2, right: 2,
width: 11, height: 11, background: "#ef4444",
borderRadius: "50%", border: "2px solid white",
}} />
)}
</button>
{/* ── FENÊTRE CHAT ── */}
{open && (
<div style={{
position: "fixed", bottom: 90, right: 28,
width: 352, maxHeight: "76vh",
background: "var(--bg-app,#f8fafc)", borderRadius: 18,
boxShadow: "0 20px 60px rgba(0,0,0,0.18)",
border: "1px solid var(--border-card,#e2e8f0)",
display: "flex", flexDirection: "column",
zIndex: 9998, overflow: "hidden",
animation: "ndfSlideUp 0.22s ease",
}}>
{/* HEADER */}
<div style={{
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
padding: "13px 16px",
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
}}>
<img
src="/img/emma-avatar.jpg"
alt="Emma"
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
/>
<div style={{ flex: 1 }}>
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div>
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
<span style={{
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
background: "#4ade80", marginRight: 4, verticalAlign: "middle",
}} />
ENSUP Group · Toujours disponible
</div>
</div>
<button
onClick={resetConversation}
title="Nouvelle conversation"
style={{
background: "rgba(255,255,255,0.15)", border: "none", borderRadius: 7,
width: 28, height: 28, cursor: "pointer", color: "rgba(255,255,255,0.8)",
fontSize: 14, display: "flex", alignItems: "center", justifyContent: "center",
}}
>🗑</button>
<button
onClick={() => 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",
}}
></button>
</div>
{/* BADGE MÉMOIRE CONTEXTUELLE */}
<MemoryBadge topics={conversationTopics} />
{/* MESSAGES */}
<div
className="ndf-scroll"
style={{
flex: 1, overflowY: "auto",
padding: "14px 13px 6px",
display: "flex", flexDirection: "column",
}}
>
{displayMsgs.map((msg) => (
<MessageBubble
key={msg.id}
msg={msg}
onNegativeFeedback={handleNegativeFeedback}
/>
))}
{loading && <TypingIndicator />}
<div ref={bottomRef} />
</div>
{/* SUGGESTIONS DYNAMIQUES */}
{currentSuggestions.length > 0 && (
<SuggestionsBar
suggestions={currentSuggestions}
label={suggestionsLabel}
onSelect={sendMessage}
disabled={loading}
/>
)}
<div style={{ height: 1, background: "var(--border-divider,#e2e8f0)", flexShrink: 0 }} />
{/* INPUT */}
<form
onSubmit={(e) => { e.preventDefault(); sendMessage(input); }}
style={{
display: "flex", gap: 7, padding: "10px 13px",
background: "var(--bg-card,#fff)", flexShrink: 0,
}}
>
<input
ref={inputRef}
className="ndf-inp"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Posez votre question..."
disabled={loading}
style={{
flex: 1, padding: "8px 11px",
border: "1.5px solid var(--border-input,#e2e8f0)",
borderRadius: 9, fontSize: 12.5, fontFamily: "inherit",
background: "var(--bg-input,#f8fafc)",
color: "var(--text-primary,#1e293b)",
transition: "border-color 0.2s, box-shadow 0.2s",
}}
/>
<button
type="submit"
className="ndf-send"
disabled={!input.trim() || loading}
style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 15, color: "#fff", transition: "background 0.15s",
}}
></button>
</form>
<div style={{
padding: "5px 13px 9px",
background: "var(--bg-card,#fff)",
textAlign: "center", fontSize: 9.5,
color: "var(--text-muted,#94a3b8)",
}}>
Emma · ENSUP Group
</div>
</div>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
+693
View File
@@ -0,0 +1,693 @@
// PresidentValidation.tsx
// Page dédiée au rôle Président pour valider les notes de frais et générer le XML
// Ajouter dans src/pages/ ou src/components/
import { useState, useEffect, useCallback } from 'react';
// ── Types ─────────────────────────────────────────────────────────────────
interface Note {
id: number;
reference: string;
libelle: string;
montant: number;
date: string;
categorie: string;
statut: string;
collaborateur: string;
collaborateurEmail: string;
departement: string;
campus: string;
societe?: string;
nomN1?: string;
nomVerificateur?: string;
dateVerification?: string;
commentaireVerification?: string;
lignesJson?: string;
fichiers?: string;
}
interface HistoriqueXml {
dateXmlJour: string;
dateXmlExacte: string;
nbNotes: number;
totalMontant: number;
noteIds: string;
listeReferences: string;
presidentNom?: string;
dateValidationPresident?: string;
commentairePresident?: string;
}
interface Props {
apiBaseUrl: string;
authToken: string;
user: { prenom: string; nom: string; email: string; roles: string[] };
onShowToast: (msg: string, type?: string) => void;
}
// ── Helpers ───────────────────────────────────────────────────────────────
const fmt = (n: number) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
const normalizeCampus = (campus?: string): string => {
if (!campus) return '';
const c = campus.toUpperCase();
if (c.includes('SQY') || c.includes('SAINT')) return 'SQY';
if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
return campus;
};
// ── Styles ────────────────────────────────────────────────────────────────
const card: React.CSSProperties = {
background: 'var(--bg-card)',
borderRadius: 16,
border: '1px solid var(--border-card)',
boxShadow: 'var(--shadow-card)',
overflow: 'hidden',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px',
border: '1.5px solid var(--border-input)', borderRadius: 8,
background: 'var(--bg-input)', fontFamily: 'inherit',
fontSize: 14, color: 'var(--text-primary)', outline: 'none',
boxSizing: 'border-box',
};
const tagStatut = (s: string) => {
const map: Record<string, { bg: string; color: string; label: string }> = {
'en_attente_president': { bg: '#ede9fe', color: '#7c3aed', label: '⏳ Attente Président' },
'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: '🏦 Paiement en attente' },
'verifie': { bg: '#ede9fe', color: '#7c3aed', label: '🔍 Vérifié' },
'approuve': { bg: '#dcfce7', color: '#15803d', label: '✅ Approuvé' },
};
const k = s?.toLowerCase().trim() ?? '';
const st = map[k] ?? { bg: '#f1f5f9', color: '#64748b', label: s };
return (
<span style={{
display: 'inline-block', fontSize: 11, fontWeight: 700,
padding: '3px 10px', borderRadius: 20, whiteSpace: 'nowrap',
background: st.bg, color: st.color,
}}>{st.label}</span>
);
};
// ══════════════════════════════════════════════════════════════════════════
// COMPOSANT PRINCIPAL
// ══════════════════════════════════════════════════════════════════════════
const PresidentValidation = ({ apiBaseUrl, authToken, user, onShowToast }: Props) => {
const [tab, setTab] = useState<'notes' | 'historique'>('notes');
// Notes en attente
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(false);
// Sélection
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [commentaire, setCommentaire] = useState('');
// Historique XML
const [historique, setHistorique] = useState<HistoriqueXml[]>([]);
const [historiqueLoading, setHistoriqueLoading] = useState(false);
// XML generation
const [xmlLoading, setXmlLoading] = useState(false);
const [xmlFiltreAnnee, setXmlFiltreAnnee] = useState('');
const [xmlFiltreMois, setXmlFiltreMois] = useState('');
const hdrs = { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' };
// ── Chargement des notes ───────────────────────────────────────────
const loadNotes = useCallback(async () => {
setNotesLoading(true);
try {
const res = await fetch(`${apiBaseUrl}/api/president/notes`, { headers: hdrs });
const data = await res.json();
if (res.ok && Array.isArray(data)) setNotes(data);
} catch { }
finally { setNotesLoading(false); }
}, [apiBaseUrl, authToken]);
// ── Chargement historique ──────────────────────────────────────────
const loadHistorique = useCallback(async () => {
setHistoriqueLoading(true);
try {
const params = new URLSearchParams();
if (xmlFiltreAnnee) params.append('annee', xmlFiltreAnnee);
if (xmlFiltreMois) params.append('mois', xmlFiltreMois);
const res = await fetch(`${apiBaseUrl}/api/president/historique?${params}`, { headers: hdrs });
const data = await res.json();
if (res.ok && Array.isArray(data)) setHistorique(data);
} catch { }
finally { setHistoriqueLoading(false); }
}, [apiBaseUrl, authToken, xmlFiltreAnnee, xmlFiltreMois]);
useEffect(() => { loadNotes(); }, [loadNotes]);
useEffect(() => { if (tab === 'historique') loadHistorique(); }, [tab, loadHistorique]);
// ── Générer XML ────────────────────────────────────────────────────
const handleGenererXml = async () => {
if (!selectedIds.length) return;
if (!window.confirm(`Valider et générer le virement XML pour ${selectedIds.length} note(s) ?`)) return;
setXmlLoading(true);
try {
const res = await fetch(`${apiBaseUrl}/api/president/generer-xml`, {
method: 'POST',
headers: hdrs,
body: JSON.stringify({ noteIds: selectedIds, commentaire: commentaire.trim() || undefined }),
});
if (!res.ok) {
const e = await res.json();
if (res.status === 422 && e.details?.length) {
alert(`${e.error}\n\n${e.details.map((d: string) => `${d}`).join('\n')}`);
} else {
throw new Error(e.error);
}
return;
}
// Télécharger le fichier XML
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `virements-ndf-PRESIDENT-${new Date().toISOString().split('T')[0]}.xml`;
a.click();
URL.revokeObjectURL(url);
onShowToast(`✅ XML généré et signé par ${user.prenom} ${user.nom}${selectedIds.length} virement(s)`, 'success');
setSelectedIds([]);
setCommentaire('');
await loadNotes();
} catch (e: any) {
onShowToast(e.message || 'Erreur lors de la génération', 'error');
} finally {
setXmlLoading(false);
}
};
// ── Statistiques ───────────────────────────────────────────────────
const totalNotes = notes.length;
const totalMontant = notes.reduce((s, n) => s + (n.montant || 0), 0);
const totalSelectionne = notes
.filter(n => selectedIds.includes(n.id))
.reduce((s, n) => s + (n.montant || 0), 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── BANDEAU IDENTITÉ PRÉSIDENT ── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 20,
padding: '20px 28px',
background: 'linear-gradient(135deg,#0f172a 0%,#1e3a5f 50%,#1d4ed8 100%)',
borderRadius: 16, position: 'relative', overflow: 'hidden',
}}>
{/* Décoration fond */}
<div style={{
position: 'absolute', right: -40, top: -40,
width: 200, height: 200, borderRadius: '50%',
background: 'rgba(99,102,241,0.15)', pointerEvents: 'none',
}} />
<div style={{
width: 56, height: 56, borderRadius: 16,
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
border: '2px solid rgba(255,255,255,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 28, flexShrink: 0,
}}>
💼
</div>
<div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '1px', marginBottom: 4 }}>
Espace Président Validation des virements
</div>
<div style={{ color: '#fff', fontSize: 18, fontWeight: 800 }}>
{user.prenom} {user.nom}
</div>
<div style={{ color: 'rgba(255,255,255,0.65)', fontSize: 12, marginTop: 2 }}>
Votre validation est requise avant tout virement bancaire
</div>
</div>
{/* Badge compte notes en attente */}
{totalNotes > 0 && (
<div style={{ marginLeft: 'auto', textAlign: 'right', flexShrink: 0 }}>
<div style={{
background: '#ef4444', color: '#fff',
fontSize: 22, fontWeight: 900,
padding: '8px 18px', borderRadius: 12,
display: 'inline-block',
}}>
{totalNotes}
</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 4 }}>
en attente
</div>
</div>
)}
</div>
{/* ── ONGLETS ── */}
<div style={{ display: 'flex', gap: 0, borderBottom: '2px solid var(--border-divider)' }}>
{[
{ id: 'notes', icon: '📋', label: `Notes à valider${totalNotes > 0 ? ` (${totalNotes})` : ''}` },
{ id: 'historique', icon: '📅', label: 'Historique de mes validations' },
].map(t => (
<button
key={t.id}
onClick={() => setTab(t.id as any)}
style={{
padding: '12px 24px',
border: 'none', borderBottom: tab === t.id ? '2px solid #1d4ed8' : '2px solid transparent',
marginBottom: -2, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: tab === t.id ? 800 : 500,
color: tab === t.id ? '#1d4ed8' : 'var(--text-muted)',
background: 'transparent', transition: 'all 0.15s',
}}>
{t.icon} {t.label}
</button>
))}
</div>
{/*
ONGLET 1 : NOTES À VALIDER
*/}
{tab === 'notes' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Stat cards ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{[
{ label: 'Notes en attente', value: totalNotes, accent: '#1d4ed8', icon: '📋' },
{ label: 'Volume total', value: fmt(totalMontant), accent: '#7c3aed', icon: '💰' },
{ label: 'Sélection', value: `${selectedIds.length} note(s) — ${fmt(totalSelectionne)}`, accent: '#15803d', icon: '✅' },
].map((c, i) => (
<div key={i} style={{ ...card, padding: '16px 20px', borderLeft: `4px solid ${c.accent}` }}>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
{c.icon} {c.label}
</div>
<div style={{ fontSize: 18, fontWeight: 900, color: c.accent }}>{c.value}</div>
</div>
))}
</div>
{notesLoading ? (
<div style={{ ...card, padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
Chargement des notes...
</div>
) : notes.length === 0 ? (
<div style={{ ...card, padding: '64px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 52, marginBottom: 16 }}></div>
<div style={{ fontSize: 18, fontWeight: 800, color: 'var(--text-primary)', marginBottom: 8 }}>
Aucune note en attente
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Les notes soumises par le ValidateurFinance apparaîtront ici.
</div>
</div>
) : (
<>
{/* ── Barre d'action fixe ── */}
<div style={{
...card,
padding: '14px 20px',
background: 'linear-gradient(90deg,#eff6ff,var(--bg-card))',
borderLeft: '4px solid #1d4ed8',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
{/* Sélectionner tout */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={selectedIds.length === notes.length && notes.length > 0}
onChange={e => setSelectedIds(e.target.checked ? notes.map(n => n.id) : [])}
style={{ width: 16, height: 16 }}
/>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)' }}>
Tout sélectionner
</span>
</label>
{selectedIds.length > 0 && (
<span style={{ fontSize: 12, color: '#1d4ed8', fontWeight: 600 }}>
· {selectedIds.length} note(s) {fmt(totalSelectionne)}
</span>
)}
{/* Commentaire optionnel */}
<input
value={commentaire}
onChange={e => setCommentaire(e.target.value)}
placeholder="Commentaire de validation (optionnel)"
style={{ ...inputStyle, flex: 1, minWidth: 200, fontSize: 12, padding: '8px 12px' }}
/>
{/* Bouton générer XML */}
<button
disabled={selectedIds.length === 0 || xmlLoading}
onClick={handleGenererXml}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '11px 24px',
background: (selectedIds.length === 0 || xmlLoading)
? '#e2e8f0'
: 'linear-gradient(135deg,#1d4ed8,#1e40af)',
color: (selectedIds.length === 0 || xmlLoading) ? '#94a3b8' : '#fff',
border: 'none', borderRadius: 10,
cursor: (selectedIds.length === 0 || xmlLoading) ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 800,
boxShadow: selectedIds.length > 0 && !xmlLoading
? '0 4px 14px rgba(29,78,216,.4)' : 'none',
whiteSpace: 'nowrap',
transition: 'all 0.15s',
}}>
{xmlLoading ? (
<>
<div style={{
width: 14, height: 14,
border: '2px solid rgba(0,0,0,0.2)',
borderTop: '2px solid #64748b',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
Génération...
</>
) : (
<>🏦 Valider et générer XML ({selectedIds.length})</>
)}
</button>
</div>
</div>
{/* ── Tableau des notes ── */}
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 700 }}>
<thead>
<tr style={{ background: 'var(--bg-thead)' }}>
{['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Libellé', 'Montant', 'Vérificateur', 'Statut'].map(h => (
<th key={h} style={{
padding: '11px 14px', textAlign: 'left',
fontSize: 11, fontWeight: 700,
color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.6px', borderBottom: '1px solid var(--border-divider)',
whiteSpace: 'nowrap',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{notes.map(note => {
const isChecked = selectedIds.includes(note.id);
return (
<tr
key={note.id}
style={{
borderTop: '1px solid var(--border-divider)',
background: isChecked ? '#eff6ff' : undefined,
cursor: 'pointer', transition: 'background 0.1s',
}}
onClick={() => setSelectedIds(isChecked
? selectedIds.filter(id => id !== note.id)
: [...selectedIds, note.id]
)}
onMouseEnter={e => { if (!isChecked) e.currentTarget.style.background = 'var(--bg-input)'; }}
onMouseLeave={e => { e.currentTarget.style.background = isChecked ? '#eff6ff' : ''; }}
>
<td style={{ padding: '12px 14px' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => { }}
style={{ width: 16, height: 16, cursor: 'pointer' }}
/>
</td>
<td style={{ padding: '12px 14px', fontFamily: 'monospace', fontSize: 12, color: '#1d4ed8', fontWeight: 700, whiteSpace: 'nowrap' }}>
{note.reference}
</td>
<td style={{ padding: '12px 14px' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{note.collaborateur}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{note.collaborateurEmail}</div>
</td>
<td style={{ padding: '12px 14px' }}>
{note.campus ? (
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700 }}>
{normalizeCampus(note.campus)}
</span>
) : '—'}
</td>
<td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--text-muted)' }}>
{note.societe || '—'}
</td>
<td style={{ padding: '12px 14px', fontSize: 13, color: 'var(--text-secondary)', maxWidth: 180 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.libelle}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
{note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '—'}
</div>
</td>
<td style={{ padding: '12px 14px', fontWeight: 900, fontSize: 15, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
{fmt(note.montant)}
</td>
<td style={{ padding: '12px 14px' }}>
{note.nomVerificateur ? (
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{note.nomVerificateur}</div>
{note.dateVerification && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 1 }}>
{new Date(note.dateVerification).toLocaleDateString('fr-FR')}
</div>
)}
</div>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}></span>
)}
</td>
<td style={{ padding: '12px 14px' }}>
{tagStatut(note.statut)}
</td>
</tr>
);
})}
</tbody>
{/* Pied de tableau : total sélection */}
{selectedIds.length > 0 && (
<tfoot>
<tr style={{ borderTop: '2px solid #1d4ed8', background: '#eff6ff' }}>
<td colSpan={6} style={{ padding: '10px 14px', fontSize: 12, fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Total sélection ({selectedIds.length} note{selectedIds.length > 1 ? 's' : ''})
</td>
<td colSpan={3} style={{ padding: '10px 14px', fontSize: 16, fontWeight: 900, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
{fmt(totalSelectionne)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
{/* ── Récap bouton bas ── */}
{selectedIds.length > 0 && (
<div style={{
...card,
padding: '18px 24px',
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 14,
}}>
<div style={{ color: '#fff' }}>
<div style={{ fontSize: 14, fontWeight: 800 }}>
💼 Prêt à valider {selectedIds.length} virement{selectedIds.length > 1 ? 's' : ''}
</div>
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 3 }}>
Montant total : <strong>{fmt(totalSelectionne)}</strong>
{commentaire.trim() && ` · "${commentaire.trim()}"`}
</div>
<div style={{ fontSize: 11, opacity: 0.65, marginTop: 3 }}>
La mention "Validé par le Président {user.prenom} {user.nom}" sera inscrite dans le fichier XML
</div>
</div>
<button
disabled={xmlLoading}
onClick={handleGenererXml}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 32px',
background: '#fff',
color: '#1d4ed8',
border: 'none', borderRadius: 12,
cursor: xmlLoading ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', fontSize: 15, fontWeight: 900,
boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
flexShrink: 0,
}}>
{xmlLoading ? '⏳ Génération...' : '🏦 Valider et générer XML →'}
</button>
</div>
)}
</>
)}
</div>
)}
{/*
ONGLET 2 : HISTORIQUE
*/}
{tab === 'historique' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Filtres */}
<div style={{ ...card, padding: '14px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
🔍 Filtrer
</span>
<select
value={xmlFiltreAnnee}
onChange={e => setXmlFiltreAnnee(e.target.value)}
style={{ ...inputStyle, width: 140 }}>
<option value="">Toutes les années</option>
{[2024, 2025, 2026, 2027].map(a => (
<option key={a} value={String(a)}>{a}</option>
))}
</select>
<select
value={xmlFiltreMois}
onChange={e => setXmlFiltreMois(e.target.value)}
style={{ ...inputStyle, width: 160 }}>
<option value="">Tous les mois</option>
{[
['1', 'Janvier'], ['2', 'Février'], ['3', 'Mars'], ['4', 'Avril'],
['5', 'Mai'], ['6', 'Juin'], ['7', 'Juillet'], ['8', 'Août'],
['9', 'Septembre'], ['10', 'Octobre'], ['11', 'Novembre'], ['12', 'Décembre']
].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<button
onClick={loadHistorique}
style={{
padding: '9px 18px', borderRadius: 8, border: 'none',
background: '#1d4ed8', color: '#fff', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
}}>
🔄 Actualiser
</button>
</div>
</div>
{historiqueLoading ? (
<div style={{ ...card, padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
Chargement...
</div>
) : historique.length === 0 ? (
<div style={{ ...card, padding: '64px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>📂</div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
Aucune validation trouvée
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 8 }}>
Les virements que vous avez validés apparaîtront ici.
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{historique.map((batch, i) => {
const dateXml = new Date(batch.dateXmlExacte || batch.dateXmlJour);
const dateLabel = dateXml.toLocaleDateString('fr-FR', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric'
});
const heureLabel = dateXml.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
return (
<div key={i} style={card}>
{/* En-tête batch */}
<div style={{
padding: '16px 22px',
background: 'linear-gradient(90deg,#eff6ff,var(--bg-card))',
borderBottom: '1px solid var(--border-divider)',
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg,#1d4ed8,#1e40af)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20,
}}>🏦</div>
<div>
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
XML du {dateLabel}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{heureLabel} · {batch.nbNotes} virement{batch.nbNotes > 1 ? 's' : ''}
</div>
{/* Badge Président */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
marginTop: 6, padding: '4px 10px',
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
borderRadius: 20, fontSize: 11, color: '#fff', fontWeight: 700,
}}>
💼 Validé par le Président {batch.presidentNom || `${user.prenom} ${user.nom}`}
</div>
{batch.commentairePresident && (
<div style={{ fontSize: 11, color: '#6366f1', marginTop: 4, fontStyle: 'italic' }}>
💬 {batch.commentairePresident}
</div>
)}
</div>
</div>
<div style={{
fontSize: 20, fontWeight: 900, color: '#1d4ed8',
background: '#eff6ff', border: '1.5px solid #bfdbfe',
padding: '8px 18px', borderRadius: 10,
}}>
{fmt(parseFloat(String(batch.totalMontant)) || 0)}
</div>
</div>
{/* Références */}
<div style={{
padding: '10px 22px',
background: 'var(--bg-thead)',
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
}}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', flexShrink: 0 }}>
Virements :
</span>
{(batch.listeReferences || '').split(', ').slice(0, 8).map((ref, ri) => (
<span key={ri} style={{
fontSize: 11, fontWeight: 700,
background: '#eff6ff', color: '#1d4ed8',
padding: '2px 8px', borderRadius: 6, fontFamily: 'monospace',
}}>
{ref.trim()}
</span>
))}
{(batch.listeReferences || '').split(', ').length > 8 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontStyle: 'italic' }}>
+{(batch.listeReferences || '').split(', ').length - 8} autres
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Animation spinner */}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
};
export default PresidentValidation;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,5 +23,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [ "src" ]
"include": [ "src/**/*" ]
}