diff --git a/ndf/public/backend/ndfPdfGenerator.js b/ndf/public/backend/ndfPdfGenerator.js
index 191bd4e..43f09dd 100644
--- a/ndf/public/backend/ndfPdfGenerator.js
+++ b/ndf/public/backend/ndfPdfGenerator.js
@@ -129,7 +129,13 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
const km = parseFloat(l.km) || 0;
const cv = parseInt(l.chevaux) || 7;
- const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0;
+ const indemniteKm = isKm ? (() => {
+ const b = BAREME_KM[cv];
+ if (!b || km <= 0) return 0;
+ if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
+ if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
+ return parseFloat((km * b.t3).toFixed(2));
+ })() : 0;
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
const montantAjuste = l.montantAjuste === true;
@@ -208,6 +214,7 @@ export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
// generateFicheSignee
// ─────────────────────────────────────────────────────────────────────────────
export async function generateFicheSignee(note, signatures = []) {
+ console.log('🔍 generateFicheSignee montantServeur reçu =', note.montant);
const tarifKm = parseFloat(note.tarifKm) || 0.697;
let lignesPDF = [];
@@ -244,22 +251,23 @@ export async function generateFicheSignee(note, signatures = []) {
mois = m.charAt(0).toUpperCase() + m.slice(1);
}
- return _buildPDF({
- reference: note.reference || '',
- nomPrenom: note.nomPrenom || note.collaborateur || '',
- mois,
- departement: note.departement || '',
- lignes: lignesPDF,
- tarifKm,
- signatures,
- statut: note.statut || 'enattente',
- });
+ return _buildPDF({
+ reference: note.reference || '',
+ nomPrenom: note.nomPrenom || note.collaborateur || '',
+ mois,
+ departement: note.departement || '',
+ lignes: lignesPDF,
+ tarifKm,
+ signatures,
+ statut: note.statut || 'enattente',
+ montantServeur: note.montant ? parseFloat(note.montant) : null,
+ });
}
// ─────────────────────────────────────────────────────────────────────────────
// _buildPDF
// ─────────────────────────────────────────────────────────────────────────────
-function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
+function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures, montantServeur }) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'A4', layout: 'landscape', margin: 0,
@@ -367,7 +375,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
lib: libelleAffiche,
km: km > 0 ? f2(km) : '',
tarifKm: tarif > 0 ? f3(tarif) : '',
- sousKm: sousKm > 0 ? f2(sousKm) : '',
+ sousKm: sousKm > 0 ? f2(sousKm) + ' €' : '',
ttc: f2(ttc),
tva21: f2(t21), tva55: f2(t55),
tva10: f2(t10), tva20: f2(t20),
@@ -426,7 +434,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
const totMap = {
km: totKm > 0 ? f2(totKm) : '',
tarifKm: '',
- sousKm: totSousKm > 0 ? f2(totSousKm) : '',
+ sousKm: totSousKm > 0 ? f2(totSousKm) + ' €' : '',
ttc: f2(totTTC),
tva21: f2(totT21), tva55: f2(totT55),
tva10: f2(totT10), tva20: f2(totT20),
@@ -444,7 +452,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
// ── Zone bas ─────────────────────────────────────────────────
const footY = totalY + ROW_H + 12;
- const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
+const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
const bw = 64;
// ✅ Légende proratisation si au moins une ligne ajustée
@@ -469,18 +477,32 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
const labelOffsetY = hasProrata ? 16 : 0;
- doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
- .text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
- drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
- doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
- .text(f2(montantR) + ' €',
- MARGIN + 182, footY + 3.5 + labelOffsetY,
- { width: bw + 6, align: 'right', lineBreak: false });
+ // APRÈS
+ doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
+ .text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
+
+ // Détail calcul km + dépenses
+ doc.font('Helvetica').fontSize(7.5).fillColor(C.grey)
+ .text(
+ totSousKm > 0 && totTTC > 0
+ ? `${f2(totSousKm)} € (km) + ${f2(totTTC)} € (dépenses) =`
+ : totSousKm > 0
+ ? `${f2(totSousKm)} € (indemnités kilométriques) =`
+ : `${f2(totTTC)} € (dépenses) =`,
+ MARGIN, footY + 17 + labelOffsetY,
+ { lineBreak: false }
+ );
+
+ drawRect(doc, MARGIN + 220, footY + 13 + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
+ doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
+ .text(f2(montantR) + ' €',
+ MARGIN + 222, footY + 16.5 + labelOffsetY,
+ { width: bw + 6, align: 'right', lineBreak: false });
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
.text(
- `Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} €`,
- MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
+ ` Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)} € — Total : ${f2(totSousKm)} € + ${f2(totTTC)} € = ${f2(montantR)} €`,
+ MARGIN, footY + 34 + labelOffsetY, { lineBreak: false }
);
// ── Signatures ────────────────────────────────────────────────
diff --git a/ndf/public/backend/server.js b/ndf/public/backend/server.js
index 742ef0c..99b1637 100644
--- a/ndf/public/backend/server.js
+++ b/ndf/public/backend/server.js
@@ -97,7 +97,7 @@ const dbConfig = {
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000,
- useUTC: false
+ useUTC: false
},
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 }
};
@@ -175,6 +175,18 @@ function normalizeCampus(campus) {
return null;
}
+function recalculerMontantNote(note) {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ if (!lignes.length) return parseFloat(note.montant) || 0;
+ return lignes.reduce((sum, l) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return parseFloat(note.montant) || 0; }
+}
+
// ── Helper getTarifKm ────────────────────────────────────────────
async function getTarifKm() {
try {
@@ -1330,7 +1342,7 @@ app.post('/api/verificateur/notes/:id/refuser', authenticateToken, async (req, r
// ── 5. Notifications (en dehors de la transaction)
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
- const montantFormate = parseFloat(note.montant).toFixed(2);
+ const montantFormate = recalculerMontantNote(note).toFixed(2);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
// HTML : tableau récap des lignes refusées
@@ -1729,34 +1741,44 @@ async function genererReference(campus, nom, prenom) {
const annee = now.getFullYear();
const campusCode = normalizeCampus(campus) || 'XXX';
-
const nomClean = (nom || '').toUpperCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Z]/g, '');
const prenomInitiale = (prenom || '').charAt(0).toUpperCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Z]/g, '');
-
const nomPart = `${nomClean}.${prenomInitiale}`;
const tx = new sql.Transaction(pool);
await tx.begin();
try {
- await new sql.Request(tx).query(`
- IF NOT EXISTS (SELECT 1 FROM NDFSequence WHERE annee = ${annee})
- INSERT INTO NDFSequence (annee, compteur) VALUES (${annee}, 0)
- `);
+ // ✅ MERGE atomique — crée la ligne si elle n'existe pas, puis incrémente
const result = await new sql.Request(tx).query(`
- UPDATE NDFSequence SET compteur = compteur + 1
- OUTPUT INSERTED.compteur
- WHERE annee = ${annee}
+ MERGE NDFSequence WITH (HOLDLOCK) AS target
+ USING (SELECT ${annee} AS annee) AS source
+ ON target.annee = source.annee
+ WHEN MATCHED THEN
+ UPDATE SET compteur = ISNULL(target.compteur, 0) + 1
+ WHEN NOT MATCHED THEN
+ INSERT (annee, compteur) VALUES (${annee}, 1);
+
+ SELECT compteur FROM NDFSequence WHERE annee = ${annee};
`);
+
await tx.commit();
- const num = String(result.recordset[0].compteur).padStart(2, '0');
- // Format : NDF01-SQY-IMER.O-05-05-2026
- return `NDF${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`;
+
+ const compteur = result.recordset?.[0]?.compteur;
+ if (!compteur || Number.isNaN(Number(compteur))) {
+ throw new Error(`Compteur NDFSequence invalide pour ${annee}`);
+ }
+
+ // ✅ padStart(3) pour avoir NDF001, NDF002... NDF999
+ const num = String(compteur).padStart(3, '0');
+ return `NDF N\u00B0${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`;
+
+
} catch (e) {
- await tx.rollback();
+ try { await tx.rollback(); } catch { }
throw e;
}
}
@@ -1999,9 +2021,13 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
} catch { }
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm);
- const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0);
- const indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0);
- const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0);
+ const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0);
+ const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0);
+ const indemKm = lignesParsed.reduce((s, l) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return s + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return s;
+ }, 0);
const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2));
const montantFormate = montantFinal.toFixed(2);
const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0);
@@ -2034,11 +2060,11 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
- const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
- const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-zA-Z0-9_]/g, '_');
- const now = new Date();
+ const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
+ const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9_]/g, '_');
+ const now = new Date();
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// ── Collecte des fichiers (QR global) ────────────────────────────
@@ -2110,15 +2136,59 @@ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
})
);
+ // ── Upload fichiers soumis directement par ligne (files_${depId}) ──
+ // Identique à la logique du PUT /api/notes/brouillons/:id
+ const perLigneFieldnameSet = new Set();
+ const perLigneUploaded = [];
+
+ for (const file of req.files || []) {
+ const match = (file.fieldname || '').match(/^files_(.+)$/);
+ if (!match) continue;
+
+ const depId = String(match[1]);
+ perLigneFieldnameSet.add(file.fieldname);
+
+ // Trouver la ligne par son id frontend (stocké dans lignesJson)
+ const li = lignesParsed.findIndex(l => String(l.id) === depId);
+
+ try {
+ const up = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
+ perLigneUploaded.push(up);
+
+ if (li >= 0) {
+ if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
+ const dejaSauve = lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName);
+ if (!dejaSauve) {
+ lignesParsed[li].qrFiles.push({
+ fileName: up.fileName,
+ uploadUrl: up.uploadUrl,
+ origin: 'upload'
+ });
+ }
+ console.log(`✅ Fichier injecté ligne ${li}: ${up.fileName}`);
+ } else {
+ console.warn(`⚠️ Fichier ${file.originalname} : aucune ligne trouvée pour depId=${depId}`);
+ }
+ } catch (e) {
+ console.error(`❌ Upload ligne file ${file.originalname}:`, e.message);
+ }
+ }
+
+ // lignesJsonFinal APRÈS injection des qrFiles dans chaque ligne
const lignesJsonFinal = JSON.stringify(lignesParsed);
- // ── Upload justificatifs en parallèle ────────────────────────────
- const fichiersUploades = (await Promise.all(
- allFiles.map(file =>
- uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier)
- .catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; })
- )
- )).filter(Boolean);
+ // ── Upload justificatifs restants (QR globaux, hors per-ligne déjà traités) ──
+ const fichiersUploades = [
+ ...perLigneUploaded,
+ ...(await Promise.all(
+ allFiles
+ .filter(file => !perLigneFieldnameSet.has(file.fieldname))
+ .map(file =>
+ uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier)
+ .catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; })
+ )
+ )).filter(Boolean)
+ ];
const noteDataPDF = {
reference,
@@ -2348,17 +2418,14 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
SELECT * FROM NoteDeFrais
WHERE id = @id AND collaborateurId = @collabId
AND statut IN ('enattente', 'refuse', 'refuse_verif', 'non_conforme_verif', 'brouillon')
-
`);
if (!noteCheck.recordset.length)
return res.status(403).json({ error: 'Note introuvable ou non modifiable (statut incompatible)' });
const noteExist = noteCheck.recordset[0];
-
- // ✅ Détecter si correction (refusée ou non-conforme) → nouvelle note
- const estCorrection = noteExist.statut === 'refuse'
- || noteExist.statut === 'refuse_verif'
+ const estCorrection = noteExist.statut === 'refuse'
+ || noteExist.statut === 'refuse_verif'
|| noteExist.statut === 'non_conforme_verif';
const { libelle, date, description, participants, nombreParticipants, lignes } = req.body;
@@ -2404,19 +2471,18 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
const dateObj = new Date(date);
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
- const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-zA-Z0-9_]/g, '_');
- const now = new Date();
- const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
- const tarifKmVal = await getTarifKm();
+ const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9_]/g, '_');
+ const now = new Date();
+ const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+ const tarifKmVal = await getTarifKm();
// ════════════════════════════════════════════════════════════════════
// CAS 1 — Note REFUSÉE ou NON_CONFORME_VERIF → créer une NOUVELLE note
// ════════════════════════════════════════════════════════════════════
if (estCorrection) {
- // 1. Archiver l'ancienne note
const statutArchive = noteExist.statut === 'non_conforme_verif'
? 'non_conforme_archive'
: noteExist.statut === 'refuse_verif'
@@ -2433,20 +2499,41 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
WHERE id = @id
`);
- // 2. Nouvelle référence
const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
- // 3. Upload fichiers joints
- const allFiles = [...(req.files || [])];
+ // ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
const fichiersUploades = [];
- for (const file of allFiles) {
+ const perLigneFieldnameSetCas1 = new Set();
+
+ for (const file of req.files || []) {
+ const match = (file.fieldname || '').match(/^files_(.+)$/);
+ if (!match) continue;
+ const depId = String(match[1]);
+ perLigneFieldnameSetCas1.add(file.fieldname);
+ const li = lignesParsed.findIndex(l => String(l.id) === depId);
+ try {
+ const up = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
+ fichiersUploades.push(up);
+ if (li >= 0) {
+ if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
+ if (!lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName)) {
+ lignesParsed[li].qrFiles.push({ fileName: up.fileName, uploadUrl: up.uploadUrl, origin: 'upload' });
+ }
+ console.log(`✅ [CAS1] Fichier injecté ligne ${li}: ${up.fileName}`);
+ }
+ } catch (e) { console.error(`❌ Upload justif correction ligne ${file.originalname}:`, e.message); }
+ }
+
+ // Fichiers globaux non per-ligne
+ for (const file of req.files || []) {
+ if (perLigneFieldnameSetCas1.has(file.fieldname)) continue;
try {
const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
fichiersUploades.push(r);
- } catch (e) { console.error(`❌ Upload justif correction ${file.originalname}:`, e.message); }
+ } catch (e) { console.error(`❌ Upload justif correction global ${file.originalname}:`, e.message); }
}
- // 4. Récupérer fichiers QR par ligne
+ // QR par ligne
for (let i = 0; i < lignesParsed.length; i++) {
const ligneQrRef = lignesParsed[i].qrNoteRef;
if (!ligneQrRef) continue;
@@ -2470,12 +2557,15 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
} catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); }
}
- // 5. Générer fiche PDF
+ // lignesJsonCorrige APRÈS injection
+ const lignesJsonCorrige = JSON.stringify(lignesParsed);
+
+ // Générer fiche PDF
const noteDataPDF = {
reference: nouvelleReference, nomPrenom, mois: moisCapitalized, date,
categorie: categorieNote, libelle,
montant: parseFloat(montantFinal.toFixed(2)),
- lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed),
+ lignes: lignesParsed, lignesJson: lignesJsonCorrige,
tarifKm: tarifKmVal, statut: 'enattente',
departement: collaborateur.departement,
};
@@ -2495,25 +2585,20 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
fichiersUploades.push(ficheResult);
} catch (e) { console.error('❌ Fiche PDF correction:', e.message); }
- // 6. Hiérarchie
const hierarchie = await pool.request()
.input('collabId', sql.Int, userId)
.query(`
SELECT h.SuperieurId,
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1
-
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
-
WHERE h.CollaborateurId = @collabId
`);
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
-
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
- // 7. Insérer nouvelle note
const insertResult = await pool.request()
.input('reference', sql.NVarChar, nouvelleReference)
.input('collaborateurId', sql.Int, userId)
@@ -2527,10 +2612,9 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades))
.input('statut', sql.NVarChar, 'enattente')
.input('validateurN1Id', sql.Int, n1Id)
-
.input('km', sql.Decimal, kmTotal || null)
.input('indemniteKm', sql.Decimal, indemKm || null)
- .input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed))
+ .input('lignesJson', sql.NVarChar, lignesJsonCorrige)
.input('noteRefuseeId', sql.Int, noteId)
.query(`
INSERT INTO NoteDeFrais
@@ -2543,14 +2627,13 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
VALUES
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
@description, @participants, @nombreParticipants,
- @fichiers, @statut, @validateurN1Id,
+ @fichiers, @statut, @validateurN1Id,
@km, @indemniteKm, @lignesJson, @noteRefuseeId,
GETDATE(), GETDATE())
`);
const nouvelleNote = insertResult.recordset[0];
- // 8. Insérer lignes
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal);
for (let i = 0; i < lignesParsed.length; i++) {
const l = lignesParsed[i];
@@ -2590,13 +2673,9 @@ app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
} catch (e) { console.error(`❌ Insertion ligne correction ${i + 1}:`, e.message); }
}
- // 9. Notifier N1
if (n1Id && emailN1) {
try {
- const titreNotif = noteExist.statut === 'non_conforme_verif'
- ? `📋 Note corrigée à valider — ${nouvelleReference}`
- : `📋 Note corrigée à valider — ${nouvelleReference}`;
- // Dans le PUT /api/notes/:id — CAS 1 correction, section "Notifier N1"
+ const titreNotif = `📋 Note corrigée à valider — ${nouvelleReference}`;
const msgNotif = `${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée.
Ancienne référence : ${noteExist.reference} (${statutArchive})
Nouvelle référence : ${nouvelleReference}
@@ -2646,19 +2725,42 @@ Montant : ${parseFloat(montantFinal.toFixed(2))} €`;
}
// ════════════════════════════════════════════════════════════════════
- // CAS 2 — Note EN ATTENTE → modifier sur place (comportement original)
+ // CAS 2 — Note EN ATTENTE → modifier sur place
// ════════════════════════════════════════════════════════════════════
const reference = noteExist.reference;
- const allFiles = [...(req.files || [])];
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { }
- for (const file of allFiles) {
+ // ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
+ const perLigneFieldnameSetCas2 = new Set();
+
+ for (const file of req.files || []) {
+ const match = (file.fieldname || '').match(/^files_(.+)$/);
+ if (!match) continue;
+ const depId = String(match[1]);
+ perLigneFieldnameSetCas2.add(file.fieldname);
+ const li = lignesParsed.findIndex(l => String(l.id) === depId);
try {
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
fichiersExistants.push(r);
- } catch (e) { console.error(`❌ Upload justif modif ${file.originalname}:`, e.message); }
+ if (li >= 0) {
+ if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
+ if (!lignesParsed[li].qrFiles.some(f => f.fileName === r.fileName)) {
+ lignesParsed[li].qrFiles.push({ fileName: r.fileName, uploadUrl: r.uploadUrl, origin: 'upload' });
+ }
+ console.log(`✅ [CAS2] Fichier injecté ligne ${li}: ${r.fileName}`);
+ }
+ } catch (e) { console.error(`❌ Upload justif modif ligne ${file.originalname}:`, e.message); }
+ }
+
+ // Fichiers globaux non per-ligne
+ for (const file of req.files || []) {
+ if (perLigneFieldnameSetCas2.has(file.fieldname)) continue;
+ try {
+ const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
+ fichiersExistants.push(r);
+ } catch (e) { console.error(`❌ Upload justif modif global ${file.originalname}:`, e.message); }
}
const noteDataPDF = {
@@ -2900,7 +3002,12 @@ app.get('/api/notes', authenticateToken, async (req, res) => {
}
}
- res.json(notes);
+ const notesAvecMontant = notes.map(n => ({
+ ...n,
+ montant: recalculerMontantNote(n),
+ }));
+
+ res.json(notesAvecMontant);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -3064,7 +3171,12 @@ app.get('/api/notes/pending', authenticateToken, async (req, res) => {
}
}
- res.json(notes);
+ const notesAvecMontant = notes.map(n => ({
+ ...n,
+ montant: recalculerMontantNote(n),
+ }));
+
+ res.json(notesAvecMontant);
} catch (e) {
console.error('Erreur /api/notes/pending:', e.message);
res.status(500).json({ error: e.message });
@@ -3095,6 +3207,47 @@ app.get('/api/notes/:id/lignes', authenticateToken, async (req, res) => {
}
});
+app.get('/api/notes/:id/lignes-refusees-n1', authenticateToken, async (req, res) => {
+ try {
+ const noteId = parseInt(req.params.id);
+
+ // Vérifier accès : collaborateur propriétaire ou validateur ou Finance
+ const noteCheck = await pool.request()
+ .input('id', sql.Int, noteId)
+ .query(`
+ SELECT collaborateurId, validateurN1Id
+ FROM NoteDeFrais WHERE id = @id
+ `);
+ if (!noteCheck.recordset.length)
+ return res.status(404).json({ error: 'Note introuvable' });
+
+ const note = noteCheck.recordset[0];
+ const isOwner = note.collaborateurId === req.user.id;
+ const isN1 = note.validateurN1Id === req.user.id;
+ const isFinance = hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'ValidateurFinance', 'superUtilisateur');
+
+ if (!isOwner && !isN1 && !isFinance)
+ return res.status(403).json({ error: 'Accès refusé' });
+
+ const result = await pool.request()
+ .input('noteId', sql.Int, noteId)
+ .query(`
+ SELECT lr.ligneIndex, lr.ligneLibelle, lr.ligneCategorie,
+ lr.motif, lr.statut, lr.dateRefus,
+ c.prenom + ' ' + c.nom AS verificateur
+ FROM LignesRefusees lr
+ JOIN CollaborateurAD c ON c.id = lr.verificateurId
+ WHERE lr.noteDeFraisId = @noteId
+ AND lr.statut = 'active'
+ ORDER BY lr.ligneIndex ASC
+ `);
+
+ res.json(result.recordset);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
// GET /api/notes/:id/detail — récupère une note par ID (pour validateur + collaborateur)
app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => {
try {
@@ -3134,7 +3287,13 @@ app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => {
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
} else { note.sharepointFiles = []; }
- res.json(note);
+ // juste avant res.json(notes);
+ const notesAvecMontant = notes.map(n => ({
+ ...n,
+ montant: recalculerMontantNote(n),
+ }));
+
+ res.json(notesAvecMontant);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -3146,116 +3305,203 @@ app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => {
app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
- const { action, commentaire, motifRefus } = req.body;
+ // lignesDecisions = [{ ligneIndex, statut: 'valide'|'refuse', commentaire }]
+ const { action, commentaire, motifRefus, lignesDecisions } = req.body;
const userId = Number(req.user.id);
-
+
const noteResult = await pool.request()
.input('id', sql.Int, id)
.query('SELECT * FROM NoteDeFrais WHERE id = @id');
if (!noteResult.recordset.length)
return res.status(404).json({ error: 'Note non trouvée' });
-
+
const note = noteResult.recordset[0];
const n1Id = Number(note.validateurN1Id);
const statutNote = note.statut?.trim();
-
- let nouveauStatut = null, niveauValidation = null;
-
- if (n1Id === userId && statutNote === 'enattente') {
- niveauValidation = 'N1';
- nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
- } else {
+
+ // Seul le N1 peut valider une note 'enattente'
+ if (!(n1Id === userId && statutNote === 'enattente'))
return res.status(403).json({ error: 'Non autorisé à valider cette note' });
+
+ // --- Déterminer le nouveau statut ---
+ // Si lignesDecisions fourni, on fait une validation ligne par ligne
+ let nouveauStatut;
+ let lignesRefuseesFinal = [];
+
+ if (Array.isArray(lignesDecisions) && lignesDecisions.length > 0) {
+ // Vérifier si au moins une ligne est refusée
+ const lignesRefusees = lignesDecisions.filter(l => l.statut === 'refuse');
+ const lignesValides = lignesDecisions.filter(l => l.statut === 'valide');
+
+ if (lignesRefusees.length === 0) {
+ // Toutes les lignes sont validées → approuvé
+ nouveauStatut = 'approuve';
+ } else {
+ // Au moins une ligne refusée → refus global avec détail
+ nouveauStatut = 'refuse';
+ lignesRefuseesFinal = lignesRefusees;
+ }
+ } else {
+ // Comportement legacy (validation globale sans ligne)
+ nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
}
-
- await pool.request()
- .input('id', sql.Int, id)
- .input('statut', sql.NVarChar, nouveauStatut)
- .input('commentaire', sql.NVarChar, commentaire ?? null)
- .input('motifRefus', sql.NVarChar, motifRefus ?? null)
- .query(`
- UPDATE NoteDeFrais
- SET statut = @statut,
- dateValidationN1 = GETDATE(),
- commentaireN1 = @commentaire,
- motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END,
- DateModification = GETDATE()
- WHERE id = @id
- `);
-
- await pool.request()
- .input('noteId', sql.Int, id)
- .input('validateurId', sql.Int, userId)
- .input('niveau', sql.NVarChar, 'N1')
- .input('action', sql.NVarChar, action)
- .input('commentaire', sql.NVarChar, commentaire ?? null)
- .input('motifRefus', sql.NVarChar, motifRefus ?? null)
- .input('statut', sql.NVarChar, nouveauStatut)
- .query(`
- INSERT INTO HistoriqueValidation
- (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, MotifRefus, NouveauStatut, DateAction)
- VALUES
- (@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE())
- `);
-
- res.json({ success: true, statut: nouveauStatut, niveau: 'N1' });
-
+
+ // --- Construire le commentaire synthétique ---
+ let commentaireFinal = commentaire || null;
+ let motifRefusFinal = motifRefus || null;
+
+ if (lignesRefuseesFinal.length > 0) {
+ // Parser les lignes de la note pour avoir les libellés
+ let lignesData = [];
+ try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
+
+ const detailRefus = lignesRefuseesFinal.map(l => {
+ const ligne = lignesData[l.ligneIndex] || {};
+ const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
+ return `• ${label} — ${l.commentaire || 'Non conforme'}`;
+ }).join('\n');
+
+ motifRefusFinal = `${lignesRefuseesFinal.length} ligne(s) refusée(s) :\n${detailRefus}`;
+ commentaireFinal = motifRefusFinal;
+ }
+
+ // --- Transaction : mise à jour note + LignesRefusees ---
+ const transaction = new sql.Transaction(pool);
+ await transaction.begin();
+
+ try {
+ // 1. Mettre à jour la note
+ await new sql.Request(transaction)
+ .input('id', sql.Int, id)
+ .input('statut', sql.NVarChar, nouveauStatut)
+ .input('commentaire', sql.NVarChar, commentaireFinal)
+ .input('motifRefus', sql.NVarChar, motifRefusFinal)
+ .query(`
+ UPDATE NoteDeFrais
+ SET statut = @statut,
+ dateValidationN1 = GETDATE(),
+ commentaireN1 = @commentaire,
+ motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END,
+ DateModification = GETDATE()
+ WHERE id = @id
+ `);
+
+ // 2. Si des lignes sont refusées, les enregistrer dans LignesRefusees
+ if (lignesRefuseesFinal.length > 0) {
+ let lignesData = [];
+ try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
+
+ // Archiver les anciens refus actifs si re-validation
+ await new sql.Request(transaction)
+ .input('noteId', sql.Int, id)
+ .query(`
+ UPDATE LignesRefusees
+ SET statut = 'archive'
+ WHERE noteDeFraisId = @noteId AND statut = 'active'
+ `);
+
+ // Insérer les nouveaux refus
+ for (const l of lignesRefuseesFinal) {
+ const ligne = lignesData[l.ligneIndex] || {};
+ await new sql.Request(transaction)
+ .input('noteId', sql.Int, id)
+ .input('ligneIndex', sql.Int, l.ligneIndex)
+ .input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`)
+ .input('ligneCategorie', sql.NVarChar, ligne.categorie || null)
+ .input('motif', sql.NVarChar, l.commentaire || 'Non conforme')
+ .input('verificateurId', sql.Int, userId)
+ .query(`
+ INSERT INTO LignesRefusees
+ (noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut)
+ VALUES
+ (@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active')
+ `);
+ }
+ }
+
+ // 3. Historique de validation
+ await new sql.Request(transaction)
+ .input('noteId', sql.Int, id)
+ .input('validateurId', sql.Int, userId)
+ .input('niveau', sql.NVarChar, 'N1')
+ .input('action', sql.NVarChar, nouveauStatut === 'approuve' ? 'valider' : 'refuser')
+ .input('commentaire', sql.NVarChar, commentaireFinal)
+ .input('motifRefus', sql.NVarChar, motifRefusFinal)
+ .input('statut', sql.NVarChar, nouveauStatut)
+ .query(`
+ INSERT INTO HistoriqueValidation
+ (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, MotifRefus, NouveauStatut, DateAction)
+ VALUES
+ (@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE())
+ `);
+
+ await transaction.commit();
+ } catch (e) {
+ try { await transaction.rollback(); } catch { }
+ throw e;
+ }
+
+ // Répondre immédiatement
+ res.json({
+ success: true,
+ statut: nouveauStatut,
+ niveau: 'N1',
+ nbLignesRefusees: lignesRefuseesFinal.length,
+ nbLignesValidees: Array.isArray(lignesDecisions)
+ ? lignesDecisions.filter(l => l.statut === 'valide').length
+ : (nouveauStatut === 'approuve' ? 1 : 0)
+ });
+
+ // --- Traitement asynchrone : PDFs + emails ---
setImmediate(async () => {
try {
- console.log(`🔄 [ASYNC] PDF + emails validation ${id} → ${nouveauStatut}`);
-
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
const montantFormate = parseFloat(note.montant).toFixed(2);
-
+
const [collabResult, validateurResult, noteCompleteResult] = await Promise.all([
- pool.request()
- .input('id', sql.Int, note.collaborateurId)
+ pool.request().input('id', sql.Int, note.collaborateurId)
.query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'),
- pool.request()
- .input('id', sql.Int, userId)
+ pool.request().input('id', sql.Int, userId)
.query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'),
- pool.request()
- .input('id', sql.Int, id)
- .query(`
- SELECT n.reference, n.libelle, n.montant, n.date, n.categorie,
- n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson,
- n.commentaireN1, n.dateValidationN1,
- c.prenom + ' ' + c.nom AS nomPrenom,
- c.prenom AS collabPrenom, c.nom AS collabNom, c.departement,
- v1.prenom + ' ' + v1.nom AS nomValidateurN1
- FROM NoteDeFrais n
- JOIN CollaborateurAD c ON c.id = n.collaborateurId
- LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
- WHERE n.id = @id
- `)
+ pool.request().input('id', sql.Int, id).query(`
+ SELECT n.reference, n.libelle, n.montant, n.date, n.categorie,
+ n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson,
+ n.commentaireN1, n.dateValidationN1,
+ c.prenom + ' ' + c.nom AS nomPrenom,
+ c.prenom AS collabPrenom, c.nom AS collabNom, c.departement,
+ v1.prenom + ' ' + v1.nom AS nomValidateurN1
+ FROM NoteDeFrais n
+ JOIN CollaborateurAD c ON c.id = n.collaborateurId
+ LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
+ WHERE n.id = @id
+ `)
]);
-
+
const c = collabResult.recordset[0];
const v = validateurResult.recordset[0];
const nd = noteCompleteResult.recordset[0];
+ if (!c || !nd) return;
- if (!c || !nd) {
- console.error(`❌ [ASYNC] Données manquantes pour note ${id}`);
- return;
- }
-
+ console.log('🔍 nd.montant =', nd.montant);
+ console.log('🔍 note.montant =', note.montant);
+
const nomValidateurActuel = v
? `${v.prenom} ${v.nom}`.trim()
: `${req.user.prenom} ${req.user.nom}`.trim();
-
- // ── Construire les signatures ──────────────────────────────
+
+ // Construire les signatures pour le PDF
const signatures = [
{ niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null },
- { niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }
+ { niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action: nouveauStatut === 'approuve' ? 'valider' : 'refuser', commentaire: commentaireFinal }
];
-
+
const moisStr = (() => {
if (!nd.date) return '';
const d = new Date(nd.date);
const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return m.charAt(0).toUpperCase() + m.slice(1);
})();
-
+
const noteDataPDF = {
reference: nd.reference,
nomPrenom: nd.nomPrenom,
@@ -3263,140 +3509,148 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
departement: nd.departement,
lignesJson: nd.lignesJson,
tarifKm: await getTarifKm(),
- statut: nouveauStatut
+ statut: nouveauStatut,
+ montant: parseFloat(nd.montant),
};
-
+
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { }
-
+
const existingFolder = fichiersExistants[0]?.folderPath;
- // APRÈS
- let nomDossier = existingFolder
- ? existingFolder.split('/')[1]
- : `${nd.collabNom}_${nd.collabPrenom}`
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-zA-Z0-9_]/g, '_');
- let moisDossier = existingFolder
- ? existingFolder.split('/')[2]
- : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
-
- // ── Génération PDF signé (fiche seule) ────────────────────
+ let nomDossier = existingFolder
+ ? existingFolder.split('/')[1]
+ : `${nd.collabNom}_${nd.collabPrenom}`
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9_]/g, '_');
+ let moisDossier = existingFolder
+ ? existingFolder.split('/')[2]
+ : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
+
+ // Génération PDF signé
try {
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse';
-
const signedResult = await uploadToSharePointHierarchique(
{ buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length },
nd.reference, nomDossier, moisDossier
);
fichiersExistants.push(signedResult);
-
- await pool.request()
- .input('id', sql.Int, id)
- .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
+ await pool.request().input('id', sql.Int, id).input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
-
- console.log(`✅ [ASYNC] PDF signé uploadé: ${signedResult.fileName}`);
} catch (pdfError) {
- console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message);
+ console.error('❌ [ASYNC] PDF signé N1:', pdfError.message);
}
-
- // ── Régénérer le recap complet ────────────────────────────
+
+ // Régénérer le recap
try {
const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap'];
const justifFiles = [];
-
for (const f of fichiersExistants) {
const fname = (f.fileName || '').toLowerCase();
if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue;
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
- const mimetype = fname.endsWith('.pdf') ? 'application/pdf'
- : fname.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ const mimetype = fname.endsWith('.pdf') ? 'application/pdf' : fname.endsWith('.png') ? 'image/png' : 'image/jpeg';
justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length });
- } catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); }
+ } catch { }
}
-
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures);
const recapResult = await uploadToSharePointHierarchique(
- {
- buffer: recapBuffer,
- originalname: `${nd.reference}_recap.pdf`,
- mimetype: 'application/pdf',
- size: recapBuffer.length
- },
+ { buffer: recapBuffer, originalname: `${nd.reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length },
nd.reference, nomDossier, moisDossier
);
-
const fichiersAvecRecap = fichiersExistants.filter(f => {
const fname = (f.fileName || '').toLowerCase();
return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement');
});
fichiersAvecRecap.push(recapResult);
-
- await pool.request()
- .input('id', sql.Int, id)
- .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap))
+ await pool.request().input('id', sql.Int, id).input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
-
- console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s): ${recapResult.fileName}`);
} catch (recapError) {
- console.error('❌ [ASYNC] Régénération recap:', recapError.message);
+ console.error('❌ [ASYNC] Recap N1:', recapError.message);
}
-
- // ── Notifications + emails ────────────────────────────────
+
+ // --- Emails + notifications ---
const isApprouve = nouveauStatut === 'approuve';
const isRefus = nouveauStatut === 'refuse';
+
+ // Construire le tableau HTML des lignes refusées (pour l'email)
+ let lignesRefuseesHtml = '';
+ if (lignesRefuseesFinal.length > 0) {
+ let lignesData = [];
+ try { lignesData = JSON.parse(nd.lignesJson || '[]'); } catch { }
+
+ lignesRefuseesHtml = `
+
+
+
+ Lignes à corriger (${lignesRefuseesFinal.length})
+
+
+
+
+
+ N°
+ Dépense
+ Motif
+
+
+
+ ${lignesRefuseesFinal.map(l => {
+ const ligne = lignesData[l.ligneIndex] || {};
+ const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
+ const cat = ligne.categorie || '';
+ return `
+
+ ${l.ligneIndex + 1}
+
+ ${label}
+ ${cat ? `${cat}
` : ''}
+
+ ${l.commentaire || 'Non conforme'}
+ `;
+ }).join('')}
+
+
+
`;
+ }
+
const titreCollab = isApprouve
- ? `Note ${note.reference} approuvée`
- : `Note ${note.reference} refusée`;
+ ? `Note ${note.reference} approuvée ✅`
+ : `Note ${note.reference} — corrections demandées ❌`;
+
const msgCollab = isApprouve
- ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.`
- : `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`;
-
- const motifAffiche = motifRefus || commentaire || 'Non précisé';
-
- const emailCollabHtml = isRefus
- ? `
+ ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée par ${nomValidateurActuel}.`
+ : `Votre note ${note.reference} a été refusée par ${nomValidateurActuel}. ${lignesRefuseesFinal.length} ligne(s) à corriger.`;
+
+ const emailCollabHtml = isRefus ? `
+
-
❌ Votre note de frais a été refusée
-
Une action de votre part est nécessaire
+
❌ Corrections demandées sur votre note
+
${lignesRefuseesFinal.length} ligne(s) à corriger
Bonjour ${c.prenom} ${c.nom} ,
-
Votre note ${note.reference} a été refusée par ${nomValidateurActuel} .
-
-
Motif du refus
-
${motifAffiche}
-
-
-
- Référence ${note.reference}
- Libellé ${note.libelle}
- Montant ${montantFormate} €
- Refusé par ${nomValidateurActuel}
- Date ${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
-
-
+
Votre note ${note.reference} a été examinée par ${nomValidateurActuel} . Certaines dépenses nécessitent des corrections avant approbation.
+ ${lignesRefuseesHtml}
📝 Que faire maintenant ?
Connectez-vous à la plateforme NDF
- Rendez-vous dans Mes notes
- Cliquez sur la note ${note.reference}
- Corrigez les informations demandées
+ Ouvrez la note ${note.reference}
+ Corrigez uniquement les lignes listées ci-dessus
Resoumettez la note
-
`
- : `
-
+
` : `
+
+
${titreCollab}
@@ -3407,7 +3661,7 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
`;
-
+
await Promise.all([
creerNotification({
destinataireId: c.id,
@@ -3416,27 +3670,26 @@ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
titre: titreCollab,
message: msgCollab,
noteId: parseInt(id)
- }).catch(e => console.error('❌ [ASYNC] Notif collab:', e.message)),
-
+ }).catch(e => console.error('❌ [ASYNC N1] Notif collab:', e.message)),
sendMailGraph(
c.email,
- isRefus ? `❌ Note refusée — action requise : ${note.reference}` : titreCollab,
+ isRefus ? `❌ Corrections demandées — ${note.reference}` : titreCollab,
emailCollabHtml
- ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)),
+ ).catch(e => console.error('❌ [ASYNC N1] Email collab:', e.message)),
]);
-
- console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`);
-
+
+ console.log(`✅ [ASYNC N1] Validation terminée note ${id} → ${nouveauStatut} (${lignesRefuseesFinal.length} ligne(s) refusée(s))`);
} catch (e) {
- console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message);
+ console.error(`❌ [ASYNC N1] Erreur générale validation note ${id}:`, e.message);
}
});
-
+
} catch (error) {
- console.error('Erreur validation:', error.message);
+ console.error('Erreur validation N1:', error.message);
res.status(500).json({ error: error.message });
}
});
+
// ================================================
// GET /api/notes/:id/historique
// ================================================
@@ -3702,7 +3955,7 @@ async function sendMailGraph(to, subject, htmlBody) {
async function creerNotification({ destinataireId, destinataireEmail, type, titre, message, noteId }) {
if (!destinataireId) {
- console.error('❌ creerNotification annulée : destinataireId manquant', { destinataireEmail, type, titre });
+ console.error('❌ creerNotification annulée : destinataireId manquant');
return;
}
try {
@@ -3713,13 +3966,16 @@ async function creerNotification({ destinataireId, destinataireEmail, type, titr
.input('message', sql.NVarChar, message)
.input('noteId', sql.Int, noteId || null)
.query(`
- INSERT INTO Notifications (CollaborateurId, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation)
- VALUES (@destinataireId, @type, @titre, @message, @noteId, 0, GETDATE())
+ INSERT INTO Notifications
+ (CollaborateurId, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation)
+ VALUES
+ (@destinataireId, @type, @titre, @message, @noteId, 0, GETDATE())
`);
console.log(`🔔 Notification insérée pour ${destinataireEmail} (id: ${destinataireId})`);
- } catch (err) { console.error('❌ Erreur insertion notification:', err.message); }
+ } catch (err) {
+ console.error('❌ Erreur insertion notification:', err.message);
+ }
}
-
// ================================================
// PAIEMENTS — NOTIFIER
// ================================================
@@ -3733,7 +3989,7 @@ app.post('/api/paiements/notifier', authenticateToken, async (req, res) => {
`);
if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note en attente de paiement' });
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const alexandre = await pool.request()
.input('email', sql.NVarChar, process.env.RESPONSABLE_PAIEMENT_EMAIL)
.query(`SELECT TOP 1 id, email, prenom, nom FROM CollaborateurAD WHERE email = @email AND Actif = 1`);
@@ -3757,12 +4013,25 @@ app.post('/api/paiements/notifier', authenticateToken, async (req, res) => {
// ================================================
app.get('/api/notifications', authenticateToken, async (req, res) => {
try {
- const result = await pool.request().input('userId', sql.Int, req.user.id).query(`
- SELECT TOP 50 id, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation
- FROM Notifications WHERE CollaborateurId = @userId ORDER BY DateCreation DESC
- `);
+ const result = await pool.request()
+ .input('userId', sql.Int, req.user.id)
+ .query(`
+ SELECT TOP 50
+ id, Type, Titre, Message, NoteDeFraisId, Lu,
+ CONVERT(VARCHAR(23), DateCreation, 126) +
+ CASE DATEDIFF(HOUR, GETUTCDATE(), GETDATE())
+ WHEN 2 THEN '+02:00'
+ WHEN 1 THEN '+01:00'
+ ELSE '+00:00'
+ END AS DateCreation
+ FROM Notifications
+ WHERE CollaborateurId = @userId
+ ORDER BY DateCreation DESC
+ `);
res.json(result.recordset);
- } catch (error) { res.status(500).json({ error: error.message }); }
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
app.put('/api/notifications/:id/lu', authenticateToken, async (req, res) => {
@@ -4004,7 +4273,7 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => {
const todayISO = now.toISOString().split('T')[0];
const creDtTm = now.toISOString().slice(0, 19);
const msgId = `NDF-${annee}${mois}-${Date.now().toString().slice(-7)}`;
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const totalFormate = total.toFixed(2);
// Détecter le campus dominant des notes sélectionnées
@@ -4051,7 +4320,7 @@ app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => {
${n.reference}
- ${parseFloat(n.montant).toFixed(2)}
+ ${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
@@ -4332,8 +4601,8 @@ app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) =>
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
const notes = await pool.request().query(`
- SELECT n.id, n.reference, n.montant, n.libelle, n.dateXml,
- c.nom, c.prenom, c.iban, c.bic
+ SELECT n.id, n.reference, n.montant, n.libelle, n.dateXml, n.lignesJson, c.nom, c.prenom, c.iban, c.bic
+
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
@@ -4349,7 +4618,8 @@ app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) =>
const todayISO = now.toISOString().split('T')[0];
const creDtTm = now.toISOString().slice(0, 19);
const msgId = `NDF-REGEN-${annee}${mois}-${Date.now().toString().slice(-7)}`;
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0).toFixed(2);
+
const campusDominant = (() => {
const campusCounts = {};
for (const n of notes.recordset) {
@@ -4387,7 +4657,7 @@ app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) =>
${n.reference}
- ${parseFloat(n.montant).toFixed(2)}
+ ${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
${benefNom}
@@ -4535,8 +4805,7 @@ app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res
// Récupérer les notes pour notifications
const notes = await pool.request().query(`
- SELECT n.id, n.reference, n.montant, n.libelle,
- c.id AS collabId, c.email, c.prenom, c.nom
+ SELECT n.id,n.reference,n.montant,n.libelle,n.lignesJson,c.id AS collabId,c.email,c.prenom,c.nom
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
@@ -4570,7 +4839,7 @@ app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res
destinataireEmail: n.email,
type: 'paiement',
titre: `Paiement effectué : ${n.reference}`,
- message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)}€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`,
+ message: `Votre note ${n.reference} de ${recalculerMontantNote(n).toFixed(2) }€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`,
noteId: n.id
});
await sendMailGraph(n.email, `Paiement effectué : ${n.reference}`, `
@@ -4580,7 +4849,7 @@ app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res
Bonjour ${n.prenom} ${n.nom} ,
-
Votre note ${n.reference} — ${n.libelle} d'un montant de ${parseFloat(n.montant).toFixed(2)}€ a été payée le ${dateObj.toLocaleDateString('fr-FR')} .
+
Votre note ${n.reference} — ${n.libelle} d'un montant de ${recalculerMontantNote(n).toFixed(2) }€ a été payée le ${dateObj.toLocaleDateString('fr-FR')} .
@@ -4589,7 +4858,7 @@ app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res
} catch (e) { console.error('Notif paiement confirmé:', e.message); }
}
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
res.json({
success: true,
count: notes.recordset.length,
@@ -4657,7 +4926,7 @@ app.get('/api/notes/brouillons', authenticateToken, async (req, res) => {
});
// POST /api/notes/brouillons — crée un nouveau brouillon
-app.post('/api/notes/brouillons', authenticateToken, async (req, res) => {
+app.post('/api/notes/brouillons', authenticateToken, upload.any(), async (req, res) => {
try {
const { libelle, date, description, lignes } = req.body;
@@ -4674,7 +4943,11 @@ app.post('/api/notes/brouillons', authenticateToken, async (req, res) => {
montantEstime = lignesParsed.reduce((acc, l) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km) || 0;
- const ttc = isKm ? parseFloat((km * tarifKm).toFixed(2)) : (parseFloat(l.montant) || 0);
+ const ttc = isKm
+ ? parseFloat((km * tarifKm).toFixed(2))
+ : (l.tvaItems?.length
+ ? l.tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0)
+ : parseFloat(l.montant) || 0);
return acc + ttc;
}, 0);
} catch (e) { /* montant reste 0 */ }
@@ -4857,56 +5130,59 @@ app.delete('/api/notes/brouillons/:id', authenticateToken, async (req, res) => {
// ============================================================
// DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis
// ============================================================
-const DOCTYPES = ['rib', 'cartegrise', 'permis'];
+const DOCTYPES = ['rib', 'cartegrise', 'carte_grise', 'permis'];
// GET /api/profil/documents
-app.get('/api/profil/documents', authenticateToken, async (req, res) => {
- try {
- const result = await pool.request()
- .input('collabId', sql.Int, req.user.id)
- .query(`
+ // GET /api/profil/documents
+ app.get('/api/profil/documents', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request()
+ .input('collabId', sql.Int, req.user.id)
+ .query(`
SELECT type, fileName, sharepointUrl, dateUpload, DateModification, statut, commentaire
FROM DocumentsCollaborateur
WHERE collaborateurId = @collabId
- AND type != 'rib' -- ← exclure le rib de cette table
+ AND type != 'rib'
`);
- // Vérifier si IBAN saisi directement dans CollaborateurAD
- const ibanResult = await pool.request()
- .input('collabId', sql.Int, req.user.id)
- .query(`SELECT IBAN FROM CollaborateurAD WHERE id = @collabId`);
+ const ibanResult = await pool.request()
+ .input('collabId', sql.Int, req.user.id)
+ .query(`SELECT IBAN FROM CollaborateurAD WHERE id = @collabId`);
- const ibanSaisi = !!(ibanResult.recordset[0]?.IBAN);
+ const ibanSaisi = !!(ibanResult.recordset[0]?.IBAN);
- const docs = {
- rib: ibanSaisi
- ? { fileName: 'IBAN_saisi', sharepointUrl: '', updatedAt: new Date().toISOString(), statut: 'valide', commentaire: null }
- : null,
- carte_grise: null,
- permis: null
- };
-
- for (const row of result.recordset) {
- docs[row.type] = {
- fileName: row.fileName,
- sharepointUrl: row.sharepointUrl,
- updatedAt: row.DateModification,
- statut: row.statut ?? 'en_attente',
- commentaire: row.commentaire ?? null
+ const docs = {
+ rib: ibanSaisi
+ ? { fileName: 'IBAN_saisi', sharepointUrl: '', updatedAt: new Date().toISOString(), statut: 'valide', commentaire: null }
+ : null,
+ carte_grise: null,
+ permis: null
};
+
+ for (const row of result.recordset) {
+ // ✅ Normaliser cartegrise → carte_grise pour le frontend
+ const frontendKey = row.type === 'cartegrise' ? 'carte_grise' : row.type;
+ docs[frontendKey] = {
+ fileName: row.fileName,
+ sharepointUrl: row.sharepointUrl,
+ updatedAt: row.DateModification,
+ statut: row.statut ?? 'en_attente',
+ commentaire: row.commentaire ?? null
+ };
+ }
+
+ res.json(docs);
+ } catch (error) {
+ console.error('GET /api/profil/documents', error.message);
+ res.status(500).json({ error: error.message });
}
-
- res.json(docs);
- } catch (error) {
- console.error('GET /api/profil/documents', error.message);
- res.status(500).json({ error: error.message });
- }
-});
-
+ });
// POST /api/profil/documents/:type — upload ou remplacement
app.post('/api/profil/documents/:type', authenticateToken, upload.single('file'), async (req, res) => {
try {
- const type = req.params.type;
+ const rawType = req.params.type;
+ const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
+
if (!DOCTYPES.includes(type))
return res.status(400).json({ error: 'Type invalide. Valeurs : rib, cartegrise, permis' });
if (!req.file)
@@ -5017,7 +5293,8 @@ WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1
// DELETE /api/profil/documents/:type
app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => {
try {
- const type = req.params.type;
+ const rawType = req.params.type;
+ const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
if (!DOCTYPES.includes(type))
return res.status(400).json({ error: 'Type invalide' });
await pool.request()
@@ -5225,7 +5502,7 @@ app.post('/api/verificateur/notes/:id/non-conforme', authenticateToken, async (r
.input('id', sql.Int, noteId)
.query(`
SELECT
- n.id, n.reference, n.libelle, n.montant,
+ n.id, n.reference, n.libelle, n.montant,n.lignesJson,
n.collaborateurId,
c.prenom, c.nom, c.email,
v1.id AS n1Id,
@@ -5278,7 +5555,7 @@ app.post('/api/verificateur/notes/:id/non-conforme', authenticateToken, async (r
`);
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
- const montantFormate = parseFloat(note.montant).toFixed(2);
+ const montantFormate = recalculerMontantNote(note).toFixed(2);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
// Notifications (votre code existant inchangé)
@@ -5624,8 +5901,7 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
// Vérifier que toutes les notes sont bien au statut 'verifie'
const notes = await pool.request().query(`
- SELECT n.id, n.reference, n.montant, n.libelle, n.statut,
- c.prenom, c.nom, c.email, c.campus, c.id AS collabId
+ SELECT n.id, n.reference, n.montant, n.libelle, n.statut, n.lignesJson,c.prenom,c.nom,c.email,c.campus,c.id AS collabId
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
@@ -5670,7 +5946,7 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
WHERE r.role = 'President' AND r.actif = 1 AND c.Actif = 1
`);
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
const validateurNom = `${req.user.prenom} ${req.user.nom}`;
@@ -5691,7 +5967,7 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
`
${n.reference}
${n.prenom} ${n.nom}
- ${parseFloat(n.montant).toFixed(2)} €
+ ${recalculerMontantNote(n).toFixed(2)} €
`
).join('');
@@ -5755,12 +6031,12 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
// 2. Président → Récupérer les notes en attente de sa validation
// GET /api/president/notes
// ══════════════════════════════════════════════════════════════════════════
- app.get('/api/president/notes', authenticateToken, async (req, res) => {
- if (!hasAnyRole(req.user, 'President', 'superUtilisateur'))
- return res.status(403).json({ error: 'Accès réservé au Président' });
-
- try {
- const result = await pool.request().query(`
+ app.get('/api/president/notes', authenticateToken, async (req, res) => {
+ if (!hasAnyRole(req.user, 'President', 'superUtilisateur'))
+ return res.status(403).json({ error: 'Accès réservé au Président' });
+
+ try {
+ const result = await pool.request().query(`
SELECT n.*,
c.nom + ' ' + c.prenom AS collaborateur,
c.email AS collaborateurEmail,
@@ -5776,14 +6052,19 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
AND n.presidentValidation = 'en_attente_president'
ORDER BY n.DateModification DESC
`);
-
- res.json(result.recordset);
- } catch (error) {
- console.error('GET /api/president/notes:', error.message);
- res.status(500).json({ error: error.message });
- }
- });
-
+
+ // ✅ Recalculer le montant (km, etc.) au lieu d'afficher n.montant brut
+ const notes = result.recordset.map(n => ({
+ ...n,
+ montant: recalculerMontantNote(n),
+ }));
+
+ res.json(notes);
+ } catch (error) {
+ console.error('GET /api/president/notes:', error.message);
+ res.status(500).json({ error: error.message });
+ }
+ });
// ══════════════════════════════════════════════════════════════════════════
// 3. Président → Générer le XML + valider les notes
// POST /api/president/generer-xml
@@ -5834,8 +6115,8 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
const creDtTm = now.toISOString().slice(0, 19);
const presidentNom = `${req.user.prenom} ${req.user.nom}`;
const msgId = `NDF-PRES-${annee}${mois}-${Date.now().toString().slice(-7)}`;
- const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0);
- const totalFormate = total.toFixed(2);
+ const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
+ const totalFormate = total.toFixed(2);
// Campus dominant pour le compte débiteur
const campusDominant = (() => {
@@ -5878,7 +6159,7 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
${n.reference}
- ${parseFloat(n.montant).toFixed(2)}
+ ${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
@@ -6043,7 +6324,7 @@ app.post('/api/paiements/soumettre-president', authenticateToken, async (req, re
`
${n.reference}
${n.prenom} ${n.nom}
- ${parseFloat(n.montant).toFixed(2)} €
+ ${recalculerMontantNote(n).toFixed(2) } €
`
).join('');
diff --git a/ndf/src/pages/Dashboard.tsx b/ndf/src/pages/Dashboard.tsx
index 609b3b6..dd8a715 100644
--- a/ndf/src/pages/Dashboard.tsx
+++ b/ndf/src/pages/Dashboard.tsx
@@ -158,6 +158,13 @@ interface Exception {
DateDemande?: string;
}
+
+interface LigneDecision {
+ ligneIndex: number;
+ statut: 'valide' | 'refuse' | 'en_attente';
+ commentaire: string;
+}
+
interface PaiementConfig {
JourPaiement: number;
}
@@ -365,28 +372,26 @@ const tagStatut = (statut: string) => {
const StatutStepper = ({ statut }: { statut: string }) => {
const s = normalizeStatut(statut);
- const steps = [
- { key: 'enattente', label: 'Soumise', icon: '📋' },
- { key: 'validen1', label: 'Validation', icon: '✅' },
-
- { key: 'approuve', label: 'Approuvée', icon: '🎉' },
- { key: 'verifie', label: 'Vérifiée', icon: '🔍' },
- { key: 'paiementenattente', label: 'En att. paiement', icon: '⏳' },
- { key: 'payee', label: 'Payée', icon: '💶' },
- ];
+ const steps = [
+ { key: 'brouillon', label: 'Brouillon', icon: '✏️' }, // ← ajouté en 1er
+ { key: 'enattente', label: 'Soumise', icon: '📋' },
+ { key: 'validen1', label: 'Validation', icon: '✅' },
+ { key: 'approuve', label: 'Approuvée', icon: '🎉' },
+ { key: 'verifie', label: 'Vérifiée', icon: '🔍' },
+ { key: 'paiementenattente', label: 'En att. paiement', icon: '⏳' },
+ { key: 'payee', label: 'Payée', icon: '💶' },
+ ];
- const getActiveIndex = () => {
- if (s === 'refuse' || s === 'refus') return -1;
- if (s === 'refuse_verif') return -1; // ✅ AJOUT
- if (s === 'payee') return 6;
- if (s === 'paiementenattente' || s === 'paiement_en_attente') return 5;
- if (s === 'verifie') return 4;
- if (s === 'approuve') return 3;
-
- if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
- if (s === 'non_conforme_verif') return -1;
- return 0;
- };
+ const getActiveIndex = () => {
+ if (s === 'refuse' || s === 'refus' || s === 'refuse_verif' || s === 'non_conforme_verif') return -1;
+ if (s === 'payee') return 6;
+ if (s === 'paiementenattente' || s === 'paiement_en_attente') return 5;
+ if (s === 'verifie') return 4;
+ if (s === 'approuve') return 3;
+ if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 2;
+ if (s === 'enattente' || s === 'en_attente' || s === 'en attente') return 1;
+ return 0; // ← brouillon = étape 0
+ };
const active = getActiveIndex();
@@ -739,349 +744,545 @@ const UrlPreviewPopup = ({ item, onClose }: { item: { url: string; name: string
};
// ── DETAIL LIGNES HELPER ───────────────────────────────
-const LignesDetail = ({
+const LignesDetail =({
note,
tarifKm,
onPreview,
+ // Props optionnelles pour la validation ligne par ligne
+ decisions,
+ onDecisionChange,
}: {
note: Note;
tarifKm: number;
onPreview: (url: string, name: string) => void;
+ decisions?: LigneDecision[];
+ onDecisionChange?: (decisions: LigneDecision[]) => void;
}) => {
let lignesData: any[] = [];
try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
if (!lignesData.length) return null;
-
+
+ // ── État local pour la popup motif de refus ──
+ const [popupMotif, setPopupMotif] = useState<{ ligneIndex: number; commentaire: string } | null>(null);
+
let fichiersTous: { fileName?: string; uploadUrl?: string }[] = [];
if (note.sharepointFiles && note.sharepointFiles.length > 0) fichiersTous = note.sharepointFiles;
else if (note.fichiers) { try { fichiersTous = JSON.parse(note.fichiers); } catch { } }
-
- const EXCLUS_SYSTEME = [
- '_soumission.pdf',
- '_resoumission.pdf',
- '_recap.pdf',
- '-signe-approuve.pdf',
- '-signe-refuse.pdf',
- '-verifie.pdf',
- '-verifie-proratise.pdf',
- 'recap-paiement.pdf',
- ];
-
-
- const totalNote = note.montant || 0;
- //const [previewLoading, setPreviewLoading] = useState(null);
-
- // ── ÉTAPE 1 : résoudre les fichiers de chaque ligne ──────────────────
+
+ const EXCLUS_SYSTEME = [
+ '_soumission.pdf',
+ '_resoumission.pdf',
+ '_recap.pdf',
+ '-signe-approuve.pdf',
+ '-signe-refuse.pdf',
+ '-verifie.pdf',
+ '-verifie-proratise.pdf',
+ 'recap-paiement.pdf',
+ ];
+
+ const totalNote = lignesData.reduce((sum: number, l: any) => {
+ const isKmLine = (l.categorie || '').toLowerCase().includes('kilom');
+ if (isKmLine) return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+
+ // ── Résoudre les fichiers de chaque ligne ──
const lignesResolues = lignesData.map((l: any) => {
- // APRÈS — les km peuvent avoir des justificatifs (facultatifs)
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
-
- // Ne pas ignorer les qrFiles même pour les km
- const fichiersKm: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
- (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
- );
-
+ const fichiersKm: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
+ (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
+ );
if (isKm) return { ...l, _resolvedFiles: fichiersKm };
-
- // Fichiers stockés directement dans qrFiles de la ligne
- const fichiersLigne: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
- (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
- );
- // Fichiers trouvés via qrNoteRef dans fichiersTous
+
+ const fichiersLigne: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
+ (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
+ );
const qrRef: string = l.qrNoteRef || '';
const fichiersQR = qrRef
? fichiersTous.filter(f =>
!EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw)) &&
- ((f.fileName && f.fileName.includes(qrRef)) ||
- (f.uploadUrl && f.uploadUrl.includes(qrRef)))
+ ((f.fileName && f.fileName.includes(qrRef)) || (f.uploadUrl && f.uploadUrl.includes(qrRef)))
)
: [];
-
- // Fusion sans doublons
+
const tousFichiers = [
...fichiersLigne,
...fichiersQR.filter(fq => !fichiersLigne.some(fl => fl.uploadUrl === fq.uploadUrl)),
].filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
-
+
return { ...l, _resolvedFiles: tousFichiers };
});
-
- // ── ÉTAPE 2 : tous les fichiers non-système de la note ──
- const tousFichiersNonSysteme = fichiersTous.filter(f => {
- const n = (f.fileName || '').toLowerCase();
- return !EXCLUS_SYSTEME.some(kw => n.endsWith(kw));
- }).filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
-
- // Fichiers orphelins = non assignés à aucune ligne
+
+ const tousFichiersNonSysteme = fichiersTous.filter(f => {
+ const n = (f.fileName || '').toLowerCase();
+ return !EXCLUS_SYSTEME.some(kw => n.endsWith(kw));
+ }).filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
+
const urlsDejaAssignees = new Set(
lignesResolues.flatMap((l: any) => (l._resolvedFiles || []).map((f: any) => f.uploadUrl))
);
-
- const fichiersOrphelins = tousFichiersNonSysteme.filter(f =>
- !urlsDejaAssignees.has(f.uploadUrl)
- );
-
- // ── ÉTAPE 3 : distribuer aux lignes sans fichiers ──
+ const fichiersOrphelins = tousFichiersNonSysteme.filter(f => !urlsDejaAssignees.has(f.uploadUrl));
+
const indicesLignesSansFichiers = lignesResolues
.map((l: any, i: number) => ({ l, i }))
- .filter(({ l }) =>
- !(l.categorie || '').toLowerCase().includes('kilom') &&
- l._resolvedFiles.length === 0
- )
+ .filter(({ l }) => !(l.categorie || '').toLowerCase().includes('kilom') && l._resolvedFiles.length === 0)
.map(({ i }) => i);
-
+
const distributionOrphelins: Record = {};
-
- if (indicesLignesSansFichiers.length > 0) {
- if (fichiersOrphelins.length > 0) {
- if (indicesLignesSansFichiers.length === 1) {
- // Une seule ligne sans fichier → tous les orphelins
- distributionOrphelins[indicesLignesSansFichiers[0]] = fichiersOrphelins;
- } else {
- // Plusieurs lignes sans fichier → 1 orphelin par ligne séquentiellement
- let pool = [...fichiersOrphelins];
- indicesLignesSansFichiers.forEach(idx => {
- if (pool.length > 0) {
- distributionOrphelins[idx] = [pool.shift()!];
- }
- });
- }
+ if (indicesLignesSansFichiers.length > 0 && fichiersOrphelins.length > 0) {
+ if (indicesLignesSansFichiers.length === 1) {
+ distributionOrphelins[indicesLignesSansFichiers[0]] = fichiersOrphelins;
+ } else {
+ let pool = [...fichiersOrphelins];
+ indicesLignesSansFichiers.forEach(idx => {
+ if (pool.length > 0) distributionOrphelins[idx] = [pool.shift()!];
+ });
}
}
-
+
+ // ── Helper : mettre à jour une décision ──
+ const setDecision = (ligneIndex: number, statut: 'valide' | 'refuse') => {
+ if (!decisions || !onDecisionChange) return;
+ if (statut === 'refuse') {
+ // Ouvrir popup pour le motif
+ setPopupMotif({ ligneIndex, commentaire: decisions.find(d => d.ligneIndex === ligneIndex)?.commentaire || '' });
+ } else {
+ onDecisionChange(decisions.map(d => d.ligneIndex === ligneIndex ? { ...d, statut, commentaire: '' } : d));
+ }
+ };
+
+ const confirmRefus = () => {
+ if (!popupMotif || !decisions || !onDecisionChange) return;
+ onDecisionChange(decisions.map(d =>
+ d.ligneIndex === popupMotif.ligneIndex
+ ? { ...d, statut: 'refuse' as const, commentaire: popupMotif.commentaire }
+ : d
+ ));
+ setPopupMotif(null);
+ };
+
return (
-
-
-
- 📋
-
-
-
- Dépenses déclarées
-
-
- {lignesData.length} dépense{lignesData.length > 1 ? 's' : ''} · total {fmt(totalNote)}
-
-
-
-
-
- {lignesResolues.map((l: any, i: number) => {
- const isKm = (l.categorie || '').toLowerCase().includes('kilom');
- const isRepas = (l.categorie || '').toLowerCase().includes('repas');
- const km = parseFloat(l.km) || 0;
- const montant = isKm
- ? parseFloat((km * tarifKm).toFixed(2))
- : parseFloat(l.montant) || 0;
- const taux = parseFloat(l.tauxTVA) || 0;
- const ht = (!isKm && taux > 0) ? getHT(montant, taux) : null;
- const tva = (!isKm && taux > 0) ? getMontantTVA(montant, taux) : null;
-
- const accent = isKm ? '#7c3aed' : isRepas ? '#d97706' : '#6366f1';
- const icone = isKm ? '🚗' : isRepas ? '🍽️' : '📋';
-
- // ── Fichiers finaux pour cette ligne ──────────────────
- const tousFichiers = [
- ...(l._resolvedFiles || []),
- ...(distributionOrphelins[i] || []),
- ].filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
-
- // ── Badge conformité ──────────────────────────────────
- const nonConformes: any[] = (note as any).nonConformes || [];
- const ncLigne = nonConformes.find((nc: any) =>
- tousFichiers.some(f => f.fileName === nc.fileName) ||
- (nc.ligneIndex !== undefined && nc.ligneIndex === i)
- );
- const conformiteStatut: 'ok' | 'nok' | 'pending' | 'km' =
- isKm ? 'km' :
- ncLigne ? 'nok' :
- tousFichiers.length > 0 ? 'ok' :
- 'pending';
-
- const conformiteBadge = {
- ok: { bg: '#dcfce7', color: '#15803d', label: '✅ Conforme', border: '#86efac' },
- nok: { bg: '#fee2e2', color: '#dc2626', label: '❌ Non conforme', border: '#fca5a5' },
- pending: { bg: '#fef3c7', color: '#b45309', label: '⚠️ Sans justif', border: '#fde68a' },
- km: { bg: '#ede9fe', color: '#7c3aed', label: '🚗 Kilométrique', border: '#c4b5fd' },
- }[conformiteStatut];
-
- return (
-
+ {/* ── Popup motif de refus ── */}
+ {popupMotif && (
+
setPopupMotif(null)}
+ style={{
+ position: 'fixed', inset: 0, zIndex: 10002,
+ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
+ }}>
+
e.stopPropagation()}
+ style={{
+ background: 'var(--bg-card)', borderRadius: 14,
+ padding: 24, maxWidth: 420, width: '100%',
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
+ border: '1.5px solid #fecaca',
}}>
+ {/* En-tête */}
+
-
-
{i + 1}
-
- {icone} {l.categorie || 'Dépense'}
-
+ width: 40, height: 40, borderRadius: 10, flexShrink: 0,
+ background: 'linear-gradient(135deg,#ef4444,#dc2626)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20,
+ }}>❌
+
+
Motif de refus
+
+ Ligne {popupMotif.ligneIndex + 1} — {(() => {
+ const l = lignesData[popupMotif.ligneIndex];
+ return l?.libelle || l?.categorie || `Dépense ${popupMotif.ligneIndex + 1}`;
+ })()}
-
-
- {conformiteBadge.label}
-
-
- {fmt(montant)}
-
-
-
-
-
- {/* ── Ligne titre + date + boutons Voir ── */}
-
-
- {l.libelle || '—'}
-
-
- {/* Boutons Voir inline */}
- {tousFichiers.length > 0 && tousFichiers.map((f: any, fi: number) => {
- const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName || '');
- const isPdf = /\.pdf$/i.test(f.fileName || '');
- return (
-
{
- const url = f.uploadUrl || '';
- const proxied = url.includes('sharepoint') || url.includes('.sharepoint.')
- ? proxyUrl(url)
- : url;
- onPreview(proxied, f.fileName || `Fichier ${fi + 1}`);
- }}
-
- title={f.fileName || `Justificatif ${fi + 1}`}
- style={{
- display: 'inline-flex', alignItems: 'center', gap: 4,
- padding: '4px 10px', background: accent,
- border: 'none', borderRadius: 20,
- cursor: 'pointer', fontFamily: 'inherit',
- fontSize: 11, fontWeight: 700, color: '#fff',
- transition: 'opacity 0.15s', flexShrink: 0,
- }}
- >
- {isImage ? '🖼️' : isPdf ? '📄' : '📎'} Voir
-
- );
- })}
-
- {l.date ? new Date(l.date).toLocaleDateString('fr-FR') : '—'}
-
-
-
-
-
- {/* badges TVA etc — inchangés */}
-
-
- {/* ── Nuits hébergement ── */}
- {(l.categorie || '').toLowerCase().includes('hebergement') && l.nuits && parseInt(l.nuits) > 0 && (() => {
- const montant = parseFloat(l.montant) || 0;
- const nuits = parseInt(l.nuits);
- const parNuit = nuits > 0 && montant > 0 ? montant / nuits : 0;
- return (
-
- 🌙 {nuits} nuit{nuits > 1 ? 's' : ''}
- {parNuit > 0 && (
-
- · {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(parNuit)}/nuit
-
- )}
-
- );
- })()}
-
- {l.description && (
-
- 💬 {l.description}
-
- )}
-
- {ncLigne && (
-
- ⚠️ Non conforme : {ncLigne.motif}
-
- )}
-
- {isKm && tousFichiers.length === 0 && (
-
- 🚗 Aucun justificatif requis pour les frais kilométriques
-
- )}
-
- {/* Message sans justif — seulement si pas km et pas de fichiers */}
- {!isKm && tousFichiers.length === 0 && (
-
- ⚠️ Aucun justificatif joint pour cette dépense
-
- )}
- );
- })}
-
-
- {lignesData.length > 1 && (
-
-
- Total — {lignesData.length} dépenses
-
-
- {fmt(totalNote)}
-
+
+ {/* Textarea */}
+
+
+ Expliquez pourquoi cette dépense est refusée *
+
+
+
+ {/* Boutons */}
+
+ setPopupMotif(null)}
+ style={{
+ flex: 1, padding: '10px',
+ background: 'var(--bg-input)', border: '1px solid var(--border-input)',
+ borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)',
+ }}>
+ Annuler
+
+
+ ❌ Confirmer le refus
+
+
+
)}
-
+
+ {/* ── Contenu principal ── */}
+
+
+
+ 📋
+
+
+
+ Dépenses déclarées
+
+
+ {lignesData.length} dépense{lignesData.length > 1 ? 's' : ''} · total {fmt(totalNote)}
+ {decisions && (
+
+ {decisions.filter(d => d.statut === 'valide').length > 0 && (
+
+ ✅ {decisions.filter(d => d.statut === 'valide').length}
+
+ )}
+ {decisions.filter(d => d.statut === 'refuse').length > 0 && (
+
+ ❌ {decisions.filter(d => d.statut === 'refuse').length}
+
+ )}
+ {decisions.filter(d => d.statut === 'en_attente').length > 0 && (
+
+ ⏳ {decisions.filter(d => d.statut === 'en_attente').length}
+
+ )}
+
+ )}
+
+
+
+
+
+ {lignesResolues.map((l: any, i: number) => {
+ const isKm = (l.categorie || '').toLowerCase().includes('kilom');
+ const isRepas = (l.categorie || '').toLowerCase().includes('repas');
+ const km = parseFloat(l.km) || 0;
+ const montant = isKm
+ ? getIndemniteKm(km, parseInt(l.chevaux) || 7)
+ : parseFloat(l.montant) || 0;
+
+ const accent = isKm ? '#7c3aed' : isRepas ? '#d97706' : '#6366f1';
+ const icone = isKm ? '🚗' : isRepas ? '🍽️' : '📋';
+
+ const tousFichiers = [
+ ...(l._resolvedFiles || []),
+ ...(distributionOrphelins[i] || []),
+ ].filter((f, idx, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === idx);
+
+ const nonConformes: any[] = (note as any).nonConformes || [];
+ const ncLigne = nonConformes.find((nc: any) =>
+ tousFichiers.some(f => f.fileName === nc.fileName) ||
+ (nc.ligneIndex !== undefined && nc.ligneIndex === i)
+ );
+ const conformiteStatut: 'ok' | 'nok' | 'pending' | 'km' =
+ isKm ? 'km' : ncLigne ? 'nok' : tousFichiers.length > 0 ? 'ok' : 'pending';
+
+ const conformiteBadge = {
+ ok: { bg: '#dcfce7', color: '#15803d', label: '✅ Conforme', border: '#86efac' },
+ nok: { bg: '#fee2e2', color: '#dc2626', label: '❌ Non conforme', border: '#fca5a5' },
+ pending: { bg: '#fef3c7', color: '#b45309', label: '⚠️ Sans justif', border: '#fde68a' },
+ km: { bg: '#ede9fe', color: '#7c3aed', label: '🚗 Kilométrique', border: '#c4b5fd' },
+ }[conformiteStatut];
+
+ // ── Décision pour cette ligne ──
+ const decision = decisions?.find(d => d.ligneIndex === i);
+ const decisionStatut = decision?.statut ?? 'en_attente';
+
+ // Couleur de la carte selon la décision
+ const cardBorderColor = decisions
+ ? decisionStatut === 'valide' ? '#86efac'
+ : decisionStatut === 'refuse' ? '#fca5a5'
+ : `${accent}33`
+ : `${accent}33`;
+
+ const cardBg = decisions
+ ? decisionStatut === 'valide' ? '#f0fdf4'
+ : decisionStatut === 'refuse' ? '#fef2f2'
+ : 'var(--bg-card)'
+ : 'var(--bg-card)';
+
+ return (
+
+ {/* En-tête ligne */}
+
+
+
{i + 1}
+
+ {icone} {l.categorie || 'Dépense'}
+
+
+
+ {!decisions && (
+
+ {conformiteBadge.label}
+
+ )}
+
+ {fmt(montant)}
+
+
+ {/* ── Boutons ✅/❌ par ligne (seulement en mode validation) ── */}
+ {decisions && onDecisionChange && (
+
+ setDecision(i, 'valide')}
+ title="Valider cette dépense"
+ style={{
+ width: 28, height: 28,
+ borderRadius: 7,
+ background: decisionStatut === 'valide' ? '#15803d' : '#f0fdf4',
+ color: decisionStatut === 'valide' ? '#fff' : '#15803d',
+ border: `1.5px solid ${decisionStatut === 'valide' ? '#15803d' : '#86efac'}`,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 13, fontWeight: 700,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ flexShrink: 0, transition: 'all 0.15s',
+ }}>
+ ✅
+
+ setDecision(i, 'refuse')}
+ title="Refuser cette dépense"
+ style={{
+ width: 28, height: 28,
+ borderRadius: 7,
+ background: decisionStatut === 'refuse' ? '#dc2626' : '#fef2f2',
+ color: decisionStatut === 'refuse' ? '#fff' : '#dc2626',
+ border: `1.5px solid ${decisionStatut === 'refuse' ? '#dc2626' : '#fca5a5'}`,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 13, fontWeight: 700,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ flexShrink: 0, transition: 'all 0.15s',
+ }}>
+ ❌
+
+
+ )}
+
+
+
+
+
+
+ {l.libelle || '—'}
+
+
+ {tousFichiers.length > 0 && tousFichiers.map((f: any, fi: number) => {
+ const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName || '');
+ const isPdf = /\.pdf$/i.test(f.fileName || '');
+ return (
+
{
+ const url = f.uploadUrl || '';
+ const proxied = url.includes('sharepoint') || url.includes('.sharepoint.')
+ ? proxyUrl(url)
+ : url;
+ onPreview(proxied, f.fileName || `Fichier ${fi + 1}`);
+ }}
+ title={f.fileName || `Justificatif ${fi + 1}`}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '4px 10px', background: accent,
+ border: 'none', borderRadius: 20,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 11, fontWeight: 700, color: '#fff',
+ transition: 'opacity 0.15s', flexShrink: 0,
+ }}>
+ {isImage ? '🖼️' : isPdf ? '📄' : '📎'} Voir
+
+ );
+ })}
+
+ {l.date ? new Date(l.date).toLocaleDateString('fr-FR') : '—'}
+
+
+
+
+ {l.description && (
+
+ 💬 {l.description}
+
+ )}
+
+ {/* Motif de refus si refusée */}
+ {decisions && decisionStatut === 'refuse' && decision?.commentaire && (
+
+ ⚠️ Motif : {decision.commentaire}
+ setPopupMotif({ ligneIndex: i, commentaire: decision.commentaire })}
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ fontSize: 10, color: '#dc2626', fontWeight: 700,
+ fontFamily: 'inherit', flexShrink: 0, padding: '0 2px',
+ textDecoration: 'underline',
+ }}>
+ Modifier
+
+
+ )}
+
+ {ncLigne && (
+
+ ⚠️ Non conforme : {ncLigne.motif}
+
+ )}
+
+ {isKm && tousFichiers.length === 0 && (
+
+ 🚗 Aucun justificatif requis pour les frais kilométriques
+
+ )}
+
+ {!isKm && tousFichiers.length === 0 && (
+
+ ⚠️ Aucun justificatif joint pour cette dépense
+
+ )}
+
+
+ );
+ })}
+
+
+ {lignesData.length > 1 && (
+
+
+ Total — {lignesData.length} dépenses
+
+
+ {fmt(totalNote)}
+
+
+ )}
+
+ >
);
};
@@ -1313,6 +1514,210 @@ const InlinePreviewViewer = ({ item }: { item: { url: string; name: string } })
);
};
+const LignesValidation =({
+ note,
+ tarifKm,
+ decisions,
+ onDecisionChange,
+ onPreview,
+}: {
+ note: Note;
+ tarifKm: number;
+ decisions: LigneDecision[];
+ onDecisionChange: (decisions: LigneDecision[]) => void;
+ onPreview: (url: string, name: string) => void;
+}) => {
+ let lignesData: any[] = [];
+ try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
+ if (!lignesData.length) return null;
+
+ const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
+
+ const setDecision = (index: number, statut: 'valide' | 'refuse') => {
+ onDecisionChange(decisions.map(d => d.ligneIndex === index ? { ...d, statut } : d));
+ };
+ const setCommentaire = (index: number, commentaire: string) => {
+ onDecisionChange(decisions.map(d => d.ligneIndex === index ? { ...d, commentaire } : d));
+ };
+
+ const nbValides = decisions.filter(d => d.statut === 'valide').length;
+ const nbRefuses = decisions.filter(d => d.statut === 'refuse').length;
+ const nbEnAttente = decisions.filter(d => d.statut === 'en_attente').length;
+
+ let fichiersTous: { fileName?: string; uploadUrl?: string }[] = [];
+ if ((note as any).sharepointFiles?.length) fichiersTous = (note as any).sharepointFiles;
+ else if (note.fichiers) { try { fichiersTous = JSON.parse(note.fichiers); } catch { } }
+
+ const EXCLUS_SYSTEME = ['_soumission.pdf', '_resoumission.pdf', '_recap.pdf', '-signe-approuve.pdf', '-signe-refuse.pdf', '-verifie.pdf', '-verifie-proratise.pdf', 'recap-paiement.pdf'];
+
+ return (
+
+ {/* Barre de progression */}
+
0 ? '#fef9c3' : nbRefuses > 0 ? '#fef2f2' : '#f0fdf4',
+ border: `1.5px solid ${nbEnAttente > 0 ? '#fde68a' : nbRefuses > 0 ? '#fecaca' : '#86efac'}`,
+ borderRadius: 9,
+ }}>
+ Décisions :
+ {nbValides > 0 && ✅ {nbValides} validée{nbValides > 1 ? 's' : ''} }
+ {nbRefuses > 0 && ❌ {nbRefuses} refusée{nbRefuses > 1 ? 's' : ''} }
+ {nbEnAttente > 0 && ⏳ {nbEnAttente} en attente }
+ {fmt(note.montant || 0)}
+
+
+ {/* Cartes par ligne */}
+ {lignesData.map((l: any, i: number) => {
+ const decision = decisions.find(d => d.ligneIndex === i);
+ const statut = decision?.statut ?? 'en_attente';
+ const isKm = (l.categorie || '').toLowerCase().includes('kilom');
+ const montant = isKm
+ ? getIndemniteKm(parseFloat(l.km || 0), parseInt(l.chevaux) || 7)
+ : parseFloat(l.montant) || 0;
+
+ const colors = {
+ valide: { accent: '#15803d', bg: '#f0fdf4', border: '#86efac', headerBg: '#dcfce7' },
+ refuse: { accent: '#dc2626', bg: '#fef2f2', border: '#fca5a5', headerBg: '#fee2e2' },
+ en_attente: { accent: '#6366f1', bg: 'var(--bg-card)', border: '#c7d2fe', headerBg: '#eef2ff' },
+ };
+ const c = colors[statut] || colors.en_attente;
+
+ const fichiersLigne: { fileName: string; uploadUrl: string }[] = (l.qrFiles || []).filter(
+ (f: any) => !EXCLUS_SYSTEME.some(kw => (f.fileName || '').toLowerCase().endsWith(kw))
+ );
+
+ return (
+
+ {/* En-tête */}
+
+
+
{i + 1}
+
+
{isKm ? '🚗' : '📋'} {l.categorie || 'Dépense'}
+
{l.libelle || '—'}{l.date ? ` · ${new Date(l.date).toLocaleDateString('fr-FR')}` : ''}
+
+
+
{fmt(montant)}
+
+
+
+ {/* Justificatifs */}
+ {fichiersLigne.length > 0 ? (
+
+ {fichiersLigne.map((f, fi) => (
+ {
+ const url = f.uploadUrl || '';
+ const proxied = (url.includes('sharepoint') || url.includes('.sharepoint.')) ? `/api/proxy-pdf?url=${encodeURIComponent(url)}` : url;
+ onPreview(proxied, f.fileName || `Fichier ${fi + 1}`);
+ }}
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 10px', background: '#eef2ff', border: '1px solid #c7d2fe', borderRadius: 20, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11, fontWeight: 700, color: '#6366f1' }}>
+ {/\.pdf$/i.test(f.fileName || '') ? '📄' : /\.(jpg|jpeg|png|gif|webp)$/i.test(f.fileName || '') ? '🖼️' : '📎'} Voir
+
+ ))}
+
+ ) : !isKm && (
+
⚠️ Aucun justificatif joint
+ )}
+
+ {/* Boutons Valider / Refuser */}
+
+ setDecision(i, 'valide')} style={{
+ flex: 1, padding: '8px 0',
+ background: statut === 'valide' ? '#15803d' : '#f0fdf4',
+ color: statut === 'valide' ? '#fff' : '#15803d',
+ border: `2px solid ${statut === 'valide' ? '#15803d' : '#86efac'}`,
+ borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 700, transition: 'all 0.15s',
+ }}>✅ Valider
+ setDecision(i, 'refuse')} style={{
+ flex: 1, padding: '8px 0',
+ background: statut === 'refuse' ? '#dc2626' : '#fef2f2',
+ color: statut === 'refuse' ? '#fff' : '#dc2626',
+ border: `2px solid ${statut === 'refuse' ? '#dc2626' : '#fca5a5'}`,
+ borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 700, transition: 'all 0.15s',
+ }}>❌ Refuser
+
+
+ {/* Zone commentaire si refusée */}
+ {statut === 'refuse' && (
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
+
+const LignesRefuseesDetail =({ note }: { note: Note }) => {
+ const [lignesRefusees, setLignesRefusees] = useState
([]);
+
+ useEffect(() => {
+ const statutNorm = (note.statut || '').toLowerCase().trim();
+ if (!['refuse', 'refuse_verif'].includes(statutNorm)) { setLignesRefusees([]); return; }
+ const token = localStorage.getItem('token');
+ fetch(`/api/notes/${note.id}/lignes-refusees-n1`, { headers: { Authorization: `Bearer ${token}` } })
+ .then(r => r.ok ? r.json() : [])
+ .then(d => Array.isArray(d) && setLignesRefusees(d))
+ .catch(() => { });
+ }, [note.id, note.statut]);
+
+ if (!lignesRefusees.length) return null;
+
+ let lignesData: any[] = [];
+ try { if (note.lignesJson) lignesData = JSON.parse(note.lignesJson); } catch { }
+
+ return (
+
+
+ ❌ {lignesRefusees.length} dépense{lignesRefusees.length > 1 ? 's' : ''} à corriger
+
+
+ {lignesRefusees.map((lr: any, i: number) => {
+ const ligne = lignesData[lr.ligneIndex] || {};
+ const label = ligne.libelle || lr.ligneLibelle || `Ligne ${lr.ligneIndex + 1}`;
+ return (
+
+
+
+ {lr.ligneIndex + 1}
+
+
+
{label}
+ {(lr.ligneCategorie || ligne.categorie) &&
{lr.ligneCategorie || ligne.categorie}
}
+
+
+
+ ⚠️ {lr.motif}
+
+ {lr.verificateur &&
Signalé par {lr.verificateur}
}
+
+ );
+ })}
+
+
+ );
+};
+
// ══════════════════════════════════════════════════════
// MAIN COMPONENT
// ══════════════════════════════════════════════════════
@@ -1324,7 +1729,7 @@ const Dashboard = (): JSX.Element => {
const canValidate = isValidateur;
const isRHAdmin = isFinance;
-
+ const [noteEditKey, setNoteEditKey] = useState(0);
const [section, setSection] = useState('accueil');
const [notes, setNotes] = useState([]);
const [pending, setPending] = useState([]);
@@ -1485,10 +1890,12 @@ const Dashboard = (): JSX.Element => {
const lignePollingRefs = useRef>>({});
const [noteDetail, setNoteDetail] = useState(null);
+ const [lignesDecisions, setLignesDecisions] = useState([]);
const [selectedNote, setSelectedNote] = useState(null);
+
const [serverBrouillons, setServerBrouillons] = useState<{ id: number; libelle: string; date: string; description: string; lignesJson: string; montant: number; DateModification: string }[]>([]);
- const [brouillonSaving, setBrouillonSaving] = useState(false);
+
const autoSaveTimer = useRef | null>(null);
const token = localStorage.getItem('token');
@@ -1530,52 +1937,50 @@ const Dashboard = (): JSX.Element => {
}, []);
// ── Auto-refresh toutes les 60 secondes ──────────────
+
useEffect(() => {
- const interval = setInterval(() => {
- // Rafraîchir les données de la section active
- if (section === 'mesnotes' || section === 'accueil') {
+ let inactivityTimer: ReturnType | null = null;
+
+ const resetTimer = () => {
+ if (inactivityTimer) clearTimeout(inactivityTimer);
+ inactivityTimer = setTimeout(() => {
+ // Rafraîchir les données après 3 minutes d'inactivité
setNotesLoaded(false);
- }
- if ((section === 'validation' || section === 'accueil') && canValidate) {
- fetch(`${API}/api/notes/pending`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setPending(d));
- }
- if (section === 'verification' && isVerificateurFinance) {
- fetch(`${API}/api/verificateur/notes`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setNotesAVerifier(d));
- }
- if (section === 'paiements' && (isRHAdmin || isValidateurFinance)) {
- refreshNotesPaiements();
- fetch(`${API}/api/paiements/exceptions`, { headers: hdrs })
+ fetch(`${API}/api/notifications`, { headers: hdrs })
.then(r => r.ok ? r.json() : [])
- .then(d => Array.isArray(d) && setExceptions(d))
+ .then(d => Array.isArray(d) && setNotifications(d))
.catch(() => { });
- // ← Charger les filtres dès l'arrivée sur la section paiements
- const currentToken = localStorage.getItem('token');
- const currentHdrs = {
- Authorization: `Bearer ${currentToken}`,
- 'Content-Type': 'application/json'
- };
- fetch(`${API}/api/paiements/filtres-disponibles`, { headers: currentHdrs })
- .then(r => r.ok ? r.json() : { campus: [], societes: [], societeParCampus: {} })
- .then(d => setFiltresDisponibles(d))
- .catch(() => { });
- }
- if (section === 'xmlgenerés' && (isRHAdmin || isValidateurFinance)) {
- setXmlLoading(true);
- const params = new URLSearchParams();
- if (xmlFiltreAnnee) params.append('annee', xmlFiltreAnnee);
- if (xmlFiltreMois) params.append('mois', xmlFiltreMois);
- fetch(`${API}/api/paiements/xml-historique?${params}`, { headers: hdrs })
- .then(r => r.ok ? r.json() : [])
- .then(d => Array.isArray(d) && setXmlHistorique(d))
- .catch(() => { })
- .finally(() => setXmlLoading(false));
- }
- fetch(`${API}/api/notifications`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setNotifications(d));
- }, 60000);
+ if (canValidate) {
+ fetch(`${API}/api/notes/pending`, { headers: hdrs })
+ .then(r => r.ok ? r.json() : [])
+ .then(d => Array.isArray(d) && setPending(d))
+ .catch(() => { });
+ }
+ if (isVerificateurFinance) {
+ fetch(`${API}/api/verificateur/notes`, { headers: hdrs })
+ .then(r => r.ok ? r.json() : [])
+ .then(d => Array.isArray(d) && setNotesAVerifier(d))
+ .catch(() => { });
+ }
+ if (isRHAdmin || isValidateurFinance) {
+ refreshNotesPaiements();
+ }
+ }, 3 * 60 * 1000); // 3 minutes d'inactivité
+ };
- return () => clearInterval(interval);
- }, [section, canValidate, isVerificateurFinance, isRHAdmin, isValidateurFinance]);
+ // Événements qui signalent de l'activité
+ const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart', 'click'];
+ events.forEach(e => window.addEventListener(e, resetTimer, { passive: true }));
+
+ // Démarrer le timer immédiatement
+ resetTimer();
+
+ return () => {
+ if (inactivityTimer) clearTimeout(inactivityTimer);
+ events.forEach(e => window.removeEventListener(e, resetTimer));
+ };
+ }, [canValidate, isVerificateurFinance, isRHAdmin, isValidateurFinance]);
// ── Brouillons API ──────────────────────────────────
useEffect(() => {
@@ -1600,38 +2005,6 @@ const Dashboard = (): JSX.Element => {
.catch(() => { });
}, [isRHAdmin, isValidateurFinance]);
- useEffect(() => {
- const isEmpty = !libelle && !description && lignes.every(l => !l.libelle && !l.categorie && !l.montant && !l.km);
- if (isEmpty) return;
- if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
- autoSaveTimer.current = setTimeout(async () => {
- setBrouillonSaving(true);
- const currentId = activeBrouillonIdRef.current;
- const currentToken = localStorage.getItem('token');
- const currentHdrs = { Authorization: `Bearer ${currentToken}`, 'Content-Type': 'application/json' };
- try {
- const lignesPayload = lignes.map(l => ({ ...l, files: [] }));
- const body = JSON.stringify({ libelle, date, description, lignes: lignesPayload });
- if (currentId) {
- const res = await fetch(`${API}/api/notes/brouillons/${currentId}`, { method: 'PUT', headers: currentHdrs, body });
- if (res.ok) {
- const data = await res.json();
- setServerBrouillons(prev => prev.map(b => b.id === currentId ? { ...b, libelle, date, description, lignesJson: JSON.stringify(lignesPayload), DateModification: data.updatedAt } : b));
- }
- } else {
- const res = await fetch(`${API}/api/notes/brouillons`, { method: 'POST', headers: currentHdrs, body });
- if (res.ok) {
- const data = await res.json();
- activeBrouillonIdRef.current = data.id;
- setActiveBrouillonId(data.id);
- localStorage.setItem('ndf_brouillon_active', String(data.id));
- setServerBrouillons(prev => [{ id: data.id, libelle, date, description, lignesJson: JSON.stringify(lignesPayload), montant: 0, DateModification: data.createdAt }, ...prev]);
- }
- }
- } catch (e) { console.error('Auto-save failed:', e); }
- finally { setBrouillonSaving(false); }
- }, 3000);
- }, [lignes, libelle, date, description]);
const clearBrouillon = async (id?: number) => {
const targetId = id ?? activeBrouillonId as number;
@@ -1655,6 +2028,7 @@ const Dashboard = (): JSX.Element => {
activeBrouillonIdRef.current = b.id;
localStorage.setItem('ndf_brouillon_active', String(b.id));
setShowBrouillonList(false);
+ setNoteEditKey(k => k + 1);
};
const newBrouillon = () => {
@@ -1664,6 +2038,7 @@ const Dashboard = (): JSX.Element => {
setLibelle(''); setDate(today); setDescription('');
setLignes([newLigne(today)]); setFiles([]);
setShowBrouillonList(false);
+ setNoteEditKey(k => k + 1);
};
// ── refreshNotesPaiements ───────────────────────────
@@ -1845,16 +2220,45 @@ const Dashboard = (): JSX.Element => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, Lu: true } : n));
};
- const handleValider = async (id: number, action: 'valider' | 'refuser', commentaire?: string) => {
- try {
- const res = await fetch(`${API}/api/notes/${id}/statut`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ action, commentaire }) });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || 'Erreur');
- showToast(action === 'valider' ? `Note validée → ${data.statut}` : 'Note refusée', action === 'valider' ? 'success' : 'error');
- setPending(p => p.filter(n => n.id !== id));
- setNotesLoaded(false);
- } catch (e: any) { showToast(e.message || 'Erreur', 'error'); }
- };
+ const handleValider = async (id: number, action: 'valider' | 'refuser', commentaire?: string, decisions?: LigneDecision[]) => {
+ try {
+ const body: any = { action, commentaire };
+ if (decisions && decisions.length > 0) {
+ body.lignesDecisions = decisions.map(d => ({
+ ligneIndex: d.ligneIndex,
+ statut: d.statut,
+ commentaire: d.commentaire,
+ }));
+ }
+
+ const token = localStorage.getItem('token');
+ const res = await fetch(`/api/notes/${id}/statut`, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Erreur');
+
+ const nbRefuses = decisions?.filter(d => d.statut === 'refuse').length ?? 0;
+ const nbValides = decisions?.filter(d => d.statut === 'valide').length ?? 0;
+
+ if (decisions && nbRefuses > 0 && nbValides > 0) {
+ showToast(`Note renvoyée — ${nbRefuses} correction(s) demandée(s), ${nbValides} ligne(s) validée(s)`, 'error');
+ } else if (decisions && nbRefuses > 0) {
+ showToast(`Note refusée — ${nbRefuses} correction(s) demandée(s)`, 'error');
+ } else {
+ showToast(action === 'valider' ? `Note validée → ${data.statut}` : 'Note refusée', action === 'valider' ? 'success' : 'error');
+ }
+
+ setPending(p => p.filter(n => n.id !== id));
+ setNotesLoaded(false);
+ setNoteDetail(null);
+ setLignesDecisions([]);
+ } catch (e: any) {
+ showToast(e.message || 'Erreur', 'error');
+ }
+ };
const handleModifierNote = (note: Note) => {
setLibelle(note.libelle || '')
@@ -1893,10 +2297,76 @@ const Dashboard = (): JSX.Element => {
activeBrouillonIdRef.current = noteKey as any
localStorage.setItem('ndfbrouillonactive', noteKey)
setSelectedNote(null)
+ setNoteEditKey(k => k + 1);
nav('nouvelle')
showToast('Note chargée : corrigez puis resoumettez.', 'success')
};
+
+ const handleDupliquerNote = (note: Note) => {
+ // Libellé global repris avec mention "(copie)"
+ setLibelle((note.libelle || '') + ' (copie)');
+ setDate(today);
+ setDescription(note.description || '');
+
+ try {
+ const parsed = JSON.parse(note.lignesJson || '[]');
+ setLignes(parsed.length
+ ? parsed.map((l: any) => ({
+ ...newLigne(today), // ← repart d'une ligne 100% vierge
+ categorie: l.categorie || '', // on garde la catégorie
+ libelle: l.libelle || '', // et le libellé (évite de retaper)
+ // tout le reste (montant, tvaItems, km, chevaux, participants,
+ // qrFiles, files…) reste aux valeurs vides de newLigne()
+ }))
+ : [newLigne(today)]);
+ } catch {
+ setLignes([newLigne(today)]);
+ }
+
+ // ✅ Nouveau brouillon indépendant (ni note refusée, ni note source)
+ setActiveBrouillonId('');
+ activeBrouillonIdRef.current = '';
+ localStorage.removeItem('ndf_brouillon_active');
+ setFiles([]);
+ setQrLink(null); setQrNoteRef(''); setQrUploaded(false);
+
+ setSelectedNote(null);
+ setNoteEditKey(k => k + 1);
+ nav('nouvelle');
+ showToast('Note dupliquée — il ne reste qu\'à saisir les montants et joindre les justificatifs.', 'success');
+ };
+
+ const handleSupprimerBrouillon = async (id: number, e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!window.confirm('Supprimer ce brouillon définitivement ?')) return;
+
+ try {
+ const res = await fetch(`${API}/api/notes/brouillons/${id}`, {
+ method: 'DELETE',
+ headers: hdrs,
+ });
+ if (!res.ok) throw new Error('Erreur lors de la suppression');
+
+ // Retire le brouillon des deux listes locales (pas besoin d'un nouveau fetch)
+ setNotes(prev => prev.filter(n => n.id !== id));
+ setServerBrouillons(prev => prev.filter(b => b.id !== id));
+
+ // Si c'était le brouillon actif → reset l'état + localStorage
+ if (id === Number(activeBrouillonId)) {
+ setActiveBrouillonId('');
+ activeBrouillonIdRef.current = '';
+ localStorage.removeItem('ndf_brouillon_active');
+ }
+
+ // Si c'était la note sélectionnée dans le détail → désélectionne
+ if (selectedNote?.id === id) setSelectedNote(null);
+
+ showToast('Brouillon supprimé', 'success');
+ } catch (err: any) {
+ showToast(err.message || 'Erreur', 'error');
+ }
+ };
const handleTraiterException = async (id: number, statut: 'approuve' | 'refuse') => {
try {
const res = await fetch(`${API}/api/paiements/exceptions/${id}`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ statut }) });
@@ -2161,7 +2631,20 @@ const Dashboard = (): JSX.Element => {
{ label: 'Statut', value: tagStatut(note.statut || 'brouillon'), isNode: true },
{ label: 'Catégorie', value: note.categorie },
{ label: 'Date', value: note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '-' },
- { label: 'Montant TTC', value: fmt(note.montant || 0), bold: true },
+ {
+
+ label: 'Montant TTC', value: fmt((() => {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ if (!lignes.length) return note.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return note.montant || 0; }
+ })()), bold: true
+ },
{ label: 'Collaborateur', value: (note as any).collaborateur },
].filter(i => i.value).map(({ label, value, mono, bold, isNode }: any) => (
@@ -2490,7 +2973,7 @@ const Dashboard = (): JSX.Element => {
marquerNotifLue(n.id)} style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-divider)', background: n.Lu ? 'var(--bg-card)' : 'var(--bg-notif-unread)', cursor: 'pointer' }}>
{n.Titre}
{n.Message}
-
{new Date(n.DateCreation).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
+
{new Date(n.DateCreation).toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
))
}
@@ -2595,8 +3078,23 @@ const Dashboard = (): JSX.Element => {
{note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '—'}
-
- {fmt(note.montant || 0)}
+
+
+ {fmt(note.montant || 0)}
+
+
{ e.stopPropagation(); handleDupliquerNote(note); }}
+ title="Dupliquer cette note (mêmes catégories, sans montants ni justificatifs)"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '5px 10px',
+ background: 'linear-gradient(135deg,#7c3aed,#6d28d9)',
+ color: '#fff', border: 'none', borderRadius: 7,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 11, fontWeight: 700,
+ }}>
+ 🗐 Dupliquer
+
))}
@@ -2724,8 +3222,18 @@ const Dashboard = (): JSX.Element => {
👤 {note.collaborateur} · {note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '—'}
-
- {fmt(note.montant || 0)}
+
+ {fmt((() => {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ if (!lignes.length) return note.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return note.montant || 0; }
+ })())}
))}
@@ -3235,20 +3743,24 @@ const Dashboard = (): JSX.Element => {
{/* NOUVELLE NOTE */}
{/* ════════════════════════════════════════ */}
{section === 'nouvelle' && (
-
- handleSoumettre(titre, dateDebut, dateFin, commentaire, depenses)}
- onCancel={() => nav('accueil')}
- initialBrouillonId={typeof activeBrouillonId === 'number' ? activeBrouillonId : null}
- libelleInitial={libelle}
- dateDebutInitiale={date}
- commentaireInitial={description}
- depensesInitiales={lignes}
- onNavigateToProfil={() => nav('profil')}
-
+
+ handleSoumettre(titre, dateDebut, dateFin, commentaire, depenses)}
+ onCancel={() => nav('accueil')}
+ initialBrouillonId={typeof activeBrouillonId === 'number' ? activeBrouillonId : null}
+ libelleInitial={libelle}
+ dateDebutInitiale={date}
+ commentaireInitial={description}
+ depensesInitiales={lignes}
+ onNavigateToProfil={() => nav('profil')}
+ onBrouillonChange={(id: number) => { // ← AJOUT
+ setActiveBrouillonId(id);
+ activeBrouillonIdRef.current = id;
+ localStorage.setItem('ndf_brouillon_active', String(id));
+ }}
/>
)}
@@ -3310,7 +3822,7 @@ const Dashboard = (): JSX.Element => {
{(() => {
const notesFiltrees = notes
- .filter(note => normalizeStatut(note.statut) !== 'brouillon')
+
.filter(note => !mesNotesFiltreStatut || normalizeStatut(note.statut) === mesNotesFiltreStatut);
if (notesFiltrees.length === 0) return (
@@ -3330,23 +3842,32 @@ const Dashboard = (): JSX.Element => {
return (
{
- if (note.statut === 'brouillon') {
- const b = {
- id: note.id as unknown as number,
- libelle: note.libelle || '',
- date: note.date ? String(note.date).split('T')[0] : today,
- description: note.description || '',
- lignesJson: note.lignesJson || '[]',
- montant: note.montant || 0,
- DateModification: note.DateModification || new Date().toISOString()
- };
- loadBrouillon(b);
- nav('nouvelle');
- } else {
- setSelectedNote(isSelected ? null : note);
- }
- }}
+ onClick={async () => {
+ if (note.statut === 'brouillon') {
+ // 1. Essaie d'abord serverBrouillons (cache local)
+ let b = serverBrouillons.find(sb => sb.id === note.id);
+
+ // 2. Sinon, refetch côté serveur pour être sûr d'avoir le lignesJson le plus récent
+ if (!b) {
+ try {
+ const res = await fetch(`${API}/api/notes/brouillons/${note.id}`, { headers: hdrs });
+ if (res.ok) {
+ const fresh = await res.json();
+ b = fresh;
+ }
+ } catch { /* ignore */ }
+ }
+
+ if (b) {
+ loadBrouillon(b);
+ nav('nouvelle');
+ } else {
+ showToast('Impossible de charger ce brouillon', 'error');
+ }
+ } else {
+ setSelectedNote(isSelected ? null : note);
+ }
+ }}
style={{
border: `2px solid ${note.statut === 'brouillon' ? '#fde68a'
: isSelected ? '#6366f1'
@@ -3381,11 +3902,51 @@ const Dashboard = (): JSX.Element => {
{note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '-'}
-
-
- {fmt(note.montant || 0)}
-
+
+
+ {fmt((() => {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ if (!lignes.length) return note.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return note.montant || 0; }
+ })())}
+
{tagStatut(note.statut || 'brouillon')}
+
+ {normalizeStatut(note.statut) === 'brouillon' ? (
+
handleSupprimerBrouillon(note.id, e)}
+ title="Supprimer ce brouillon"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '5px 10px', marginTop: 2,
+ background: 'linear-gradient(135deg,#ef4444,#dc2626)',
+ color: '#fff', border: 'none', borderRadius: 7,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 11, fontWeight: 700,
+ }}>
+ 🗑 Supprimer
+
+ ) : (
+
{ e.stopPropagation(); handleDupliquerNote(note); }}
+ title="Dupliquer cette note (mêmes catégories, sans montants ni justificatifs)"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '5px 10px', marginTop: 2,
+ background: 'linear-gradient(135deg,#7c3aed,#6d28d9)',
+ color: '#fff', border: 'none', borderRadius: 7,
+ cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 11, fontWeight: 700,
+ }}>
+ 🗐 Dupliquer
+
+ )}
@@ -3437,7 +3998,19 @@ const Dashboard = (): JSX.Element => {
{ label: 'Statut', value: tagStatut(selectedNote.statut || 'brouillon'), isNode: true },
{ label: 'Catégorie', value: selectedNote.categorie },
{ label: 'Date', value: selectedNote.date ? new Date(selectedNote.date).toLocaleDateString('fr-FR') : '-' },
- { label: 'Montant TTC', value: fmt(selectedNote.montant || 0), bold: true },
+ {
+ label: 'Montant TTC', value: fmt((() => {
+ try {
+ const lignes = JSON.parse(selectedNote.lignesJson || '[]');
+ if (!lignes.length) return selectedNote.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return selectedNote.montant || 0; }
+ })()), bold: true
+ },
{ label: 'Collaborateur', value: (selectedNote as any).collaborateur },
].filter(i => i.value).map(({ label, value, mono, bold, isNode }: any) => (
@@ -3500,6 +4073,7 @@ const Dashboard = (): JSX.Element => {
}}>✏️ Corriger et resoumettre
)}
+
{/* Modifiable si en attente */}
{['enattente', 'en_attente', 'en attente'].includes(normalizeStatut(selectedNote.statut || '')) && (
@@ -3601,7 +4175,25 @@ const Dashboard = (): JSX.Element => {
: pending.map(note => {
const isSelected = noteDetail?.id === note.id;
return (
- { setNoteDetail(isSelected ? null : note); setInlinePreview(null); }} style={{
+
{
+ const isSelected = noteDetail?.id === note.id;
+ setNoteDetail(isSelected ? null : note);
+ setInlinePreview(null);
+ if (!isSelected) {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ setLignesDecisions(lignes.map((_: any, i: number) => ({
+ ligneIndex: i,
+ statut: 'en_attente' as const,
+ commentaire: '',
+ })));
+ } catch {
+ setLignesDecisions([]);
+ }
+ } else {
+ setLignesDecisions([]);
+ }
+}} style={{
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
border: `1.5px solid ${isSelected ? '#6366f1' : 'var(--border-card)'}`,
background: isSelected ? '#eef2ff' : 'var(--bg-card)',
@@ -3616,7 +4208,17 @@ const Dashboard = (): JSX.Element => {
background: normalizeStatut(note.statut || '') === 'enattente' ? '#f59e0b' : '#7c3aed',
padding: '2px 6px', borderRadius: 4
}}>{normalizeStatut(note.statut || '') === 'enattente' ? 'N1' : 'N2'}
- {fmt(note.montant || 0)}
+ {fmt((() => {
+ try {
+ const lignes = JSON.parse(note.lignesJson || '[]');
+ if (!lignes.length) return note.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return note.montant || 0; }
+ })())}
);
@@ -3629,17 +4231,30 @@ const Dashboard = (): JSX.Element => {
{noteDetail ? (
<>
{/* COL 2 : dépenses */}
-
+
+ {/* Header */}
- {noteDetail.collaborateur} · {fmt(noteDetail.montant || 0)}
+ {noteDetail.collaborateur} · {fmt((() => {
+ try {
+ const lignes = JSON.parse(noteDetail.lignesJson || '[]');
+ if (!lignes.length) return noteDetail.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return noteDetail.montant || 0; }
+ })())}
{noteDetail.libelle}
-
setNoteDetail(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: 'var(--text-muted)', flexShrink: 0 }}>✕
+
{ setNoteDetail(null); setLignesDecisions([]); }} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: 'var(--text-muted)', flexShrink: 0 }}>✕
-
+
+ {/* Corps scrollable */}
+
{(noteDetail as any).noteRefuseeId && (
@@ -3652,7 +4267,19 @@ const Dashboard = (): JSX.Element => {
{ label: 'Référence', value: noteDetail.reference || `#${noteDetail.id}`, mono: true },
{ label: 'Statut', value: tagStatut(noteDetail.statut || 'brouillon'), isNode: true },
{ label: 'Date', value: noteDetail.date ? new Date(noteDetail.date).toLocaleDateString('fr-FR') : '-' },
- { label: 'Montant TTC', value: fmt(noteDetail.montant || 0), bold: true },
+ {
+ label: 'Montant TTC', value: fmt((() => {
+ try {
+ const lignes = JSON.parse(noteDetail.lignesJson || '[]');
+ if (!lignes.length) return noteDetail.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return noteDetail.montant || 0; }
+ })()), bold: true
+ },
].map(({ label, value, mono, bold, isNode }: any) => (
{label}
@@ -3663,29 +4290,77 @@ const Dashboard = (): JSX.Element => {
{noteDetail.commentaireN1 && (
- 💬 Commentaire N1 : {noteDetail.commentaireN1}
+ 💬 Commentaire précédent : {noteDetail.commentaireN1}
)}
+ {/* Bandeau aide */}
+
+ 💡 Validez ou refusez chaque dépense. Un motif est obligatoire pour tout refus.
+
+ {/* Composant de validation ligne par ligne */}
+ {/* Composant de validation ligne par ligne */}
setInlinePreview({ url, name })}
+ decisions={lignesDecisions}
+ onDecisionChange={setLignesDecisions}
/>
- {/* Boutons fixes en bas */}
-
- { setMotifRefusInput(''); setModalValidation({ noteId: noteDetail.id, action: 'valider', onClose: () => setNoteDetail(null) }); }}>
- ✅ Valider
-
- { setMotifRefusInput(''); setModalValidation({ noteId: noteDetail.id, action: 'refuser', onClose: () => setNoteDetail(null) }); }}>
- ❌ Refuser
-
-
+
+ {/* Boutons contextuels fixes en bas */}
+ {(() => {
+ const nbEnAttente = lignesDecisions.filter(d => d.statut === 'en_attente').length;
+ const nbRefuses = lignesDecisions.filter(d => d.statut === 'refuse').length;
+ const refusesAvecMotif = lignesDecisions.filter(d => d.statut === 'refuse' && (d.commentaire || '').trim());
+ const peutSoumettre = nbEnAttente === 0 && (nbRefuses === 0 || refusesAvecMotif.length === nbRefuses);
+ const toutValide = lignesDecisions.length > 0 && lignesDecisions.every(d => d.statut === 'valide');
+ const toutDecide = nbEnAttente === 0 && lignesDecisions.length > 0;
+
+ return (
+
+ {/* Alerte si motifs manquants */}
+ {toutDecide && !peutSoumettre && (
+
+ ⚠️ {nbRefuses - refusesAvecMotif.length} refus sans motif — renseignez les motifs ci-dessus
+
+ )}
+
+ {/* Boutons rapides si rien décidé */}
+ {!toutDecide && lignesDecisions.length > 0 && (
+
+ setLignesDecisions(lignesDecisions.map(d => ({ ...d, statut: 'valide' as const })))}
+ style={{ flex: 1, padding: '8px 0', background: '#f0fdf4', color: '#15803d', border: '2px solid #86efac', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 700 }}>
+ ✅ Tout valider
+
+ setLignesDecisions(lignesDecisions.map(d => ({ ...d, statut: 'refuse' as const })))}
+ style={{ flex: 1, padding: '8px 0', background: '#fef2f2', color: '#dc2626', border: '2px solid #fca5a5', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 700 }}>
+ ❌ Tout refuser
+
+
+ )}
+
+ {/* Bouton principal contextuel */}
+ {toutValide ? (
+
handleValider(noteDetail.id, 'valider', undefined, lignesDecisions)}
+ style={{ width: '100%', padding: '11px', background: 'linear-gradient(135deg,#10b981,#059669)', color: '#fff', border: 'none', borderRadius: 9, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
+ ✅ Approuver ({lignesDecisions.length} dépense{lignesDecisions.length > 1 ? 's' : ''})
+
+ ) : toutDecide && nbRefuses > 0 && peutSoumettre ? (
+
handleValider(noteDetail.id, 'refuser', undefined, lignesDecisions)}
+ style={{ width: '100%', padding: '11px', background: 'linear-gradient(135deg,#ef4444,#dc2626)', color: '#fff', border: 'none', borderRadius: 9, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
+ ❌ Renvoyer — {nbRefuses} correction{nbRefuses > 1 ? 's' : ''} demandée{nbRefuses > 1 ? 's' : ''}
+
+ ) : null}
+
+ );
+ })()}
+
{/* COL 3 : aperçu justificatif inline */}
{/* COL 3 : aperçu justificatif inline */}
@@ -3984,7 +4659,17 @@ const Dashboard = (): JSX.Element => {
· {selectedNoteIds.length} sélectionnée(s) — {fmt(
notesFiltreesPaiements
.filter(n => selectedNoteIds.includes(n.id))
- .reduce((s, n) => s + (n.montant ?? 0), 0)
+ .reduce((s, n) => {
+ try {
+ const lignes = JSON.parse(n.lignesJson || '[]');
+ if (!lignes.length) return s + (n.montant ?? 0);
+ return s + lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return s + (n.montant ?? 0); }
+ }, 0)
)}
)}
@@ -4012,7 +4697,17 @@ const Dashboard = (): JSX.Element => {
{n.campus ?
{normalizeCampus(n.campus)} : '-'}
{n.societe || '-'}
-
{fmt(n.montant ?? 0)}
+
{fmt((() => {
+ try {
+ const lignes = JSON.parse(n.lignesJson || '[]');
+ if (!lignes.length) return n.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return n.montant || 0; }
+ })())}
{tagStatut(n.statut ?? 'brouillon')}
);
@@ -4102,7 +4797,17 @@ const Dashboard = (): JSX.Element => {
return (
{groupesTries.map(([dateKey, notesGroupe]) => {
- const totalGroupe = notesGroupe.reduce((s, n) => s + (n.montant ?? 0), 0);
+ const totalGroupe = notesGroupe.reduce((s, n) => {
+ try {
+ const lignes = JSON.parse(n.lignesJson || '[]');
+ if (!lignes.length) return s + (n.montant ?? 0);
+ return s + lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return s + (n.montant ?? 0); }
+ }, 0);
const tousSelectes = notesGroupe.every(n => selectedNoteIdsPaye.includes(n.id));
const certainsSelectes = notesGroupe.some(n => selectedNoteIdsPaye.includes(n.id));
const dateLabel = dateKey === 'sans-date'
@@ -4166,6 +4871,7 @@ const Dashboard = (): JSX.Element => {
}}>
{fmt(totalGroupe)}
+
{/* Tableau du groupe */}
@@ -4204,12 +4910,48 @@ const Dashboard = (): JSX.Element => {
) :
— }
{(n as any).societe || '—'}
-
{fmt(n.montant ?? 0)}
-
e.stopPropagation()}>
- {pdfUrl
- ? setPreviewUrl({ url: proxyUrl(pdfUrl!), name: 'PDF' })} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '5px 10px', background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', color: '#15803d', fontSize: 12, fontWeight: 600 }}>📄
- : — }
-
+
{fmt((() => {
+ try {
+ const lignes = JSON.parse(n.lignesJson || '[]');
+ if (!lignes.length) return n.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ if ((l.categorie || '').toLowerCase().includes('kilom'))
+ return sum + getIndemniteKm(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
+ return sum + (parseFloat(l.montant) || 0);
+ }, 0);
+ } catch { return n.montant || 0; }
+ })())}
+
e.stopPropagation()}>
+ {
+ try {
+ const res = await fetch(`${API}/api/paiements/regenerer-xml`, {
+ method: 'POST',
+ headers: hdrs,
+ body: JSON.stringify({ noteIds: [n.id] })
+ });
+ if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
+ const blob = await res.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `virement-${n.reference || n.id}.xml`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e: any) {
+ showToast(e.message || 'Erreur téléchargement XML', 'error');
+ }
+ }}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '5px 10px', background: '#eef2ff',
+ border: '1px solid #c7d2fe', borderRadius: 6,
+ cursor: 'pointer', fontFamily: 'inherit',
+ color: '#1d4ed8', fontSize: 12, fontWeight: 600,
+ }}>
+ 🏦 XML
+
+
);
})}
@@ -5892,17 +6634,19 @@ const Dashboard = (): JSX.Element => {
- {/* ── URL PREVIEW ── */}
- {previewUrl &&
setPreviewUrl(null)} />}
+ {/* ── URL PREVIEW ── */ }
+ { previewUrl && setPreviewUrl(null)} /> }
- {/* ── TOAST ── */}
- {toast && (
-
- {toast.msg}
-
- )}
-
+ {/* ── TOAST ── */ }
+ {
+ toast && (
+
+ {toast.msg}
+
+ )
+ }
+
);
};
-export default Dashboard;
\ No newline at end of file
+ export default Dashboard;
\ No newline at end of file
diff --git a/ndf/src/pages/NouvelleNote.tsx b/ndf/src/pages/NouvelleNote.tsx
index 4a41de6..2950edb 100644
--- a/ndf/src/pages/NouvelleNote.tsx
+++ b/ndf/src/pages/NouvelleNote.tsx
@@ -61,6 +61,7 @@ interface NouvelleNoteProps {
commentaireInitial?: string;
depensesInitiales?: any[];
onNavigateToProfil?: () => void;
+ onBrouillonChange?: (id: number) => void;
}
// ── CONSTANTES ────────────────────────────────────────
@@ -89,7 +90,7 @@ const TVA_EXCEL_COLS = [
];
// Catégories utilisant le tableau Excel TVA
-const CATS_EXCEL_TVA = ["repas", "hebergement", "transport"];
+const CATS_EXCEL_TVA = ["repas", "hebergement", "transport", "autre"];
const CAT_ICONS: Record = {
"Deplacement kilometrique": "🚗",
@@ -240,7 +241,7 @@ interface ProfilVehiculeData {
function newDepense(defaultChevaux = 7): Depense {
return {
id: Math.random(), categorie: "", date: "", libelle: "", description: "",
- km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "20", montantTTC: "" }],
+ km: "", chevaux: defaultChevaux, tvaItems: [{ taux: "", montantTTC: "" }],
nombreParticipants: "", participants: [{ nom: "", prenom: "", societe: "" }],
files: [], filesMeta: [], qrLink: null, qrUploaded: false, qrNoteRef: "", qrFiles: [],
nuits: "",
@@ -248,13 +249,35 @@ function newDepense(defaultChevaux = 7): Depense {
}
function serializeDepenses(depenses: Depense[]) {
- return depenses.map(d => ({
- ...d,
- files: [],
- filesMeta: d.filesMeta ?? [],
- qrFiles: d.qrFiles ?? [],
- tvaItems: d.tvaItems.map(tvaItemToBackend),
- }));
+ return depenses.map(d => {
+ const reactFiles = d.files ?? [];
+ const storedFiles = getStoredFiles(d.id);
+ const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
+
+ // Récupérer les metas déjà calculées pour les fichiers en mémoire
+ const inMemoryMetas = (d.filesMeta ?? []).filter(
+ m => allFiles.some(f => f.name === m.name && f.size === m.size)
+ );
+ // Pour les nouveaux fichiers sans meta encore
+ const missingMetas: FileMeta[] = allFiles
+ .filter(f => !inMemoryMetas.some(m => m.name === f.name && m.size === f.size))
+ .map(f => ({ name: f.name, type: f.type, size: f.size }));
+
+ const freshMetas = [...inMemoryMetas, ...missingMetas];
+
+ // Conserver les metas de fichiers déjà uploadés (QR, session précédente)
+ const existingNonMemory = (d.filesMeta ?? []).filter(
+ m => !allFiles.some(f => f.name === m.name && f.size === m.size)
+ );
+
+ return {
+ ...d,
+ files: [],
+ filesMeta: [...freshMetas, ...existingNonMemory],
+ qrFiles: d.qrFiles ?? [],
+ tvaItems: d.tvaItems.map(tvaItemToBackend),
+ };
+ });
}
// ── FILES STORE ───────────────────────────────────────
@@ -682,6 +705,32 @@ select.nn-input {
@media(max-width: 900px) { .nn-layout { flex-direction: column; } .nn-side { width: 100%; position: static; } }
@media(max-width: 600px) { .nn-meta { grid-template-columns: 1fr 1fr; } .nn-tva-row { grid-template-columns: 22px 22px 110px 1fr 70px 60px; } }
@media(max-width: 480px) { .nn-meta { grid-template-columns: 1fr; } .nn-row.g3 { grid-template-columns: 1fr 1fr; } }
+.nn-tva-alert {
+ background: rgba(245,158,11,.08);
+ border: 2px solid rgba(245,158,11,.5);
+ border-radius: 9px;
+ padding: 10px;
+ animation: nn-pulse-border 2s ease-in-out infinite;
+ }
+ @keyframes nn-pulse-border {
+ 0%, 100% { border-color: rgba(245,158,11,.5); }
+ 50% { border-color: rgba(245,158,11,1); }
+ }
+ .nn-tva-alert-banner {
+ display: flex; align-items: center; gap: 8px;
+ background: rgba(245,158,11,.15);
+ border: 1.5px solid rgba(245,158,11,.6);
+ border-radius: 7px; padding: 8px 12px;
+ font-size: 12px; font-weight: 700; color: #92400e;
+ margin-bottom: 10px;
+ }
+ .nn-tva-select-highlight {
+ border: 2px solid #f59e0b !important;
+ background: rgba(245,158,11,.07) !important;
+ font-weight: 700 !important;
+ color: #92400e !important;
+ box-shadow: 0 0 0 3px rgba(245,158,11,.2) !important;
+ }
`;
// ── VIEWER MODAL ──────────────────────────────────────
@@ -1114,6 +1163,7 @@ const DepenseCard = React.memo(({
onDelete: (id: number) => void;
onGenerateQR?: (id: number) => void;
onNavigateToProfil?: () => void;
+
disabled?: boolean;
apiBaseUrl: string;
profilVehicule?: ProfilVehiculeData | null;
@@ -1252,7 +1302,7 @@ const DepenseCard = React.memo(({
🍽️
- Catégorie Repas sélectionnée — plafond 25 € / personne (sauf repas événementiel).
+ Catégorie Repas sélectionnée — plafond 25 € / personne .
Renseignez le nombre de convives en bas pour contrôler le seuil en temps réel.
@@ -1262,15 +1312,19 @@ const DepenseCard = React.memo(({
Catégorie *
{
- set("categorie", e.target.value);
- set("km", "");
- set("tvaItems", [{ taux: "20", montantTTC: "" }]);
- if (!e.target.value.toLowerCase().includes("repas")) {
- set("nombreParticipants", "");
- set("participants", [{ nom: "", prenom: "", societe: "" }]);
- }
- }}>
+ onChange={e => {
+ set("categorie", e.target.value);
+ set("km", "");
+ set("tvaItems", [{ taux: "20", montantTTC: "" }]);
+ // ✅ Ajouter ceci :
+ if (e.target.value.toLowerCase().includes("kilom") && profilVehicule?.chevaux) {
+ set("chevaux", profilVehicule.chevaux);
+ }
+ if (!e.target.value.toLowerCase().includes("repas")) {
+ set("nombreParticipants", "");
+ set("participants", [{ nom: "", prenom: "", societe: "" }]);
+ }
+ }}>
— Choisir —
{CATEGORIES_DEFAULT.map(c => {CAT_ICONS[c]} {c} )}
@@ -1371,7 +1425,7 @@ const DepenseCard = React.memo(({
)}
{/* ── TVA EXCEL ── */}
- {!isKm && isExcel && (
+ {!isKm && isExcel && depense.categorie && (
set("tvaItems", items)}
@@ -1379,7 +1433,8 @@ const DepenseCard = React.memo(({
)}
{/* ── NUITS (Hébergement) ── */}
- {depense.categorie.toLowerCase().includes("hebergement") && (
+ {depense.categorie.toLowerCase().includes("hebergement") && depense.categorie && (
+
🌙 Nombre de nuits
@@ -1416,46 +1471,94 @@ const DepenseCard = React.memo(({
)}
{/* ── TVA CLASSIQUE ── */}
- {!isKm && !isExcel && (
-
+ {!isKm && !isExcel && depense.categorie && (
+
!it.taux || it.taux === "") ? " nn-tva-alert" : ""}`}>
Montants & TVA
Saisir le montant TTC — HT calculé automatiquement
+
+ {/* ── Bandeau alerte si taux non confirmé ── */}
+ {tvaItems.some(it => !it.taux || it.taux === "") && (
+
+ ⚠️
+ Vérifiez le taux de TVA avant de saisir le montant — il impacte directement le montant HT remboursé.
+
+ )}
+
{["Taux TVA", "Montant TTC", "HT calculé", "TVA"].map((h, i) => (
-
{h}
+
+ {i === 0 ? "⚠ " + h + " *" : h}
+
))}
+
{tvaItems.map((item, idx) => {
const ttc = parseFloat(item.montantTTC) || 0;
const taux = parseFloat(item.taux) || 0;
const ht = ttcToHt(ttc, taux);
const tva = ttcToTva(ttc, taux);
+ const tauxManquant = !item.taux || item.taux === "";
return (
-
addTvaAfter(idx)} title="Ajouter une ligne après">+
-
removeTva(idx)} title="Supprimer cette ligne">−
-
updTva(idx, "taux", e.target.value)}>
- — Taux —
- {TAUX_TVA.map((t, i) => (
- {t.libelle}
- ))}
-
+
addTvaAfter(idx)} title="Ajouter une ligne après">+
+
removeTva(idx)} title="Supprimer cette ligne">−
+
+ {/* ── Select TVA mis en évidence si non renseigné ── */}
+
+ updTva(idx, "taux", e.target.value)}>
+ ⚠ Choisir un taux *
+ {TAUX_TVA.map((t, i) => (
+ {t.libelle}
+ ))}
+
+ {tauxManquant && (
+ ⚠️
+ )}
+
+
updTva(idx, "montantTTC", e.target.value)}
placeholder="0,00" />
- €
+ €
+
+
+
+ {ttc > 0 ? fmt(ht) : "—"}
+
+
+ {tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}
-
{ttc > 0 ? fmt(ht) : "—"}
-
{tva > 0 ? fmt(tva) : taux === 0 && ttc > 0 ? "0 €" : "—"}
);
})}
@@ -1486,7 +1589,7 @@ const DepenseCard = React.memo(({
ℹ️
- Un repas professionnel ne doit pas dépasser 25 € par personne (sauf repas événementiel).
+ Un repas professionnel ne doit pas dépasser 25 € par personne .
@@ -1728,7 +1831,7 @@ export default function NouvelleNote({
initialBrouillonId = null,
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
depensesInitiales,
- onNavigateToProfil,
+ onNavigateToProfil, onBrouillonChange,
}: NouvelleNoteProps) {
const initDepenses = (defaultCv = 7): Depense[] => {
@@ -1756,6 +1859,7 @@ export default function NouvelleNote({
};
const [titre, setTitre] = useState(libelleInitial || "");
+ const [wasOnceActive, setWasOnceActive] = useState(false);
const [dateDebut, setDateDebut] = useState(dateDebutInitiale || "");
const [dateFin, setDateFin] = useState("");
const [commentaire, setComment] = useState(commentaireInitial || "");
@@ -1780,6 +1884,22 @@ export default function NouvelleNote({
const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef
(initialBrouillonId);
+ const isSavingRef = useRef(false);
+ const needsResaveRef = useRef(false);
+ const latestStateRef = useRef({ titre, dateDebut, dateFin, commentaire, depenses });
+ const hasLoadedInitial = useRef(false);
+ const mountInitialIdRef = useRef(initialBrouillonId);
+ const persistRef = useRef<() => void>(() => { });
+
+ // snapshot de l'état toujours à jour pour le flush et la sauvegarde
+ useEffect(() => {
+ latestStateRef.current = { titre, dateDebut, dateFin, commentaire, depenses };
+ });
+
+ useEffect(() => {
+ if (activeBrouillonId !== null) setWasOnceActive(true);
+ }, [activeBrouillonId]);
+
useEffect(() => {
if (!authToken) return;
fetch(`${apiBaseUrl}/api/profil/vehicule`, {
@@ -1810,8 +1930,10 @@ export default function NouvelleNote({
setDepenses(p => p.map(d => d.id === id ? { ...d, [f]: v } : d)), []);
const handleDeleteDepense = useCallback((id: number) => { filesStore.delete(id); setDepenses(p => p.filter(d => d.id !== id)); }, []);
const handleAddDepense = useCallback(() => {
- const d = newDepense(); setDepenses(p => [...p, d]); setExpandedId(d.id);
- }, []);
+ const d = newDepense(profilVehicule?.chevaux ?? 7);
+ setDepenses(p => [...p, d]);
+ setExpandedId(d.id);
+ }, [profilVehicule]);
const generateQRForDepense = useCallback(async (depenseId: number) => {
const tempRef = `NDF-DEP-${depenseId}-${Date.now()}`;
@@ -1903,61 +2025,135 @@ export default function NouvelleNote({
}, [apiBaseUrl, getHeaders]);
useEffect(() => { fetchBrouillons(); }, [fetchBrouillons]);
- useEffect(() => {
- if (depensesInitiales && depensesInitiales.length > 0) return;
- if (!initialBrouillonId || !brouillons.length) return;
- const b = brouillons.find(b => b.id === initialBrouillonId);
- if (b) loadBrouillon(b);
- }, [initialBrouillonId, brouillons]);
+ useEffect(() => {
+ if (depensesInitiales && depensesInitiales.length > 0) return;
+ if (hasLoadedInitial.current) return; // déjà chargé
+ if (!mountInitialIdRef.current || !brouillons.length) return;
+ const b = brouillons.find(x => x.id === mountInitialIdRef.current);
+ if (b) loadBrouillon(b);
+ hasLoadedInitial.current = true;
+ }, [brouillons]); // ← plus de dépendance sur initialBrouillonId
- const scheduleSave = useCallback((state: any) => {
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
- setSaveStatus("saving");
- saveTimerRef.current = setTimeout(async () => {
- const currentId = activeBrouillonIdRef.current;
- const fd = new FormData();
- fd.append("libelle", state.titre || "Sans titre");
- fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
- fd.append("description", state.commentaire || "");
- fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
- for (const dep of state.depenses) {
- for (const file of getStoredFiles(dep.id)) fd.append(`files_${dep.id}`, file);
- }
- const headers = { Authorization: `Bearer ${authToken}` };
- try {
- if (currentId) {
- const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, { method: "PUT", headers, body: fd });
- if (res.ok) {
- const data = await res.json();
- if (data.uploadedFiles) {
- setDepenses(prev => prev.map(d => {
- const uploaded = data.uploadedFiles[d.id];
- if (!uploaded?.length) return d;
- const newQrFiles = [
- ...(d.qrFiles ?? []),
- ...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const }))
- ].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
- return { ...d, qrFiles: newQrFiles };
- }));
- }
- }
- } else {
- const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, { method: "POST", headers, body: fd });
- const created = await res.json();
- activeBrouillonIdRef.current = created.id;
- setActiveBrouillonId(created.id);
- }
- await fetchBrouillons();
- setSaveStatus("saved");
- state.depenses.forEach((d: Depense) => setStoredFiles(d.id, []));
- } catch { setSaveStatus("error"); }
- }, 1500);
- }, [apiBaseUrl, authToken, fetchBrouillons]);
+ // ── persistBrouillon — envoi correct des fichiers ──
+ const persistBrouillon = useCallback(async () => {
+ if (isSavingRef.current) {
+ needsResaveRef.current = true;
+ return;
+ }
+ isSavingRef.current = true;
+ setSaveStatus("saving");
+ try {
+ const state = latestStateRef.current;
+ const currentId = activeBrouillonIdRef.current;
+ const fd = new FormData();
+ fd.append("libelle", state.titre || "Sans titre");
+ fd.append("date", state.dateDebut || new Date().toISOString().split("T")[0]);
+ fd.append("description", state.commentaire || "");
+ fd.append("lignes", JSON.stringify(serializeDepenses(state.depenses)));
+
+ // ✅ FIX : lire depuis state.depenses.files ET filesStore
+ for (const dep of state.depenses) {
+ // Fichiers en mémoire React (depense.files)
+ const reactFiles = dep.files ?? [];
+ // Fichiers dans le store séparé
+ const storedFiles = getStoredFiles(dep.id);
+
+ // Fusionner sans doublons (filesStore peut contenir des copies de dep.files)
+ const allFiles = storedFiles.length > 0 ? storedFiles : reactFiles;
+
+ for (const file of allFiles) {
+ fd.append(`files_${dep.id}`, file);
+ }
+ }
+
+ const headers = { Authorization: `Bearer ${authToken}` };
- useEffect(() => {
- if (isFirstRender.current) { isFirstRender.current = false; return; }
- scheduleSave({ titre, dateDebut, dateFin, commentaire, depenses });
- }, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
+ if (currentId) {
+ const res = await fetch(`${apiBaseUrl}/api/notes/brouillons/${currentId}`, {
+ method: "PUT", headers, body: fd
+ });
+ if (res.ok) {
+ const data = await res.json();
+ if (data.uploadedFiles) {
+ setDepenses(prev => prev.map(d => {
+ const uploaded = data.uploadedFiles[d.id];
+ if (!uploaded?.length) return d;
+ const merged = [
+ ...(d.qrFiles ?? []),
+ ...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
+ ].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
+ setStoredFiles(d.id, []);
+ // ✅ Garder files en état React — seulement vider le store
+ // Les qrFiles contiennent déjà l'URL SharePoint pour le re-submit
+ return { ...d, qrFiles: merged };
+ }));
+ }
+ // ✅ FIX : si pas d'uploadedFiles dans la réponse, vider quand même le store
+ // car le serveur a bien reçu les fichiers
+
+ }
+ } else {
+ const res = await fetch(`${apiBaseUrl}/api/notes/brouillons`, {
+ method: "POST", headers, body: fd
+ });
+ const created = await res.json();
+ activeBrouillonIdRef.current = created.id;
+ setActiveBrouillonId(created.id);
+ onBrouillonChange?.(created.id);
+
+ // ✅ FIX : vider le store après création réussie
+ if (created.uploadedFiles) {
+ setDepenses(prev => prev.map(d => {
+ const uploaded = created.uploadedFiles[d.id];
+ if (!uploaded?.length) return d;
+ const merged = [
+ ...(d.qrFiles ?? []),
+ ...uploaded.map((f: any) => ({ ...f, origin: 'upload' as const })),
+ ].filter((f, i, arr) => arr.findIndex(x => x.uploadUrl === f.uploadUrl) === i);
+ setStoredFiles(d.id, []);
+ return { ...d, files: [], qrFiles: merged };
+ }));
+ }
+ }
+
+ await fetchBrouillons();
+ setSaveStatus("saved");
+
+ } catch {
+ setSaveStatus("error");
+ } finally {
+ isSavingRef.current = false;
+ if (needsResaveRef.current) {
+ needsResaveRef.current = false;
+ persistBrouillon();
+ }
+ }
+ }, [apiBaseUrl, authToken, fetchBrouillons, onBrouillonChange]);
+
+ const scheduleSave = useCallback(() => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ setSaveStatus("saving");
+ saveTimerRef.current = setTimeout(() => {
+ saveTimerRef.current = null; // marque comme exécuté
+ persistBrouillon();
+ }, 1500);
+ }, [persistBrouillon]);
+
+ useEffect(() => {
+ if (isFirstRender.current) { isFirstRender.current = false; return; }
+ scheduleSave(); // plus besoin de passer l'état
+ }, [titre, dateDebut, dateFin, commentaire, depenses, scheduleSave]);
+
+ useEffect(() => { persistRef.current = persistBrouillon; });
+
+ useEffect(() => {
+ return () => {
+ if (saveTimerRef.current) {
+ clearTimeout(saveTimerRef.current);
+ persistRef.current();
+ }
+ };
+ }, []);
const loadBrouillon = (b: BrouillonServeur) => {
setTitre(b.libelle || ""); setComment(b.description || "");
@@ -2045,12 +2241,19 @@ export default function NouvelleNote({
}
setSubmitting(true);
try {
- const depensesBackend = depenses.map(d => ({
- ...d,
- files: getStoredFiles(d.id).length > 0 ? getStoredFiles(d.id) : d.files,
- tvaItems: d.tvaItems.map(tvaItemToBackend),
- qrFiles: d.qrFiles ?? [],
- }));
+ const depensesBackend = depenses.map(d => {
+ // Fusionner : store (fichiers pas encore uploadés) + d.files (fichiers React)
+ const storedFiles = getStoredFiles(d.id);
+ const allFiles = storedFiles.length > 0
+ ? [...d.files, ...storedFiles.filter(sf => !d.files.some(rf => rf.name === sf.name && rf.size === sf.size))]
+ : d.files;
+ return {
+ ...d,
+ files: allFiles,
+ tvaItems: d.tvaItems.map(tvaItemToBackend),
+ qrFiles: d.qrFiles ?? [],
+ };
+});
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend);
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); }
};
@@ -2103,7 +2306,7 @@ export default function NouvelleNote({
)}
- {libelleInitial && !activeBrouillon && (
+ {libelleInitial && !activeBrouillon && !wasOnceActive && (
✏️
diff --git a/ndf/src/pages/VerificateurFinanceLight.tsx b/ndf/src/pages/VerificateurFinanceLight.tsx
index 2ad84be..8d8aa38 100644
--- a/ndf/src/pages/VerificateurFinanceLight.tsx
+++ b/ndf/src/pages/VerificateurFinanceLight.tsx
@@ -36,10 +36,20 @@ interface LigneState { status: 'ok' | 'refused' | 'pending'; motif?: string; }
const fmt = (n: number) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n);
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('fr-FR') : '—';
-const getIndemniteKm = (km: number, cv: number) => {
- const BAREME: Record
= { 3: 0.529, 4: 0.606, 5: 0.636, 6: 0.665, 7: 0.697 };
- return km * (BAREME[Math.min(Math.max(cv, 3), 7)] ?? 0.697);
-};
+const getIndemniteKm = (km: number, cv: number): number => {
+ const BAREME: Record = {
+ 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 },
+ };
+ const b = BAREME[Math.min(Math.max(cv, 3), 7)];
+ if (km <= 0) return 0;
+ if (km <= 5000) return parseFloat((km * b.t1).toFixed(2));
+ if (km <= 20000) return parseFloat((km * b.t2_a + b.t2_b).toFixed(2));
+ return parseFloat((km * b.t3).toFixed(2));
+ };
const ligneKey = (noteId: number, idx: number) => `${noteId}-l${idx}`;
const SYSTEME_KEYWORDS = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
const isSystemFile = (f: Fichier) => SYSTEME_KEYWORDS.some(kw => (f.fileName ?? '').toLowerCase().includes(kw));
@@ -690,7 +700,7 @@ export default function VerificateurFinance4Panels({
{[note.collaborateur, note.campus, note.departement, fmtDate(note.date)].filter(Boolean).join(' · ')}
-
0 ? 'adj' : ''}`} style={{ color: nbModifs > 0 ? '#15803d' : undefined }}>{nbModifs > 0 ? fmt(totalAjuste) : fmt(note.montant || 0)}
+ {nbModifs > 0 &&
{fmt(note.montant || 0)}
}
{nbModifs > 0 &&
{fmt(note.montant || 0)}
}
{lignesData.length} ligne{lignesData.length > 1 ? 's' : ''}{nbModifs > 0 ? ` · ${nbModifs} ajust.` : ''}
@@ -730,7 +740,7 @@ export default function VerificateurFinance4Panels({
{note.libelle}
Total demandé
-
0 ? 'adj' : ''}`}>{fmt(note.montant || 0)}
+
0 ? 'adj' : ''}`}>{fmt(totalAjuste)}
{nbModifs > 0 &&
→ {fmt(totalAjuste)} après ajust.
}
@@ -882,7 +892,7 @@ export default function VerificateurFinance4Panels({
Total retenu
- 0 ? '#15803d' : '#111827' }}>{fmt(nbModifs > 0 ? totalAjuste : (note.montant || 0))}
+ {fmt(totalAjuste)}
{canValidate &&
Toutes conformes{nbModifs > 0 ? ` (${nbModifs} ajust.)` : ''}
}
@@ -937,7 +947,20 @@ export default function VerificateurFinance4Panels({
{[h.collaborateur, h.campus, h.departement].filter(Boolean).join(' · ')}
-
{fmt(h.montant || 0)}
+
+ {fmt((() => {
+ try {
+ const lignes = JSON.parse(h.lignesJson || '[]');
+ if (!lignes.length) return h.montant || 0;
+ return lignes.reduce((sum: number, l: any) => {
+ const isKm = (l.categorie || '').toLowerCase().includes('kilom');
+ const km = parseFloat(l.km || '0') || 0;
+ const cv = parseInt(l.chevaux || '7') || 7;
+ return sum + (isKm ? getIndemniteKm(km, cv) : (parseFloat(l.montant || '0') || 0));
+ }, 0);
+ } catch { return h.montant || 0; }
+ })())}
+
{new Date(h.dateVerification).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}