Version_Chatbot
This commit is contained in:
@@ -1,20 +1,9 @@
|
|||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// ndfPdfGenerator.js — v4 (pdfkit pur, 0% Python)
|
// ndfPdfGenerator.js — v5 (pdfkit pur, support proratisation repas)
|
||||||
// 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.
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// CONSTANTES
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
blue: '#1B4F8A', header: '#2563EB',
|
blue: '#1B4F8A', header: '#2563EB',
|
||||||
totalBg: '#DBEAFE', altRow: '#EFF6FF',
|
totalBg: '#DBEAFE', altRow: '#EFF6FF',
|
||||||
@@ -27,14 +16,11 @@ const C = {
|
|||||||
validBg: '#EEF2FF', validBorder: '#C7D2FE', validText: '#4F46E5',
|
validBg: '#EEF2FF', validBorder: '#C7D2FE', validText: '#4F46E5',
|
||||||
refusBg: '#FEF2F2', refusBorder: '#FCA5A5', refusText: '#DC2626',
|
refusBg: '#FEF2F2', refusBorder: '#FCA5A5', refusText: '#DC2626',
|
||||||
waitBg: '#F8FAFC', waitBorder: '#E2E8F0',
|
waitBg: '#F8FAFC', waitBorder: '#E2E8F0',
|
||||||
kmBg: '#F5F3FF', // fond violet clair pour colonne tarif km
|
kmBg: '#F5F3FF', kmBorder: '#DDD6FE', kmText: '#7C3AED', kmTotalBg: '#EDE9FE',
|
||||||
kmBorder: '#DDD6FE', // bordure violet clair
|
// ✅ Nouveaux — lignes proratisées
|
||||||
kmText: '#7C3AED', // texte violet
|
prorataBg: '#FFFBEB', prorataText: '#D97706', prorataBorder: '#FDE68A',
|
||||||
kmTotalBg: '#EDE9FE', // fond sous-total km
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Colonnes tableau (largeurs en points)
|
|
||||||
// ── v4 : 'tarifKm' et 'sousKm' insérées après 'km' ──
|
|
||||||
const COLS = [
|
const COLS = [
|
||||||
{ key: 'num', label: 'N°pièce', w: 34, align: 'center' },
|
{ key: 'num', label: 'N°pièce', w: 34, align: 'center' },
|
||||||
{ key: 'date', label: 'Date', w: 58, align: 'left' },
|
{ key: 'date', label: 'Date', w: 58, align: 'left' },
|
||||||
@@ -51,14 +37,13 @@ const COLS = [
|
|||||||
{ key: 'ht', label: 'HT', w: 50, align: 'right' },
|
{ key: 'ht', label: 'HT', w: 50, align: 'right' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Colonnes km (pour coloration spéciale)
|
|
||||||
const KM_COLS = ['km', 'tarifKm', 'sousKm'];
|
const KM_COLS = ['km', 'tarifKm', 'sousKm'];
|
||||||
|
|
||||||
const MARGIN = 30;
|
const MARGIN = 30;
|
||||||
const ROW_H = 16;
|
const ROW_H = 16;
|
||||||
const HEAD_H = 20;
|
const HEAD_H = 20;
|
||||||
const PAGE_W = 841.89; // A4 largeur
|
const PAGE_W = 841.89;
|
||||||
const PAGE_H = 595.28; // A4 hauteur
|
const PAGE_H = 595.28;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// HELPERS
|
// HELPERS
|
||||||
@@ -89,11 +74,8 @@ function fmtDateTime(s) {
|
|||||||
|
|
||||||
function drawRect(doc, x, y, w, h, fill, stroke = null, lw = 0.3) {
|
function drawRect(doc, x, y, w, h, fill, stroke = null, lw = 0.3) {
|
||||||
doc.save();
|
doc.save();
|
||||||
if (stroke) {
|
if (stroke) doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke);
|
||||||
doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke);
|
else doc.rect(x, y, w, h).fill(fill);
|
||||||
} else {
|
|
||||||
doc.rect(x, y, w, h).fill(fill);
|
|
||||||
}
|
|
||||||
doc.restore();
|
doc.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,47 +85,108 @@ function drawCellText(doc, text, x, y, w, h, font, size, color, align, padX = 3)
|
|||||||
doc.save().font(font).fontSize(size).fillColor(color);
|
doc.save().font(font).fontSize(size).fillColor(color);
|
||||||
while (text.length > 1 && doc.widthOfString(text) > maxW) text = text.slice(0, -1);
|
while (text.length > 1 && doc.widthOfString(text) > maxW) text = text.slice(0, -1);
|
||||||
const ty = y + h * 0.28;
|
const ty = y + h * 0.28;
|
||||||
if (align === 'right') {
|
if (align === 'right') doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false });
|
||||||
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 if (align === 'center') {
|
else doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false });
|
||||||
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();
|
doc.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawVLine(doc, x, y1, y2) {
|
function drawVLine(doc, x, y1, y2) {
|
||||||
doc.save().strokeColor(C.border).lineWidth(0.4)
|
doc.save().strokeColor(C.border).lineWidth(0.4).moveTo(x, y1).lineTo(x, y2).stroke().restore();
|
||||||
.moveTo(x, y1).lineTo(x, y2).stroke().restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawHLine(doc, x1, x2, y) {
|
function drawHLine(doc, x1, x2, y) {
|
||||||
doc.save().strokeColor(C.border).lineWidth(0.3)
|
doc.save().strokeColor(C.border).lineWidth(0.3).moveTo(x1, y).lineTo(x2, y).stroke().restore();
|
||||||
.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) => {
|
return (lignesParsed || []).map((l, idx) => {
|
||||||
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
|
||||||
const km = parseFloat(l.km) || 0;
|
const km = parseFloat(l.km) || 0;
|
||||||
const ttc = isKm ? 0 : (parseFloat(l.montant) || 0);
|
const cv = parseInt(l.chevaux) || 7;
|
||||||
const taux = parseFloat(l.tauxTVA) || 0;
|
|
||||||
let ht = ttc, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0;
|
|
||||||
|
|
||||||
if (!isKm && taux > 0 && ttc > 0) {
|
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));
|
ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2));
|
||||||
const tvaM = parseFloat((ttc - ht).toFixed(2));
|
const tvaM = parseFloat((ttc - ht).toFixed(2));
|
||||||
if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM;
|
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 - 5.5) < 0.01) tva55 = tvaM;
|
||||||
else if (Math.abs(taux - 10) < 0.01) tva10 = 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 - 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 {
|
return {
|
||||||
numPiece: idx + 1,
|
numPiece: idx + 1,
|
||||||
@@ -151,20 +194,21 @@ export function preparerLignesPDF(lignesParsed, tarifKm = TARIF_KM_DEFAULT) {
|
|||||||
nature: l.categorie || '',
|
nature: l.categorie || '',
|
||||||
libelle: l.libelle || '',
|
libelle: l.libelle || '',
|
||||||
km: isKm ? km : 0,
|
km: isKm ? km : 0,
|
||||||
tarifKmVal: isKm ? tarifKm : 0, // ← valeur numérique tarif
|
tarifKmVal: isKm ? tarifKmAffiche : 0,
|
||||||
montantTTC: isKm ? 0 : ttc,
|
montantTTC: isKm ? 0 : ttc,
|
||||||
tva21, tva55, tva10, tva20,
|
tva21, tva55, tva10, tva20,
|
||||||
montantHT: isKm ? 0 : ht,
|
montantHT: isKm ? 0 : ht,
|
||||||
indemniteKm,
|
indemniteKm,
|
||||||
|
montantAjuste,
|
||||||
|
montantOriginal,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// generateFicheSignee — point d'entrée appelé depuis server.js
|
// generateFicheSignee
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export async function generateFicheSignee(note, signatures = []) {
|
export async function generateFicheSignee(note, signatures = []) {
|
||||||
const tarifKm = parseFloat(note.tarifKm) || TARIF_KM_DEFAULT;
|
const tarifKm = parseFloat(note.tarifKm) || 0.697;
|
||||||
|
|
||||||
let lignesPDF = [];
|
let lignesPDF = [];
|
||||||
if (note.lignesJson) {
|
if (note.lignesJson) {
|
||||||
@@ -176,16 +220,20 @@ export async function generateFicheSignee(note, signatures = []) {
|
|||||||
} else if (note.lignes && Array.isArray(note.lignes)) {
|
} else if (note.lignes && Array.isArray(note.lignes)) {
|
||||||
lignesPDF = preparerLignesPDF(note.lignes, tarifKm);
|
lignesPDF = preparerLignesPDF(note.lignes, tarifKm);
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback ligne unique (rétrocompat)
|
||||||
const isKm = !!(note.km && parseFloat(note.km) > 0);
|
const isKm = !!(note.km && parseFloat(note.km) > 0);
|
||||||
const km = isKm ? 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 = [{
|
lignesPDF = [{
|
||||||
numPiece: 1, date: note.date, nature: note.categorie || '', libelle: note.libelle || '',
|
numPiece: 1, date: note.date, nature: note.categorie || '', libelle: note.libelle || '',
|
||||||
km: isKm ? km : 0,
|
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),
|
montantTTC: isKm ? 0 : parseFloat(note.montant || 0),
|
||||||
tva21: 0, tva55: 0, tva10: 0, tva20: 0,
|
tva21: 0, tva55: 0, tva10: 0, tva20: 0,
|
||||||
montantHT: isKm ? 0 : parseFloat(note.montantHT || note.montant || 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 }) {
|
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'A4',
|
size: 'A4', layout: 'landscape', margin: 0,
|
||||||
layout: 'landscape',
|
|
||||||
margin: 0,
|
|
||||||
info: {
|
info: {
|
||||||
Title: `Note de Frais ${reference}`,
|
Title: `Note de Frais ${reference}`,
|
||||||
Author: `ENSUP — ${nomPrenom}`,
|
Author: `ENSUP — ${nomPrenom}`,
|
||||||
Subject: `NDF ${reference}`,
|
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('end', () => resolve(Buffer.concat(chunks)));
|
||||||
doc.on('error', e => reject(e));
|
doc.on('error', e => reject(e));
|
||||||
|
|
||||||
// ── Positions X colonnes ─────────────────────────────────────
|
// Positions X colonnes
|
||||||
const colX = {};
|
const colX = {};
|
||||||
let cx = MARGIN;
|
let cx = MARGIN;
|
||||||
for (const col of COLS) { colX[col.key] = cx; cx += col.w; }
|
for (const col of COLS) { colX[col.key] = cx; cx += col.w; }
|
||||||
const tableW = cx - MARGIN;
|
const tableW = cx - MARGIN;
|
||||||
|
|
||||||
// ── 2. BANDEAU ───────────────────────────────────────────────
|
// ── Bandeau titre ────────────────────────────────────────────
|
||||||
const bandY = 44;
|
const bandY = 44;
|
||||||
drawRect(doc, MARGIN, bandY, tableW, 18, C.header);
|
drawRect(doc, MARGIN, bandY, tableW, 18, C.header);
|
||||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.white)
|
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.white)
|
||||||
.text('NOTE DE FRAIS', MARGIN, bandY + 3.5, { width: tableW, align: 'center', lineBreak: false });
|
.text('NOTE DE FRAIS', MARGIN, bandY + 3.5, { width: tableW, align: 'center', lineBreak: false });
|
||||||
|
|
||||||
// ── 3. INFOS COLLAB ──────────────────────────────────────────
|
// ── Infos collaborateur ──────────────────────────────────────
|
||||||
const infoY = bandY + 23;
|
const infoY = bandY + 23;
|
||||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||||
.text(`NOM : ${nomPrenom}`, MARGIN, infoY, { lineBreak: false });
|
.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)
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||||
.text(`Mois : ${mois}`, MARGIN, infoY + 13, { lineBreak: false });
|
.text(`Mois : ${mois}`, MARGIN, infoY + 13, { lineBreak: false });
|
||||||
|
|
||||||
// ── 4. EN-TÊTE COLONNES ──────────────────────────────────────
|
// ── En-tête colonnes ─────────────────────────────────────────
|
||||||
const tableTop = infoY + 27;
|
const tableTop = infoY + 27;
|
||||||
|
|
||||||
// Fond de base
|
|
||||||
drawRect(doc, MARGIN, tableTop, tableW, HEAD_H, C.headerRow, C.border, 0.5);
|
drawRect(doc, MARGIN, tableTop, tableW, HEAD_H, C.headerRow, C.border, 0.5);
|
||||||
|
for (const key of KM_COLS)
|
||||||
// Fond spécial violet pour les 3 colonnes km dans l'en-tête
|
|
||||||
for (const key of KM_COLS) {
|
|
||||||
drawRect(doc, colX[key], tableTop, COLS.find(c => c.key === key).w, HEAD_H, C.kmBg);
|
drawRect(doc, colX[key], tableTop, COLS.find(c => c.key === key).w, HEAD_H, C.kmBg);
|
||||||
}
|
|
||||||
|
|
||||||
for (const col of COLS) {
|
for (const col of COLS) {
|
||||||
const isKmCol = KM_COLS.includes(col.key);
|
const isKmCol = KM_COLS.includes(col.key);
|
||||||
drawCellText(
|
drawCellText(doc, col.label, colX[col.key], tableTop, col.w, HEAD_H,
|
||||||
doc, col.label,
|
|
||||||
colX[col.key], tableTop, col.w, HEAD_H,
|
|
||||||
'Helvetica-Bold', isKmCol ? 6.5 : 7,
|
'Helvetica-Bold', isKmCol ? 6.5 : 7,
|
||||||
isKmCol ? C.kmText : C.dark,
|
isKmCol ? C.kmText : C.dark, col.align);
|
||||||
col.align
|
|
||||||
);
|
|
||||||
drawVLine(doc, colX[col.key], tableTop, tableTop + HEAD_H);
|
drawVLine(doc, colX[col.key], tableTop, tableTop + HEAD_H);
|
||||||
}
|
}
|
||||||
drawVLine(doc, MARGIN + tableW, tableTop, tableTop + HEAD_H);
|
drawVLine(doc, MARGIN + tableW, tableTop, tableTop + HEAD_H);
|
||||||
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop);
|
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop);
|
||||||
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop + HEAD_H);
|
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop + HEAD_H);
|
||||||
|
|
||||||
// ── 5. LIGNES DONNÉES ────────────────────────────────────────
|
// ── Lignes données ───────────────────────────────────────────
|
||||||
const MIN_ROWS = 18;
|
const MIN_ROWS = 18;
|
||||||
const totalRows = Math.max(MIN_ROWS, lignes.length);
|
const totalRows = Math.max(MIN_ROWS, lignes.length);
|
||||||
let y = tableTop + HEAD_H;
|
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++) {
|
for (let i = 0; i < totalRows; i++) {
|
||||||
const lig = lignes[i] || null;
|
const lig = lignes[i] || null;
|
||||||
// Fond de ligne alterné
|
const isProrata = lig?.montantAjuste === true;
|
||||||
drawRect(doc, MARGIN, y, tableW, ROW_H, i % 2 === 1 ? C.altRow : C.white);
|
|
||||||
|
|
||||||
// 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) {
|
for (const key of KM_COLS) {
|
||||||
const col = COLS.find(c => c.key === key);
|
const col = COLS.find(c => c.key === key);
|
||||||
drawRect(doc, colX[key], y, col.w, ROW_H,
|
drawRect(doc, colX[key], y, col.w, ROW_H,
|
||||||
@@ -315,13 +357,16 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
totHT += ht;
|
totHT += ht;
|
||||||
totSousKm += sousKm;
|
totSousKm += sousKm;
|
||||||
|
|
||||||
|
// ✅ Libellé enrichi si proratisé : afficher montant original barré
|
||||||
|
let libelleAffiche = lig.libelle || '';
|
||||||
|
|
||||||
const r = {
|
const r = {
|
||||||
num: String(lig.numPiece || i + 1),
|
num: String(lig.numPiece || i + 1),
|
||||||
date: fmtDate(lig.date),
|
date: fmtDate(lig.date),
|
||||||
nature: lig.nature || '',
|
nature: lig.nature || '',
|
||||||
lib: lig.libelle || '',
|
lib: libelleAffiche,
|
||||||
km: km > 0 ? f2(km) : '',
|
km: km > 0 ? f2(km) : '',
|
||||||
tarifKm: tarif > 0 ? f3(tarif) : '', // ex: 0.697
|
tarifKm: tarif > 0 ? f3(tarif) : '',
|
||||||
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
sousKm: sousKm > 0 ? f2(sousKm) : '',
|
||||||
ttc: f2(ttc),
|
ttc: f2(ttc),
|
||||||
tva21: f2(t21), tva55: f2(t55),
|
tva21: f2(t21), tva55: f2(t55),
|
||||||
@@ -331,34 +376,45 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
|
|||||||
|
|
||||||
for (const col of COLS) {
|
for (const col of COLS) {
|
||||||
const isKmCol = KM_COLS.includes(col.key);
|
const isKmCol = KM_COLS.includes(col.key);
|
||||||
drawCellText(
|
// ✅ Couleur ambre pour montants proratisés
|
||||||
doc, r[col.key],
|
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,
|
colX[col.key], y, col.w, ROW_H,
|
||||||
'Helvetica', 7,
|
'Helvetica', 7, textColor, col.align);
|
||||||
isKmCol ? C.kmText : C.dark,
|
|
||||||
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 {
|
} else {
|
||||||
// Ligne vide — zéros en gris sur colonnes numériques
|
// Ligne vide — zéros en gris
|
||||||
for (const col of COLS) {
|
for (const col of COLS) {
|
||||||
if (['ttc', 'tva21', 'tva55', 'tva10', 'tva20', 'ht'].includes(col.key))
|
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);
|
drawHLine(doc, MARGIN, MARGIN + tableW, y + ROW_H);
|
||||||
for (const col of COLS) drawVLine(doc, colX[col.key], y, 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);
|
drawVLine(doc, MARGIN + tableW, y, y + ROW_H);
|
||||||
y += ROW_H;
|
y += ROW_H;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. LIGNE TOTAL ───────────────────────────────────────────
|
// ── Ligne Total ──────────────────────────────────────────────
|
||||||
const totalY = y;
|
const totalY = y;
|
||||||
drawRect(doc, MARGIN, totalY, tableW, ROW_H + 2, C.totalBg, C.border, 0.5);
|
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) {
|
for (const key of KM_COLS) {
|
||||||
const col = COLS.find(c => c.key === key);
|
const col = COLS.find(c => c.key === key);
|
||||||
drawRect(doc, colX[key], totalY, col.w, ROW_H + 2, C.kmTotalBg);
|
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 = {
|
const totMap = {
|
||||||
km: totKm > 0 ? f2(totKm) : '',
|
km: totKm > 0 ? f2(totKm) : '',
|
||||||
tarifKm: '', // pas de somme de tarifs
|
tarifKm: '',
|
||||||
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
|
||||||
ttc: f2(totTTC),
|
ttc: f2(totTTC),
|
||||||
tva21: f2(totT21), tva55: f2(totT55),
|
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] && totMap[col.key] !== '0.00') continue;
|
||||||
if (totMap[col.key] === '') continue;
|
if (totMap[col.key] === '') continue;
|
||||||
const isKmCol = KM_COLS.includes(col.key);
|
const isKmCol = KM_COLS.includes(col.key);
|
||||||
drawCellText(
|
drawCellText(doc, totMap[col.key],
|
||||||
doc, totMap[col.key],
|
|
||||||
colX[col.key], totalY, col.w, ROW_H + 2,
|
colX[col.key], totalY, col.w, ROW_H + 2,
|
||||||
'Helvetica-Bold', 8,
|
'Helvetica-Bold', 8, isKmCol ? C.kmText : C.dark, 'right');
|
||||||
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 footY = totalY + ROW_H + 12;
|
||||||
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
|
||||||
const bw = 64;
|
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)
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
|
||||||
.text('Montant total à rembourser', MARGIN, footY + 4, { lineBreak: false });
|
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
|
||||||
drawRect(doc, MARGIN + 180, footY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
|
||||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
|
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)
|
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)} €`,
|
.text(
|
||||||
MARGIN, footY + 24, { lineBreak: false });
|
`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 sigStartX = MARGIN + 310;
|
||||||
const sigW = (tableW - 313) / 2 - 4;
|
const sigW = (tableW - 313) / 2 - 4;
|
||||||
const sigH = 52;
|
const sigH = 52;
|
||||||
const sigY = footY - 2;
|
const sigY = footY - 2 + labelOffsetY;
|
||||||
|
|
||||||
const sigCollab = signatures.find(s => s.niveau === 'COLLAB');
|
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);
|
// ✅ Afficher toutes les signatures (jusqu'à 3 : COLLAB, N1/N2, VERIF)
|
||||||
_drawSigBox(doc, sigManager, sigStartX + sigW + 6, sigY, sigW, sigH, 'Date et signature', true);
|
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)
|
doc.font('Helvetica').fontSize(6).fillColor(C.light)
|
||||||
.text(
|
.text(
|
||||||
`Réf. ${reference} — Généré le ${new Date().toLocaleDateString('fr-FR')} — NDF ENSUP Groupe — Document électronique`,
|
`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) {
|
function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
|
||||||
let bg, border, accent, icon;
|
let bg, border, accent, icon;
|
||||||
@@ -440,6 +533,8 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
|
|||||||
const a = sig.action || '';
|
const a = sig.action || '';
|
||||||
if (a === 'refuser' || a === 'refuse') {
|
if (a === 'refuser' || a === 'refuse') {
|
||||||
bg = C.refusBg; border = C.refusBorder; accent = C.refusText; icon = '✗ REFUSÉ';
|
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) {
|
} else if (isManager) {
|
||||||
bg = C.validBg; border = C.validBorder; accent = C.validText; icon = '✓ VALIDÉ';
|
bg = C.validBg; border = C.validBorder; accent = C.validText; icon = '✓ VALIDÉ';
|
||||||
} else {
|
} else {
|
||||||
@@ -451,7 +546,6 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
|
|||||||
|
|
||||||
drawRect(doc, x, y, w, h, bg, border, 1);
|
drawRect(doc, x, y, w, h, bg, border, 1);
|
||||||
|
|
||||||
// Label haut
|
|
||||||
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C.grey)
|
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C.grey)
|
||||||
.text(label, x + 4, y + 4, { width: w - 8, lineBreak: false });
|
.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)
|
doc.save().strokeColor(border).lineWidth(0.5)
|
||||||
.moveTo(x + 3, y + h - 9).lineTo(x + w - 3, y + h - 9).stroke().restore();
|
.moveTo(x + 3, y + h - 9).lineTo(x + w - 3, y + h - 9).stroke().restore();
|
||||||
doc.font('Helvetica').fontSize(6).fillColor(C.grey)
|
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 {
|
} else {
|
||||||
doc.font('Helvetica').fontSize(8).fillColor(C.grey)
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1341
-485
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
|
|
||||||
color: white;
|
color: white;
|
||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -64,6 +64,31 @@ const ROLE_CONFIG: Record<string, {
|
|||||||
accent: '#7c3aed',
|
accent: '#7c3aed',
|
||||||
features: ['Voir toutes les notes de l\'organisation', 'Filtrer par collaborateur, statut, mois', 'Visualiser les montants globaux'],
|
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
|
// Normalise un rôle vers sa clé de config
|
||||||
@@ -74,6 +99,8 @@ const normalizeRole = (role: string): string => {
|
|||||||
validateur: 'Validateur',
|
validateur: 'Validateur',
|
||||||
validatrice: 'Validatrice',
|
validatrice: 'Validatrice',
|
||||||
finance: 'Finance',
|
finance: 'Finance',
|
||||||
|
verificateurfinance: 'VerificateurFinance',
|
||||||
|
validateurfinance: 'ValidateurFinance',
|
||||||
};
|
};
|
||||||
return map[role.toLowerCase()] ?? role;
|
return map[role.toLowerCase()] ?? role;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: linear-gradient(135deg, #f5f5dc 0%, #e8d7c3 100%);
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const AuthCallback = (): JSX.Element => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontFamily: 'sans-serif',
|
fontFamily: 'sans-serif',
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
color: '#f5f5dc',
|
|
||||||
}}>
|
}}>
|
||||||
⏳ Connexion en cours...
|
⏳ Connexion en cours...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+978
-308
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+454
-113
@@ -3,8 +3,15 @@ import QRCode from "react-qr-code";
|
|||||||
|
|
||||||
// ── TYPES ─────────────────────────────────────────────
|
// ── TYPES ─────────────────────────────────────────────
|
||||||
interface TvaItem {
|
interface TvaItem {
|
||||||
taux: string;
|
taux: string; // "0", "5.5", "10", "20", ou "MIXED" pour ligne multi-TVA
|
||||||
montantTTC: string;
|
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 Participant { nom: string; prenom: string; societe?: string; }
|
||||||
interface Depense {
|
interface Depense {
|
||||||
@@ -17,6 +24,7 @@ interface Depense {
|
|||||||
qrNoteRef?: string;
|
qrNoteRef?: string;
|
||||||
filesMeta?: FileMeta[];
|
filesMeta?: FileMeta[];
|
||||||
qrFiles?: QrFileMeta[];
|
qrFiles?: QrFileMeta[];
|
||||||
|
nuits: string;
|
||||||
}
|
}
|
||||||
interface BrouillonServeur {
|
interface BrouillonServeur {
|
||||||
id: number; libelle: string; lignesJson: string;
|
id: number; libelle: string; lignesJson: string;
|
||||||
@@ -32,6 +40,12 @@ interface TvaItemBackend {
|
|||||||
taux: string;
|
taux: string;
|
||||||
montantTTC: string;
|
montantTTC: string;
|
||||||
montantHT: string;
|
montantHT: string;
|
||||||
|
tvaBreakdown?: {
|
||||||
|
tva21?: string;
|
||||||
|
tva55?: string;
|
||||||
|
tva10?: string;
|
||||||
|
tva20?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
interface NouvelleNoteProps {
|
interface NouvelleNoteProps {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
@@ -95,8 +109,49 @@ const ttcToTva = (ttc: number, taux: number): number => {
|
|||||||
return parseFloat((ttc - ht).toFixed(2));
|
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 tvaItemToBackend = (item: TvaItem): TvaItemBackend => {
|
||||||
const ttc = parseFloat(item.montantTTC) || 0;
|
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;
|
const taux = parseFloat(item.taux) || 0;
|
||||||
return {
|
return {
|
||||||
taux: item.taux,
|
taux: item.taux,
|
||||||
@@ -106,6 +161,14 @@ const tvaItemToBackend = (item: TvaItem): TvaItemBackend => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeTvaItem = (it: any): TvaItem => {
|
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 !== "") {
|
if (it.montantTTC !== undefined && it.montantTTC !== "") {
|
||||||
return { taux: String(it.taux ?? "20"), montantTTC: String(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: "" }],
|
km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", montantTTC: "" }],
|
||||||
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
|
||||||
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
|
||||||
|
nuits: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +512,9 @@ select.nn-input {
|
|||||||
.nn-repas-info.alert {
|
.nn-repas-info.alert {
|
||||||
background: rgba(239,68,68,.07); border-color: rgba(239,68,68,.4); color: #dc2626;
|
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; }
|
.nn-repas-info-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|
||||||
/* Boutons +/- participant */
|
/* Boutons +/- participant */
|
||||||
@@ -570,6 +637,19 @@ select.nn-input {
|
|||||||
border-radius: 9px; color: #dc2626; font-size: 12px; font-weight: 600;
|
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 { 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.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); }
|
.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 ─
|
// ── 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 {
|
interface ExcelRow {
|
||||||
ttc: string;
|
ttc: string;
|
||||||
tva21: string;
|
tva21: string;
|
||||||
@@ -720,46 +799,93 @@ function TvaExcelTable({ tvaItems, onUpdate }: {
|
|||||||
tvaItems: TvaItem[];
|
tvaItems: TvaItem[];
|
||||||
onUpdate: (items: TvaItem[]) => void;
|
onUpdate: (items: TvaItem[]) => void;
|
||||||
}) {
|
}) {
|
||||||
// On stocke les données de la table Excel dans un état local de lignes
|
// ✅ Reconstruction des ExcelRow depuis les TvaItem
|
||||||
// On initialise depuis tvaItems existants (compatibilité brouillons)
|
// 1 item = 1 ligne du tableau Excel
|
||||||
const initRows = (): ExcelRow[] => {
|
const initRows = (): ExcelRow[] => {
|
||||||
// Essaie de reconstruire une ligne depuis les tvaItems sauvegardés
|
if (tvaItems.length === 0) {
|
||||||
if (tvaItems.length === 0) return [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
|
return [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
// Cas brouillon : on reconstruit une ligne agrégeant les taux connus
|
const rows = tvaItems
|
||||||
const byTaux: Record<string, string> = {};
|
.filter(it => parseFloat(it.montantTTC || "0") > 0 || it.montantTTC === "")
|
||||||
let totalTTC = 0;
|
.map(it => {
|
||||||
tvaItems.forEach(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 ttc = parseFloat(it.montantTTC) || 0;
|
||||||
byTaux[it.taux] = it.montantTTC;
|
const taux = parseFloat(it.taux) || 0;
|
||||||
totalTTC += ttc;
|
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 [{
|
|
||||||
ttc: totalTTC > 0 ? String(totalTTC) : "",
|
return rows.length > 0 ? rows : [{ ttc: "", tva21: "", tva55: "", tva10: "", tva20: "" }];
|
||||||
tva21: byTaux["2.1"] || "",
|
|
||||||
tva55: byTaux["5.5"] || "",
|
|
||||||
tva10: byTaux["10"] || "",
|
|
||||||
tva20: byTaux["20"] || "",
|
|
||||||
}];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rows, setRows] = useState<ExcelRow[]>(initRows);
|
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 syncToParent = (newRows: ExcelRow[]) => {
|
||||||
const items: TvaItem[] = [];
|
const items: TvaItem[] = [];
|
||||||
|
|
||||||
newRows.forEach(row => {
|
newRows.forEach(row => {
|
||||||
if (row.tva21 && parseFloat(row.tva21) > 0) items.push({ taux: "2.1", montantTTC: row.ttc });
|
const ttc = parseFloat(row.ttc) || 0;
|
||||||
if (row.tva55 && parseFloat(row.tva55) > 0) items.push({ taux: "5.5", montantTTC: row.ttc });
|
if (ttc <= 0 && !row.ttc) return; // ligne vide → on saute
|
||||||
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 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 });
|
|
||||||
}
|
}
|
||||||
onUpdate(items.length ? items : [{ taux: "20", montantTTC: "" }]);
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
items.push({ taux: "20", montantTTC: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(items);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRow = (idx: number, field: keyof ExcelRow, val: string) => {
|
const updateRow = (idx: number, field: keyof ExcelRow, val: string) => {
|
||||||
@@ -781,7 +907,6 @@ function TvaExcelTable({ tvaItems, onUpdate }: {
|
|||||||
syncToParent(newRows);
|
syncToParent(newRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculs totaux
|
|
||||||
const fmtN = (v: number) =>
|
const fmtN = (v: number) =>
|
||||||
v !== 0
|
v !== 0
|
||||||
? new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v)
|
? 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 ──────────────────────────────────────
|
// ── DEPENSE CARD ──────────────────────────────────────
|
||||||
const DepenseCard = React.memo(({
|
const DepenseCard = React.memo(({
|
||||||
depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule
|
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: "" }];
|
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
|
const ttcTotal = isKm
|
||||||
? ind
|
? ind
|
||||||
: tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
|
: tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
|
||||||
|
|
||||||
// Alerte repas > 25 € par personne
|
// Calcul alerte repas
|
||||||
const nbPersonnes = Math.max(1, (parseInt(depense.nombreParticipants) || 0) + 1); // +1 = la personne elle-même
|
const nbPersonnes = Math.max(1, (parseInt(depense.nombreParticipants) || 0) + 1);
|
||||||
const ttcParPersonne = isRepas && ttcTotal > 0 ? ttcTotal / nbPersonnes : 0;
|
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
|
const htTotal = isKm
|
||||||
? ind
|
? ind
|
||||||
: tvaItems.reduce((s, item) => {
|
: tvaItems.reduce((s, item) => s + itemToHt(item), 0);
|
||||||
const ttc = parseFloat(item.montantTTC) || 0;
|
|
||||||
const taux = parseFloat(item.taux) || 0;
|
|
||||||
return s + ttcToHt(ttc, taux);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const tvaTotal = tvaItems.reduce((s, item) => {
|
// ✅ Calcul TVA — gestion du cas MIXED
|
||||||
const ttc = parseFloat(item.montantTTC) || 0;
|
const tvaTotal = tvaItems.reduce((s, item) => s + itemToTva(item), 0);
|
||||||
const taux = parseFloat(item.taux) || 0;
|
|
||||||
return s + ttcToTva(ttc, taux);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Handlers TVA classique (Autre)
|
|
||||||
const updTva = (idx: number, field: keyof TvaItem, val: string) => {
|
const updTva = (idx: number, field: keyof TvaItem, val: string) => {
|
||||||
const copy = tvaItems.map((it, i) => i === idx ? { ...it, [field]: val } : it);
|
const copy = tvaItems.map((it, i) => i === idx ? { ...it, [field]: val } : it);
|
||||||
set("tvaItems", copy);
|
set("tvaItems", copy);
|
||||||
@@ -1079,12 +1211,11 @@ const DepenseCard = React.memo(({
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="nn-acc-body">
|
<div className="nn-acc-body">
|
||||||
|
|
||||||
{/* Bandeau repas visible dès la sélection de la catégorie */}
|
|
||||||
{isRepas && (
|
{isRepas && (
|
||||||
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
<div className="nn-repas-info" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||||
<span className="nn-repas-info-icon">🍽️</span>
|
<span className="nn-repas-info-icon">🍽️</span>
|
||||||
<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.
|
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1136,7 +1267,6 @@ const DepenseCard = React.memo(({
|
|||||||
où vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
|
où vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cheval fiscal : depuis profil ou sélecteur manuel */}
|
|
||||||
{profilVehicule ? (
|
{profilVehicule ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
@@ -1198,7 +1328,7 @@ const DepenseCard = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── TVA EXCEL — Repas / Hébergement / Transport ── */}
|
{/* ── TVA EXCEL ── */}
|
||||||
{!isKm && isExcel && (
|
{!isKm && isExcel && (
|
||||||
<TvaExcelTable
|
<TvaExcelTable
|
||||||
tvaItems={tvaItems}
|
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 && (
|
{!isKm && !isExcel && (
|
||||||
<div className="nn-tva-box">
|
<div className="nn-tva-box">
|
||||||
<div className="nn-tva-head">
|
<div className="nn-tva-head">
|
||||||
@@ -1269,79 +1436,126 @@ const DepenseCard = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── REPAS — bandeau info + participants + alerte 25€ ── */}
|
{/* ── REPAS — participants + alertes ── */}
|
||||||
{isRepas && (
|
{isRepas && (
|
||||||
<div className="nn-repas-box">
|
<div className="nn-repas-box">
|
||||||
<div className="nn-repas-title">🍽️ Participants</div>
|
<div className="nn-repas-title">🍽️ Participants</div>
|
||||||
|
|
||||||
{/* Bandeau info toujours visible dès que catégorie = Repas */}
|
|
||||||
<div className="nn-repas-info">
|
<div className="nn-repas-info">
|
||||||
<span className="nn-repas-info-icon">ℹ️</span>
|
<span className="nn-repas-info-icon">ℹ️</span>
|
||||||
<span>
|
<span>
|
||||||
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong> (sauf clause repas événementiel).
|
Un repas professionnel <strong>ne doit pas dépasser 25 € par personne</strong> (sauf repas événementiel).
|
||||||
Indiquez le nombre de convives ci-dessous — le montant par personne est calculé automatiquement.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alerte si dépassement */}
|
{/* ── Choix seul ou accompagné ── */}
|
||||||
{repasAlerte && (
|
<div className="nn-field" style={{ marginBottom: 12 }}>
|
||||||
<div className="nn-repas-info alert">
|
<label className="nn-fl" style={{ color: "#92400e" }}>Type de repas <span>*</span></label>
|
||||||
<span className="nn-repas-info-icon">⚠️</span>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<span>
|
<button
|
||||||
<strong>Plafond dépassé !</strong> Le repas revient à{" "}
|
type="button"
|
||||||
<strong>{new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}</strong>{" "}
|
onClick={() => handleNombreParticipants("0")}
|
||||||
par personne ({nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""}).
|
style={{
|
||||||
Seule la clause <em>repas événementiel</em> autorise le dépassement des 25 €.
|
flex: 1, padding: "10px 14px", borderRadius: 8, cursor: "pointer",
|
||||||
</span>
|
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"
|
||||||
|
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contrôle nombre de participants */}
|
{/* ── Si accompagné : sélecteur nombre + champs ── */}
|
||||||
|
{depense.nombreParticipants !== "" && parseInt(depense.nombreParticipants) > 0 && (
|
||||||
|
<>
|
||||||
<div className="nn-field" style={{ marginBottom: 10 }}>
|
<div className="nn-field" style={{ marginBottom: 10 }}>
|
||||||
<label className="nn-fl" style={{ color: "#92400e" }}>
|
<label className="nn-fl" style={{ color: "#92400e" }}>
|
||||||
Nombre de convives invités{" "}
|
Nombre de convives invités{" "}
|
||||||
<span style={{ color: "#92400e", fontWeight: 400, textTransform: "none", fontSize: 9 }}>
|
<span style={{ color: "#92400e", fontWeight: 400, textTransform: "none", fontSize: 9 }}>
|
||||||
(0 = repas seul — vous êtes toujours compté dans le total)
|
(vous êtes toujours compté dans le total)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{/* Bouton − */}
|
<button type="button" className="nn-p-btn rm"
|
||||||
<button
|
disabled={(parseInt(depense.nombreParticipants) || 0) <= 1}
|
||||||
type="button"
|
onClick={() => handleNombreParticipants(String(Math.max(1, (parseInt(depense.nombreParticipants) || 1) - 1)))}
|
||||||
className="nn-p-btn rm"
|
|
||||||
disabled={(parseInt(depense.nombreParticipants) || 0) <= 0}
|
|
||||||
onClick={() => handleNombreParticipants(String(Math.max(0, (parseInt(depense.nombreParticipants) || 0) - 1)))}
|
|
||||||
>−</button>
|
>−</button>
|
||||||
|
|
||||||
<input
|
<input type="number" min="1" className="nn-input"
|
||||||
type="number" min="0" className="nn-input"
|
style={{ borderColor: repasAlerte ? "rgba(245,158,11,.5)" : "rgba(251,191,36,.4)", maxWidth: 80, textAlign: "center" }}
|
||||||
style={{ borderColor: repasAlerte ? "rgba(239,68,68,.5)" : "rgba(251,191,36,.4)", maxWidth: 80, textAlign: "center" }}
|
|
||||||
value={depense.nombreParticipants}
|
value={depense.nombreParticipants}
|
||||||
onChange={e => handleNombreParticipants(e.target.value)}
|
onChange={e => {
|
||||||
placeholder="0"
|
const v = Math.max(1, parseInt(e.target.value) || 1);
|
||||||
|
handleNombreParticipants(String(v));
|
||||||
|
}}
|
||||||
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bouton + */}
|
<button type="button" className="nn-p-btn add"
|
||||||
<button
|
onClick={() => handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 1) + 1))}
|
||||||
type="button"
|
|
||||||
className="nn-p-btn add"
|
|
||||||
onClick={() => handleNombreParticipants(String((parseInt(depense.nombreParticipants) || 0) + 1))}
|
|
||||||
>+</button>
|
>+</button>
|
||||||
|
|
||||||
{/* Résumé convives */}
|
<span style={{ fontSize: 11, color: repasAlerte ? "#b45309" : "#92400e", fontWeight: 700 }}>
|
||||||
{(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
|
{nbPersonnes} convive{nbPersonnes > 1 ? "s" : ""} au total
|
||||||
{ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
|
{ttcTotal > 0 && ` · ${new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(ttcParPersonne)}/pers.`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Liste des participants */}
|
{/* Alertes */}
|
||||||
{depense.participants.length > 0 && (
|
{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>
|
||||||
<div className="nn-participant-row" style={{ marginBottom: 4 }}>
|
<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" }}>Nom *</span>
|
||||||
@@ -1353,32 +1567,45 @@ const DepenseCard = React.memo(({
|
|||||||
<div key={i} className="nn-participant-row">
|
<div key={i} className="nn-participant-row">
|
||||||
<input className="nn-input"
|
<input className="nn-input"
|
||||||
style={{ fontSize: 12, borderColor: !p.nom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
|
style={{ fontSize: 12, borderColor: !p.nom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
|
||||||
value={p.nom}
|
value={p.nom} onChange={e => updP(i, "nom", e.target.value)}
|
||||||
onChange={e => updP(i, "nom", e.target.value)}
|
|
||||||
placeholder={`Nom ${i + 1} *`} />
|
placeholder={`Nom ${i + 1} *`} />
|
||||||
<input className="nn-input"
|
<input className="nn-input"
|
||||||
style={{ fontSize: 12, borderColor: !p.prenom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
|
style={{ fontSize: 12, borderColor: !p.prenom?.trim() ? 'rgba(239,68,68,.5)' : undefined }}
|
||||||
value={p.prenom}
|
value={p.prenom} onChange={e => updP(i, "prenom", e.target.value)}
|
||||||
onChange={e => updP(i, "prenom", e.target.value)}
|
|
||||||
placeholder="Prénom *" />
|
placeholder="Prénom *" />
|
||||||
<input className="nn-input" style={{ fontSize: 12 }} value={p.societe || ""} onChange={e => updP(i, "societe", e.target.value)} placeholder="Société" />
|
<input className="nn-input" style={{ fontSize: 12 }}
|
||||||
{/* + ajouter après cette ligne */}
|
value={p.societe || ""} onChange={e => updP(i, "societe", e.target.value)}
|
||||||
<button type="button" className="nn-p-btn add" title="Insérer un participant après"
|
placeholder="Société" />
|
||||||
|
<button type="button" className="nn-p-btn add"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
|
const newList = [...depense.participants.slice(0, i + 1), { nom: "", prenom: "", societe: "" }, ...depense.participants.slice(i + 1)];
|
||||||
set("participants", newList);
|
set("participants", newList);
|
||||||
set("nombreParticipants", String(newList.length));
|
set("nombreParticipants", String(newList.length));
|
||||||
}}>+</button>
|
}}>+</button>
|
||||||
{/* − supprimer cette ligne */}
|
<button type="button" className="nn-p-btn rm"
|
||||||
<button type="button" className="nn-p-btn rm" title="Retirer ce participant"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newList = depense.participants.filter((_, j) => j !== i);
|
const newList = depense.participants.filter((_, j) => j !== i);
|
||||||
|
if (newList.length === 0) {
|
||||||
|
handleNombreParticipants("0");
|
||||||
|
} else {
|
||||||
set("participants", newList);
|
set("participants", newList);
|
||||||
set("nombreParticipants", String(newList.length));
|
set("nombreParticipants", String(newList.length));
|
||||||
|
}
|
||||||
}}>−</button>
|
}}>−</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1490,11 +1717,12 @@ export default function NouvelleNote({
|
|||||||
const [dateFin, setDateFin] = useState("");
|
const [dateFin, setDateFin] = useState("");
|
||||||
const [commentaire, setComment] = useState(commentaireInitial || "");
|
const [commentaire, setComment] = useState(commentaireInitial || "");
|
||||||
const [depenses, setDepenses] = useState<Depense[]>(() => initDepenses(7));
|
const [depenses, setDepenses] = useState<Depense[]>(() => initDepenses(7));
|
||||||
// Profil véhicule — chargé depuis l'API
|
|
||||||
const [profilVehicule, setProfilVehicule] = useState<ProfilVehiculeData | null>(null);
|
const [profilVehicule, setProfilVehicule] = useState<ProfilVehiculeData | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(() => {
|
const [expandedId, setExpandedId] = useState<number | null>(() => {
|
||||||
const init = initDepenses(); return init.length > 0 ? init[0].id : 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 [brouillons, setBrouillons] = useState<BrouillonServeur[]>([]);
|
||||||
const [activeBrouillonId, setActiveBrouillonId] = useState<number | null>(initialBrouillonId);
|
const [activeBrouillonId, setActiveBrouillonId] = useState<number | null>(initialBrouillonId);
|
||||||
@@ -1509,7 +1737,6 @@ export default function NouvelleNote({
|
|||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
|
||||||
|
|
||||||
// ── Charger le profil véhicule depuis l'API au montage ──
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
|
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
|
||||||
@@ -1519,7 +1746,6 @@ export default function NouvelleNote({
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data?.configured && data.vehicule) {
|
if (data?.configured && data.vehicule) {
|
||||||
setProfilVehicule(data.vehicule);
|
setProfilVehicule(data.vehicule);
|
||||||
// Mettre à jour le cheval fiscal sur toutes les dépenses kilométriques
|
|
||||||
setDepenses(prev => prev.map(d => ({
|
setDepenses(prev => prev.map(d => ({
|
||||||
...d,
|
...d,
|
||||||
chevaux: (d.categorie || "").toLowerCase().includes("kilom")
|
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]);
|
}, [authToken, apiBaseUrl]);
|
||||||
|
|
||||||
useEffect(() => { if (depenses.length > 0 && expandedId === null) setExpandedId(depenses[0].id); }, []);
|
useEffect(() => { if (depenses.length > 0 && expandedId === null) setExpandedId(depenses[0].id); }, []);
|
||||||
@@ -1573,12 +1799,65 @@ export default function NouvelleNote({
|
|||||||
} catch { }
|
} catch { }
|
||||||
}, [apiBaseUrl, getHeaders]);
|
}, [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 () => {
|
const fetchBrouillons = useCallback(async () => {
|
||||||
setLoadingBrouillons(true);
|
setLoadingBrouillons(true);
|
||||||
try { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { headers: getHeaders() }); if (res.ok) setBrouillons(await res.json()); } catch { }
|
try { const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { headers: getHeaders() }); if (res.ok) setBrouillons(await res.json()); } catch { }
|
||||||
setLoadingBrouillons(false);
|
setLoadingBrouillons(false);
|
||||||
}, [apiBaseUrl, getHeaders]);
|
}, [apiBaseUrl, getHeaders]);
|
||||||
|
|
||||||
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
|
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1709,6 +1988,7 @@ export default function NouvelleNote({
|
|||||||
setExpandedId(d.id); return;
|
setExpandedId(d.id); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -1723,7 +2003,7 @@ export default function NouvelleNote({
|
|||||||
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
|
} 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(() => {
|
const { totalTTC, totalHT, totalTVA } = useMemo(() => {
|
||||||
let ttc = 0, ht = 0;
|
let ttc = 0, ht = 0;
|
||||||
depenses.forEach(d => {
|
depenses.forEach(d => {
|
||||||
@@ -1734,9 +2014,9 @@ export default function NouvelleNote({
|
|||||||
} else {
|
} else {
|
||||||
d.tvaItems.forEach(item => {
|
d.tvaItems.forEach(item => {
|
||||||
const ttcVal = parseFloat(item.montantTTC) || 0;
|
const ttcVal = parseFloat(item.montantTTC) || 0;
|
||||||
const taux = parseFloat(item.taux) || 0;
|
if (ttcVal <= 0) return;
|
||||||
ttc += ttcVal;
|
ttc += ttcVal;
|
||||||
ht += ttcToHt(ttcVal, taux);
|
ht += itemToHt(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1787,24 +2067,86 @@ export default function NouvelleNote({
|
|||||||
{loadingBrouillons ? "Chargement…" : `${brouillons.length} brouillon(s)`}
|
{loadingBrouillons ? "Chargement…" : `${brouillons.length} brouillon(s)`}
|
||||||
{!activeBrouillon && <SaveIndicator />}
|
{!activeBrouillon && <SaveIndicator />}
|
||||||
</span>
|
</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 && (
|
{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"}
|
{showBrouillonList ? "▲ Réduire" : "▼ Voir"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="nn-btn-sm green" onClick={handleNewBrouillon}>+ Nouvelle note</button>
|
<button className="nn-btn-sm green" onClick={handleNewBrouillon}>+ Nouvelle note</button>
|
||||||
</div>
|
</div>
|
||||||
</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 => (
|
{showBrouillonList && brouillons.map(b => (
|
||||||
<div key={b.id} className={`nn-bro-row${b.id === activeBrouillonId ? " active" : ""}`}>
|
<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 }}>
|
<div className="nn-bro-name" style={{ color: b.id === activeBrouillonId ? "#6366f1" : undefined }}>
|
||||||
{b.libelle || "Sans titre"}
|
{b.libelle || "Sans titre"}
|
||||||
{b.id === activeBrouillonId && <span className="nn-bro-active-tag">EN COURS</span>}
|
{b.id === activeBrouillonId && <span className="nn-bro-active-tag">EN COURS</span>}
|
||||||
</div>
|
</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 }}>
|
<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>
|
<button className="nn-btn-sm red" onClick={() => handleDeleteBrouillon(b.id)}>🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1825,7 +2167,6 @@ export default function NouvelleNote({
|
|||||||
|
|
||||||
{submitError && <div className="nn-error">⚠️ {submitError}</div>}
|
{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">
|
||||||
<div className="nn-meta-field">
|
<div className="nn-meta-field">
|
||||||
<label>Titre <span>*</span></label>
|
<label>Titre <span>*</span></label>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,5 +23,5 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": [ "src" ]
|
"include": [ "src/**/*" ]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user