Version_Chatbot
CI-CD NDF Complet / test-and-quality (push) Successful in 1m5s
CI-CD NDF Complet / deploy (push) Successful in 1m27s

This commit is contained in:
2026-05-19 15:17:34 +02:00
parent 3d5923c49a
commit eb6ba9f78e
11 changed files with 5372 additions and 2270 deletions
+227 -131
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,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 });
}
}
}
+1739 -883
View File
File diff suppressed because it is too large Load Diff
+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;
};
+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>
+1040 -370
View File
File diff suppressed because it is too large Load Diff
+903
View File
@@ -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<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 && (
<div style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, flexShrink: 0,
}}>🤖</div>
)}
<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 }}>
<div style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, flexShrink: 0,
}}>🤖</div>
<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 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<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>NDF BOT</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,
}}>
<div style={{
width: 34, height: 34, borderRadius: "50%",
background: "rgba(255,255,255,0.18)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 16, flexShrink: 0,
}}>🤖</div>
<div style={{ flex: 1 }}>
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Assistant NDF</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)",
}}>
Assistant NDF · ENSUP Group
</div>
</div>
)}
</>
);
}
+503 -162
View File
@@ -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<string, string> = {};
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<ExcelRow[]>(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 && (
<div className="nn-acc-body">
{/* Bandeau repas visible dès la sélection de la catégorie */}
{isRepas && (
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
<span className="nn-repas-info-icon">🍽</span>
<span>
Catégorie <strong>Repas</strong> sélectionnée plafond <strong>25 / personne</strong> (sauf clause repas événementiel).
Catégorie <strong>Repas</strong> sélectionnée plafond <strong>25 / personne</strong> (sauf repas événementiel).
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
</span>
</div>
@@ -1136,7 +1267,6 @@ const DepenseCard = React.memo(({
vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
</div>
{/* Cheval fiscal : depuis profil ou sélecteur manuel */}
{profilVehicule ? (
<div style={{
display: "flex", alignItems: "center", gap: 10,
@@ -1198,7 +1328,7 @@ const DepenseCard = React.memo(({
</div>
)}
{/* ── TVA EXCEL — Repas / Hébergement / Transport ── */}
{/* ── TVA EXCEL ── */}
{!isKm && isExcel && (
<TvaExcelTable
tvaItems={tvaItems}
@@ -1206,7 +1336,44 @@ const DepenseCard = React.memo(({
/>
)}
{/* ── TVA CLASSIQUE — Autre ── */}
{/* ── NUITS (Hébergement) ── */}
{depense.categorie.toLowerCase().includes("hebergement") && (
<div className="nn-nuits-box">
<div>
<div className="nn-nuits-label">🌙 Nombre de nuits</div>
{ttcTotal > 0 && (parseInt(depense.nuits) || 0) > 0 && (
<div style={{ fontSize: 10, color: "#6366f1", marginTop: 2 }}>
Soit <span className="nn-nuits-par-nuit">
{fmt(ttcTotal / (parseInt(depense.nuits) || 1))}
</span> / nuit
</div>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button type="button" className="nn-tva-btn rm"
disabled={(parseInt(depense.nuits) || 0) <= 1}
onClick={() => set("nuits", String(Math.max(1, (parseInt(depense.nuits) || 1) - 1)))}>
</button>
<input
type="number" min="1" className="nn-input"
style={{ maxWidth: 64, textAlign: "center", borderColor: "rgba(99,102,241,.35)" }}
value={depense.nuits}
onChange={e => set("nuits", e.target.value)}
placeholder="1"
/>
<button type="button" className="nn-tva-btn add"
onClick={() => set("nuits", String((parseInt(depense.nuits) || 0) + 1))}>
+
</button>
<span style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, whiteSpace: "nowrap" }}>
nuit{(parseInt(depense.nuits) || 0) > 1 ? "s" : ""}
</span>
</div>
</div>
)}
{/* ── TVA CLASSIQUE ── */}
{!isKm && !isExcel && (
<div className="nn-tva-box">
<div className="nn-tva-head">
@@ -1269,115 +1436,175 @@ const DepenseCard = React.memo(({
</div>
)}
{/* ── REPAS — bandeau info + participants + alerte 25€ ── */}
{isRepas && (
{/* ── REPAS — participants + alertes ── */}
{isRepas && (
<div className="nn-repas-box">
<div className="nn-repas-title">🍽 Participants</div>
{/* Bandeau info toujours visible dès que catégorie = Repas */}
<div className="nn-repas-info">
<span className="nn-repas-info-icon"></span>
<span>
Un repas professionnel <strong>ne doit pas dépasser 25 par personne</strong> (sauf clause repas événementiel).
Indiquez le nombre de convives ci-dessous le montant par personne est calculé automatiquement.
Un repas professionnel <strong>ne doit pas dépasser 25 par personne</strong> (sauf repas événementiel).
</span>
</div>
{/* Alerte si dépassement */}
{repasAlerte && (
<div className="nn-repas-info alert">
<span className="nn-repas-info-icon"></span>
<span>
<strong>Plafond dépassé !</strong> Le repas revient à{" "}
<strong>{new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}</strong>{" "}
par personne ({nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""}).
Seule la clause <em>repas événementiel</em> autorise le dépassement des 25 .
</span>
</div>
)}
{/* Contrôle nombre de participants */}
<div className="nn-field" style={{ marginBottom: 10 }}>
<label className="nn-fl" style={{ color: "#92400e" }}>
Nombre de convives invités{" "}
<span style={{ color: "#92400e", fontWeight: 400, textTransform: "none", fontSize: 9 }}>
(0 = repas seul vous êtes toujours compté dans le total)
</span>
</label>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{/* Bouton */}
{/* ── Choix seul ou accompagné ── */}
<div className="nn-field" style={{ marginBottom: 12 }}>
<label className="nn-fl" style={{ color: "#92400e" }}>Type de repas <span>*</span></label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className="nn-p-btn rm"
disabled={(parseInt(depense.nombreParticipants) || 0) <= 0}
onClick={() => handleNombreParticipants(String(Math.max(0, (parseInt(depense.nombreParticipants) || 0) - 1)))}
></button>
<input
type="number" min="0" className="nn-input"
style={{ borderColor: repasAlerte ? "rgba(239,68,68,.5)" : "rgba(251,191,36,.4)", maxWidth: 80, textAlign: "center" }}
value={depense.nombreParticipants}
onChange={e => 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
</button>
<button
type="button"
className="nn-p-btn add"
onClick={() => handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 0) + 1))}
>+</button>
{/* Résumé convives */}
{(parseInt(depense.nombreParticipants) || 0) === 0 ? (
<span className="nn-repas-seul">🧑 Repas seul</span>
) : (
<span style={{ fontSize: 11, color: repasAlerte ? "#dc2626" : "#92400e", fontWeight: 700 }}>
{nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""} au total
{ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
</span>
)}
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é
</button>
</div>
</div>
{/* Liste des participants */}
{depense.participants.length > 0 && (
<div>
<div className="nn-participant-row" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Nom *</span>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Prénom *</span>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Société</span>
<div /><div />
</div>
{depense.participants.map((p, i) => (
<div key={i} className="nn-participant-row">
<input className="nn-input"
style={{ fontSize: 12, borderColor: !p.nom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
value={p.nom}
onChange={e => updP(i, "nom", e.target.value)}
placeholder={`Nom ${i + 1} *`} />
<input className="nn-input"
style={{ fontSize: 12, borderColor: !p.prenom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
value={p.prenom}
onChange={e => updP(i, "prenom", e.target.value)}
placeholder="Prénom *" />
<input className="nn-input" style={{ fontSize: 12 }} value={p.societe || ""} onChange={e => updP(i, "societe", e.target.value)} placeholder="Société" />
{/* + ajouter après cette ligne */}
<button type="button" className="nn-p-btn add" title="Insérer un participant après"
onClick={() => {
const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
set("participants", newList);
set("nombreParticipants", String(newList.length));
}}>+</button>
{/* supprimer cette ligne */}
<button type="button" className="nn-p-btn rm" title="Retirer ce participant"
onClick={() => {
const newList = depense.participants.filter((_, j) => j !== i);
set("participants", newList);
set("nombreParticipants", String(newList.length));
}}></button>
{/* ── Si accompagné : sélecteur nombre + champs ── */}
{depense.nombreParticipants !== "" && parseInt(depense.nombreParticipants) > 0 && (
<>
<div className="nn-field" style={{ marginBottom: 10 }}>
<label className="nn-fl" style={{ color: "#92400e" }}>
Nombre de convives invités{" "}
<span style={{ color: "#92400e", fontWeight: 400, textTransform: "none", fontSize: 9 }}>
(vous êtes toujours compté dans le total)
</span>
</label>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button type="button" className="nn-p-btn rm"
disabled={(parseInt(depense.nombreParticipants) || 0) <= 1}
onClick={() => handleNombreParticipants(String(Math.max(1, (parseInt(depense.nombreParticipants) || 1) - 1)))}
></button>
<input type="number" min="1" className="nn-input"
style={{ borderColor: repasAlerte ? "rgba(245,158,11,.5)" : "rgba(251,191,36,.4)", maxWidth: 80, textAlign: "center" }}
value={depense.nombreParticipants}
onChange={e => {
const v = Math.max(1, parseInt(e.target.value) || 1);
handleNombreParticipants(String(v));
}}
placeholder="1"
/>
<button type="button" className="nn-p-btn add"
onClick={() => handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 1) + 1))}
>+</button>
<span style={{ fontSize: 11, color: repasAlerte ? "#b45309" : "#92400e", fontWeight: 700 }}>
{nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""} au total
{ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
</span>
</div>
))}
</div>
{/* Alertes */}
{repasAlerte && (
<div className="nn-repas-info warn" style={{ marginBottom: 8 }}>
<span className="nn-repas-info-icon"></span>
<span>
<strong>Attention :</strong> ce repas revient à{" "}
<strong>{new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}</strong>{" "}
par personne, au-delà du plafond de 25 .
La soumission reste possible mentionnez le contexte dans le commentaire.
</span>
</div>
)}
{isEvenementiel && ttcParPersonne > 25 && (
<div className="nn-repas-info" style={{ background: "rgba(99,102,241,.07)", borderColor: "rgba(99,102,241,.35)", color: "#4338ca", marginBottom: 8 }}>
<span className="nn-repas-info-icon">🎉</span>
<span>Repas <strong>événementiel</strong> détecté le plafond de 25 /personne ne s'applique pas.</span>
</div>
)}
{/* Champs participants */}
<div>
<div className="nn-participant-row" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Nom *</span>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Prénom *</span>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", color: "#92400e", letterSpacing: ".04em" }}>Société</span>
<div /><div />
</div>
{depense.participants.map((p, i) => (
<div key={i} className="nn-participant-row">
<input className="nn-input"
style={{ fontSize: 12, borderColor: !p.nom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
value={p.nom} onChange={e => updP(i, "nom", e.target.value)}
placeholder={`Nom ${i + 1} *`} />
<input className="nn-input"
style={{ fontSize: 12, borderColor: !p.prenom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
value={p.prenom} onChange={e => updP(i, "prenom", e.target.value)}
placeholder="Prénom *" />
<input className="nn-input" style={{ fontSize: 12 }}
value={p.societe || ""} onChange={e => updP(i, "societe", e.target.value)}
placeholder="Société" />
<button type="button" className="nn-p-btn add"
onClick={() => {
const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
set("participants", newList);
set("nombreParticipants", String(newList.length));
}}>+</button>
<button type="button" className="nn-p-btn rm"
onClick={() => {
const newList = depense.participants.filter((_, j) => j !== i);
if (newList.length === 0) {
handleNombreParticipants("0");
} else {
set("participants", newList);
set("nombreParticipants", String(newList.length));
}
}}></button>
</div>
))}
</div>
</>
)}
{/* ── Si repas seul : badge confirmation ── */}
{/* ── Aucun choix encore ── */}
{depense.nombreParticipants === "" && (
<div style={{ fontSize: 11, color: "#92400e", fontWeight: 600, opacity: 0.7, textAlign: "center", padding: "4px 0" }}>
↑ Choisissez si vous étiez seul ou accompagné
</div>
)}
</div>
@@ -1490,11 +1717,12 @@ export default function NouvelleNote({
const [dateFin, setDateFin] = useState("");
const [commentaire, setComment] = useState(commentaireInitial || "");
const [depenses, setDepenses] = useState<Depense[]>(() => initDepenses(7));
// Profil véhicule — chargé depuis l'API
const [profilVehicule, setProfilVehicule] = useState<ProfilVehiculeData | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(() => {
const init = initDepenses(); return init.length > 0 ? init[0].id : null;
});
const [selectedBrouillonIds, setSelectedBrouillonIds] = useState<Set<number>>(new Set());
const [deletingAll, setDeletingAll] = useState(false);
const [brouillons, setBrouillons] = useState<BrouillonServeur[]>([]);
const [activeBrouillonId, setActiveBrouillonId] = useState<number | null>(initialBrouillonId);
@@ -1509,7 +1737,6 @@ export default function NouvelleNote({
const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef<number | null>(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({
</div>
)}
<div className="nn-bro-bar">
<div className="nn-bro-bar">
<div className="nn-bro-head">
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{loadingBrouillons ? "Chargement…" : `${brouillons.length} brouillon(s)`}
{!activeBrouillon && <SaveIndicator />}
</span>
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
{/* Supprimer la sélection — visible si liste ouverte et items sélectionnés */}
{showBrouillonList && selectedBrouillonIds.size > 0 && (
<button className="nn-btn-sm red" onClick={handleDeleteSelected} disabled={deletingAll}>
{deletingAll ? "⏳" : "🗑"} Supprimer ({selectedBrouillonIds.size})
</button>
)}
{/* Tout supprimer — visible si aucune sélection active */}
{brouillons.length > 0 && selectedBrouillonIds.size === 0 && (
<button className="nn-btn-sm red" onClick={handleDeleteAll} disabled={deletingAll} style={{ opacity: 0.75 }}>
{deletingAll ? "⏳" : "🗑"} Tout supprimer
</button>
)}
{brouillons.length > 0 && (
<button className="nn-btn-sm indigo" onClick={() => setShowBrouillonList(v => !v)}>
<button className="nn-btn-sm indigo" onClick={() => {
setShowBrouillonList(v => !v);
if (showBrouillonList) setSelectedBrouillonIds(new Set());
}}>
{showBrouillonList ? "▲ Réduire" : "▼ Voir"}
</button>
)}
<button className="nn-btn-sm green" onClick={handleNewBrouillon}>+ Nouvelle note</button>
</div>
</div>
{/* Ligne "Tout sélectionner" */}
{showBrouillonList && brouillons.length > 1 && (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 14px", borderBottom: "1px solid rgba(0,0,0,.05)",
background: "rgba(0,0,0,.015)",
}}>
<input
type="checkbox"
checked={selectedBrouillonIds.size === brouillons.length && brouillons.length > 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" }}
/>
<span style={{ fontSize: 10, fontWeight: 700, color: "var(--text-secondary, #6b7280)", textTransform: "uppercase", letterSpacing: ".04em" }}>
Tout sélectionner
</span>
{selectedBrouillonIds.size > 0 && (
<span style={{ fontSize: 10, color: "#6366f1", fontWeight: 600 }}>
{selectedBrouillonIds.size} sélectionné(s)
</span>
)}
</div>
)}
{/* Liste des brouillons */}
{showBrouillonList && brouillons.map(b => (
<div key={b.id} className={`nn-bro-row${b.id === activeBrouillonId ? " active" : ""}`}>
<input
type="checkbox"
checked={selectedBrouillonIds.has(b.id)}
onChange={e => {
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 }}
/>
<div className="nn-bro-name" style={{ color: b.id === activeBrouillonId ? "#6366f1" : undefined }}>
{b.libelle || "Sans titre"}
{b.id === activeBrouillonId && <span className="nn-bro-active-tag">EN COURS</span>}
</div>
<span className="nn-bro-date">{new Date(b.DateModification).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })}</span>
<span className="nn-bro-date">
{new Date(b.DateModification).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })}
</span>
<div style={{ display: "flex", gap: 5 }}>
{b.id !== activeBrouillonId && <button className="nn-btn-sm indigo" onClick={() => loadBrouillon(b)}>Ouvrir</button>}
{b.id !== activeBrouillonId && (
<button className="nn-btn-sm indigo" onClick={() => loadBrouillon(b)}>Ouvrir</button>
)}
<button className="nn-btn-sm red" onClick={() => handleDeleteBrouillon(b.id)}>🗑</button>
</div>
</div>
@@ -1825,7 +2167,6 @@ export default function NouvelleNote({
{submitError && <div className="nn-error"> {submitError}</div>}
{/* ── META — 3 colonnes (Titre / Date / Commentaire) — montant déclaré supprimé ── */}
<div className="nn-meta">
<div className="nn-meta-field">
<label>Titre <span>*</span></label>
@@ -1900,4 +2241,4 @@ export default function NouvelleNote({
</div>
</>
);
}
}
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/**/*" ]
}