From 0329dbc93ac3b79403d7ae398869e7cdff8df2c6 Mon Sep 17 00:00:00 2001 From: Imer ouijdane Date: Mon, 15 Jun 2026 15:37:23 +0200 Subject: [PATCH] resolutionano --- ndf/public/backend/ndfPdfGenerator.js | 70 +- ndf/public/backend/server.js | 921 +++++++---- ndf/src/pages/Dashboard.tsx | 1714 ++++++++++++++------ ndf/src/pages/NouvelleNote.tsx | 405 +++-- ndf/src/pages/VerificateurFinanceLight.tsx | 39 +- 5 files changed, 2211 insertions(+), 938 deletions(-) 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}) + +
+ + + + + + + + + + ${lignesRefuseesFinal.map(l => { + const ligne = lignesData[l.ligneIndex] || {}; + const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`; + const cat = ligne.categorie || ''; + return ` + + + + + `; + }).join('')} + +
DépenseMotif
${l.ligneIndex + 1} +
${label}
+ ${cat ? `
${cat}
` : ''} +
${l.commentaire || 'Non conforme'}
+
`; + } + 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 ?
  1. Connectez-vous à la plateforme NDF
  2. -
  3. Rendez-vous dans Mes notes
  4. -
  5. Cliquez sur la note ${note.reference}
  6. -
  7. Corrigez les informations demandées
  8. +
  9. Ouvrez la note ${note.reference}
  10. +
  11. Corrigez uniquement les lignes listées ci-dessus
  12. Resoumettez la note
- - ✏️ Modifier ma note → + + ✏️ Corriger ma 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')}.

Voir mes notes
@@ -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 ( - - ); - })} -
- {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 */} +
+ +