diff --git a/ndf/public/backend/server.js b/ndf/public/backend/server.js index 99b1637..2049050 100644 --- a/ndf/public/backend/server.js +++ b/ndf/public/backend/server.js @@ -5237,8 +5237,10 @@ app.post('/api/profil/documents/:type', authenticateToken, upload.single('file') `); // Notifier les Finance du même campus - const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' }; - const campusNorm = normalizeCampus(campus); + // Notifier les Finance du même campus + const typeLabels = { rib: 'RIB', cartegrise: 'carte grise', permis: 'permis de conduire' }; + const typeLabelsAvecArticle = { rib: 'son RIB', cartegrise: 'sa carte grise', permis: 'son permis de conduire' }; + const campusNorm = normalizeCampus(campus); const financeResult = await pool.request() .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') .query(` @@ -5249,29 +5251,29 @@ WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1 AND c.campus LIKE @campus AND c.Actif = 1 `); - const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; - for (const finance of financeResult.recordset) { - try { - await creerNotification({ - destinataireId: finance.id, - destinataireEmail: finance.email, - type: 'validationdoc', - titre: `Document à valider — ${prenom} ${nom}`, - message: `${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} pour validation.`, - noteId: null - }); - } catch (e) { console.error('Notif BDD Finance doc', e.message); } - try { - await sendMailGraph( - finance.email, - `Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`, - `
+ const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; + for (const finance of financeResult.recordset) { + try { + await creerNotification({ + destinataireId: finance.id, + destinataireEmail: finance.email, + type: 'validationdoc', + titre: `Document à valider — ${prenom} ${nom}`, + message: `${prenom} ${nom} (${campus}) a soumis ${typeLabelsAvecArticle[type]} pour validation.`, + noteId: null + }); + } catch (e) { console.error('Notif BDD Finance doc', e.message); } + try { + await sendMailGraph( + finance.email, + `Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`, + `

Document à valider

Bonjour ${finance.prenom} ${finance.nom},

-

${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} en attente de votre validation.

+

${prenom} ${nom} (${campus}) a soumis ${typeLabelsAvecArticle[type]} en attente de votre validation.

Valider les documents @@ -5308,6 +5310,39 @@ app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => }); + // GET /api/finance/documents-historique — Finance voit l'historique des docs traités (validés/refusés) + app.get('/api/finance/documents-historique', authenticateToken, async (req, res) => { + if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) + return res.status(403).json({ error: 'Accès réservé Finance' }); + try { + const request = pool.request(); + let campusFilter = ''; + if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { + const campusCode = normalizeCampus(req.user.campus); + if (campusCode) { + request.input('campus', sql.NVarChar, `%${campusCode}%`); + campusFilter = 'AND c.campus LIKE @campus'; + } + } + const result = await request.query(` + SELECT d.id, d.collaborateurId, d.type, d.fileName, d.sharepointUrl, + d.dateUpload, d.DateModification, d.statut, d.commentaire, + d.dateValidation, d.validePar, + c.prenom, c.nom, c.email, c.campus, c.departement, + v.prenom + ' ' + v.nom AS validateurNom + FROM DocumentsCollaborateur d + JOIN CollaborateurAD c ON c.id = d.collaborateurId + LEFT JOIN CollaborateurAD v ON v.id = d.validePar + WHERE d.statut IN ('valide', 'refuse') ${campusFilter} + ORDER BY d.dateValidation DESC, d.DateModification DESC + `); + res.json(result.recordset); + } catch (error) { + console.error('GET /api/finance/documents-historique:', error.message); + res.status(500).json({ error: error.message }); + } + }); + // GET /api/finance/documents-a-valider — Finance voit les docs en_attente de son campus app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) diff --git a/ndf/src/pages/Dashboard.tsx b/ndf/src/pages/Dashboard.tsx index dd8a715..c535be9 100644 --- a/ndf/src/pages/Dashboard.tsx +++ b/ndf/src/pages/Dashboard.tsx @@ -1742,6 +1742,15 @@ const Dashboard = (): JSX.Element => { }); const [ancienneNoteComparaison, setAncienneNoteComparaison] = useState(null); + const [showProfilIncompletModal, setShowProfilIncompletModal] = useState(false); + const [profilCompletude, setProfilCompletude] = useState<{ + adresseOk: boolean; + ibanOk: boolean; + bicOk: boolean; + } | null>(null); +const [profilCheckDone, setProfilCheckDone] = useState(false); + + const [vehicule, setVehicule] = useState<{ chevaux: number; marque: string; @@ -1790,6 +1799,9 @@ const Dashboard = (): JSX.Element => { const [profilDocsUploading, setProfilDocsUploading] = useState(null); const [docsAValider, setDocsAValider] = useState([]); const [docsAValiderLoading, setDocsAValiderLoading] = useState(false); + const [docsHistorique, setDocsHistorique] = useState([]); + const [docsHistoriqueLoading, setDocsHistoriqueLoading] = useState(false); + const [vueDocsValider, setVueDocsValider] = useState<'avalider' | 'historique'>('avalider'); // ── Etats nouveaux rôles ──────────────────────────── const [notesAVerifier, setNotesAVerifier] = useState([]); @@ -1907,14 +1919,31 @@ const Dashboard = (): JSX.Element => { setTimeout(() => setToast(null), 3500); }; - + const checkProfilMinimal = async (): Promise => { + const adresseOk = !!(profile?.adresse_rue && profile?.adresse_cp && profile?.adresse_ville && profile?.adresse_pays); + let ibanOk = false, bicOk = false; + try { + const ibanCheck = await fetch(`${API}/api/profil/iban`, { headers: hdrs as HeadersInit }).then(r => r.ok ? r.json() : null); + ibanOk = !!ibanCheck?.ibanSaisi; + bicOk = !!ibanCheck?.bic; + } catch { /* ignore */ } + setProfilCompletude({ adresseOk, ibanOk, bicOk }); + return adresseOk && ibanOk && bicOk; + }; // ── Navigation avec historique navigateur ──────────── - const nav = (s: string) => { +const nav =(s: string) => { + if (s === 'nouvelle') { + checkProfilMinimal().then(complet => { + if (!complet) { setShowProfilIncompletModal(true); return; } + window.history.pushState({ section: s }, '', `#${s}`); + setSection(s); + }); + return; + } window.history.pushState({ section: s }, '', `#${s}`); setSection(s); }; - useEffect(() => { const hash = window.location.hash.replace('#', ''); if (hash && sectionTitles[hash]) { @@ -2084,7 +2113,13 @@ const Dashboard = (): JSX.Element => { fetch(`${API}/api/parametres/tva`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setTauxTVADisponibles(d)).catch(() => { }); fetch(`${API}/api/profil/documents`, { headers: hdrs }).then(r => r.ok ? r.json() : { rib: null, carte_grise: null, permis: null }).then(d => setProfilDocs(d)).catch(() => { }); }, []); - + useEffect(() => { + if (!profile || profilCheckDone) return; + checkProfilMinimal().then(complet => { + setProfilCheckDone(true); + if (!complet) setShowProfilIncompletModal(true); + }); + }, [profile, profilCheckDone]); useEffect(() => { if ((section === 'mesnotes' || section === 'accueil') && !notesLoaded) { fetch(`${API}/api/notes`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => { if (Array.isArray(d)) { setNotes(d); setNotesLoaded(true); } }).catch(() => { }); @@ -2142,10 +2177,12 @@ const Dashboard = (): JSX.Element => { .catch(() => { }); } - if (section === 'docsavalider' && (isFinance || isVerificateurFinance)) { - setDocsAValiderLoading(true); + if (section === 'docsavalider' && (isFinance || isVerificateurFinance)) { + setDocsAValiderLoading(true); fetch(`${API}/api/finance/documents-a-valider`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setDocsAValider(d)).catch(() => { }).finally(() => setDocsAValiderLoading(false)); - } + setDocsHistoriqueLoading(true); + fetch(`${API}/api/finance/documents-historique`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setDocsHistorique(d)).catch(() => { }).finally(() => setDocsHistoriqueLoading(false)); + } if (section === 'historiquepaiements' && (isRHAdmin || isValidateurFinance)) { const params = new URLSearchParams(); if (paiementFiltreMois) { @@ -4622,7 +4659,7 @@ const Dashboard = (): JSX.Element => { const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Erreur'); showToast( - `✅ ${data.nbNotes} note(s) soumises au Président pour validation`, + `✅ ${data.nbNotes} note(s) envoyés au Président pour validation`, 'success' ); setSelectedNoteIds([]); @@ -5113,51 +5150,120 @@ const Dashboard = (): JSX.Element => { {/* ════════════════════════════════════════ */} {/* DOCUMENTS À VALIDER (Finance) */} {/* ════════════════════════════════════════ */} - {section === 'docsavalider' && (isFinance || isVerificateurFinance) && ( + {section === 'docsavalider' && (isFinance || isVerificateurFinance) && (
-
- Documents à valider - {docsAValider.length > 0 && {docsAValider.length}} +
+
+ Documents + {vueDocsValider === 'avalider' && docsAValider.length > 0 && ( + {docsAValider.length} + )} +
+
+ + +
- {docsAValiderLoading ? ( -
⏳ Chargement...
- ) : docsAValider.length === 0 ? ( -
✅ Aucun document en attente de validation
- ) : ( -
- {docsAValider.map((doc: any) => { - const tl: Record = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' }; - return ( -
-
-
-
{doc.prenom} {doc.nom}
-
📍 {doc.campus} · {doc.departement}
-
- {tl[doc.type] || doc.type} - Soumis le {new Date(doc.DateModification).toLocaleDateString('fr-FR')} + + {vueDocsValider === 'avalider' ? ( + docsAValiderLoading ? ( +
⏳ Chargement...
+ ) : docsAValider.length === 0 ? ( +
✅ Aucun document en attente de validation
+ ) : ( +
+ {docsAValider.map((doc: any) => { + const tl: Record = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' }; + return ( +
+
+
+
{doc.prenom} {doc.nom}
+
📍 {doc.campus} · {doc.departement}
+
+ {tl[doc.type] || doc.type} + Envoyé le {new Date(doc.DateModification).toLocaleDateString('fr-FR')} +
+
+
+ + +
-
- - - +
+ ); + })} +
+ ) + ) : ( + docsHistoriqueLoading ? ( +
⏳ Chargement...
+ ) : docsHistorique.length === 0 ? ( +
📭 Aucun document traité pour le moment
+ ) : ( +
+ {docsHistorique.map((doc: any) => { + const tl: Record = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' }; + const isValide = doc.statut === 'valide'; + return ( +
+
+
+
+
{doc.prenom} {doc.nom}
+ {isValide ? '✅ Validé' : '❌ Refusé'} +
+
📍 {doc.campus} · {doc.departement}
+
+ {tl[doc.type] || doc.type} + + {isValide ? 'Validé' : 'Refusé'} le {doc.dateValidation ? new Date(doc.dateValidation).toLocaleDateString('fr-FR') : '—'} + {doc.validateurNom && ` par ${doc.validateurNom}`} + +
+ {!isValide && doc.commentaire && ( +
+ Motif : {doc.commentaire} +
+ )} +
+
-
- ); - })} -
+ ); + })} +
+ ) )}
)} @@ -6630,6 +6736,73 @@ const Dashboard = (): JSX.Element => {
)} + + {showProfilIncompletModal && profilCompletude && ( +
+
+
+
⚠️
+
+
Profil incomplet
+
+ Complétez les informations ci-dessous avant de créer une note. +
+
+
+ +
+ {[ + { ok: profilCompletude.adresseOk, label: 'Adresse postale complète' }, + { ok: profilCompletude.ibanOk, label: 'IBAN renseigné' }, + { ok: profilCompletude.bicOk, label: 'BIC renseigné' }, + ].map(({ ok, label }) => ( +
+ {ok ? '✅' : '❌'} + {label} +
+ ))} +
+ +
+ 💡 Pour les frais kilométriques, vous devrez aussi renseigner votre véhicule et faire valider votre carte grise / permis dans votre profil avant soumission. +
+ +
+ + +
+
+
+ )} diff --git a/ndf/src/pages/NouvelleNote.tsx b/ndf/src/pages/NouvelleNote.tsx index 2950edb..b2be00f 100644 --- a/ndf/src/pages/NouvelleNote.tsx +++ b/ndf/src/pages/NouvelleNote.tsx @@ -1883,6 +1883,8 @@ export default function NouvelleNote({ const saveTimerRef = useRef | null>(null); const isFirstRender = useRef(true); const activeBrouillonIdRef = useRef(initialBrouillonId); + const errorBannerRef = useRef(null); + const depenseRefs = useRef>({}); const isSavingRef = useRef(false); const needsResaveRef = useRef(false); @@ -2197,66 +2199,93 @@ export default function NouvelleNote({ fetchBrouillons(); } catch { } }; - - const handleSubmit = async () => { - setSubmitError(""); - if (submitting) return; - if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; } - if (dateDebut && isDateFutureMonth(dateDebut)) { - setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés."); - return; - } - for (const d of depenses) { - if (!d.date || !d.libelle.trim()) { - setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`); - setExpandedId(d.id); return; - } - // ✅ Bloquer les dates dans un mois futur - if (isDateFutureMonth(d.date)) { - setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`); - setExpandedId(d.id); return; - } - const isKmLine = d.categorie.toLowerCase().includes("kilom"); - if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) { - setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`); - setExpandedId(d.id); return; - } - if (!isKmLine) { - const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0; - if (!hasFiles) { - setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`); - setExpandedId(d.id); return; - } - } - if (d.categorie.toLowerCase().includes("repas")) { - for (let pi = 0; pi < d.participants.length; pi++) { - const p = d.participants[pi]; - if (!p.nom?.trim() || !p.prenom?.trim()) { - setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`); - setExpandedId(d.id); return; - } - } - // Pas de blocage sur le dépassement 25€ — c'est un warning uniquement - } - } - setSubmitting(true); - try { - 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); } - }; + const scrollToError = (depenseId?: number) => { + requestAnimationFrame(() => { + const target = depenseId ? depenseRefs.current[depenseId] : errorBannerRef.current; + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + } else { + errorBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }); + }; + const handleSubmit = async () => { + setSubmitError(""); + if (submitting) return; + if (!titre.trim()) { + setSubmitError("Veuillez saisir un titre."); + scrollToError(); + return; + } + if (dateDebut && isDateFutureMonth(dateDebut)) { + setSubmitError("La date de la note ne peut pas être dans un mois futur. Vous ne pouvez créer des notes que pour le mois en cours ou des mois passés."); + scrollToError(); + return; + } + for (const d of depenses) { + if (!d.date || !d.libelle.trim()) { + setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`); + setExpandedId(d.id); + scrollToError(d.id); + return; + } + // ✅ Bloquer les dates dans un mois futur + if (isDateFutureMonth(d.date)) { + setSubmitError(`"${d.libelle}" — la date ne peut pas être dans un mois futur. Vous ne pouvez soumettre des frais que pour le mois en cours ou des mois passés.`); + setExpandedId(d.id); + scrollToError(d.id); + return; + } + const isKmLine = d.categorie.toLowerCase().includes("kilom"); + if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) { + setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`); + setExpandedId(d.id); + scrollToError(d.id); + return; + } + if (!isKmLine) { + const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0; + if (!hasFiles) { + setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`); + setExpandedId(d.id); + scrollToError(d.id); + return; + } + } + if (d.categorie.toLowerCase().includes("repas")) { + for (let pi = 0; pi < d.participants.length; pi++) { + const p = d.participants[pi]; + if (!p.nom?.trim() || !p.prenom?.trim()) { + setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`); + setExpandedId(d.id); + scrollToError(d.id); + return; + } + } + // Pas de blocage sur le dépassement 25€ — c'est un warning uniquement + } + } + setSubmitting(true); + try { + const depensesBackend = depenses.map(d => { + 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); + scrollToError(); + } + }; // ✅ Calcul des totaux globaux — gestion du cas MIXED const { totalTTC, totalHT, totalTVA } = useMemo(() => { @@ -2420,7 +2449,7 @@ export default function NouvelleNote({
- {submitError &&
⚠️ {submitError}
} + {submitError &&
⚠️ {submitError}
}
@@ -2453,15 +2482,17 @@ export default function NouvelleNote({
- {depenses.map((d, i) => ( - - ))} + {depenses.map((d, i) => ( +
{ depenseRefs.current[d.id] = el; }}> + +
+ ))}