popremplissgeprofil

This commit is contained in:
2026-06-17 09:43:04 +02:00
parent 0329dbc93a
commit 3178e3bf40
3 changed files with 376 additions and 137 deletions
+55 -20
View File
@@ -5237,8 +5237,10 @@ app.post('/api/profil/documents/:type', authenticateToken, upload.single('file')
`); `);
// Notifier les Finance du même campus // Notifier les Finance du même campus
const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' }; // Notifier les Finance du même campus
const campusNorm = normalizeCampus(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() const financeResult = await pool.request()
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%')
.query(` .query(`
@@ -5249,29 +5251,29 @@ WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1
AND c.campus LIKE @campus AND c.Actif = 1 AND c.campus LIKE @campus AND c.Actif = 1
`); `);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
for (const finance of financeResult.recordset) { for (const finance of financeResult.recordset) {
try { try {
await creerNotification({ await creerNotification({
destinataireId: finance.id, destinataireId: finance.id,
destinataireEmail: finance.email, destinataireEmail: finance.email,
type: 'validationdoc', type: 'validationdoc',
titre: `Document à valider — ${prenom} ${nom}`, titre: `Document à valider — ${prenom} ${nom}`,
message: `${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} pour validation.`, message: `${prenom} ${nom} (${campus}) a soumis ${typeLabelsAvecArticle[type]} pour validation.`,
noteId: null noteId: null
}); });
} catch (e) { console.error('Notif BDD Finance doc', e.message); } } catch (e) { console.error('Notif BDD Finance doc', e.message); }
try { try {
await sendMailGraph( await sendMailGraph(
finance.email, finance.email,
`Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`, `Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`,
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto"> `<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0"> <div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0">
<h2 style="margin:0">Document à valider</h2> <h2 style="margin:0">Document à valider</h2>
</div> </div>
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px"> <div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
<p>Bonjour <strong>${finance.prenom} ${finance.nom}</strong>,</p> <p>Bonjour <strong>${finance.prenom} ${finance.nom}</strong>,</p>
<p><strong>${prenom} ${nom}</strong> (${campus}) a soumis son <strong>${typeLabels[type]}</strong> en attente de votre validation.</p> <p><strong>${prenom} ${nom}</strong> (${campus}) a soumis ${typeLabelsAvecArticle[type]} en attente de votre validation.</p>
<div style="text-align:center;margin-top:24px"> <div style="text-align:center;margin-top:24px">
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700"> <a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">
Valider les documents 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 // 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) => { app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur'))
+220 -47
View File
@@ -1742,6 +1742,15 @@ const Dashboard = (): JSX.Element => {
}); });
const [ancienneNoteComparaison, setAncienneNoteComparaison] = useState<Note | null>(null); const [ancienneNoteComparaison, setAncienneNoteComparaison] = useState<Note | null>(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<{ const [vehicule, setVehicule] = useState<{
chevaux: number; chevaux: number;
marque: string; marque: string;
@@ -1790,6 +1799,9 @@ const Dashboard = (): JSX.Element => {
const [profilDocsUploading, setProfilDocsUploading] = useState<string | null>(null); const [profilDocsUploading, setProfilDocsUploading] = useState<string | null>(null);
const [docsAValider, setDocsAValider] = useState<any[]>([]); const [docsAValider, setDocsAValider] = useState<any[]>([]);
const [docsAValiderLoading, setDocsAValiderLoading] = useState(false); const [docsAValiderLoading, setDocsAValiderLoading] = useState(false);
const [docsHistorique, setDocsHistorique] = useState<any[]>([]);
const [docsHistoriqueLoading, setDocsHistoriqueLoading] = useState(false);
const [vueDocsValider, setVueDocsValider] = useState<'avalider' | 'historique'>('avalider');
// ── Etats nouveaux rôles ──────────────────────────── // ── Etats nouveaux rôles ────────────────────────────
const [notesAVerifier, setNotesAVerifier] = useState<Note[]>([]); const [notesAVerifier, setNotesAVerifier] = useState<Note[]>([]);
@@ -1907,14 +1919,31 @@ const Dashboard = (): JSX.Element => {
setTimeout(() => setToast(null), 3500); setTimeout(() => setToast(null), 3500);
}; };
const checkProfilMinimal = async (): Promise<boolean> => {
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 ──────────── // ── 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}`); window.history.pushState({ section: s }, '', `#${s}`);
setSection(s); setSection(s);
}; };
useEffect(() => { useEffect(() => {
const hash = window.location.hash.replace('#', ''); const hash = window.location.hash.replace('#', '');
if (hash && sectionTitles[hash]) { 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/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(() => { }); 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(() => { useEffect(() => {
if ((section === 'mesnotes' || section === 'accueil') && !notesLoaded) { 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(() => { }); 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(() => { }); .catch(() => { });
} }
if (section === 'docsavalider' && (isFinance || isVerificateurFinance)) { if (section === 'docsavalider' && (isFinance || isVerificateurFinance)) {
setDocsAValiderLoading(true); 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)); 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)) { if (section === 'historiquepaiements' && (isRHAdmin || isValidateurFinance)) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (paiementFiltreMois) { if (paiementFiltreMois) {
@@ -4622,7 +4659,7 @@ const Dashboard = (): JSX.Element => {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erreur'); if (!res.ok) throw new Error(data.error || 'Erreur');
showToast( showToast(
`${data.nbNotes} note(s) soumises au Président pour validation`, `${data.nbNotes} note(s) envoyés au Président pour validation`,
'success' 'success'
); );
setSelectedNoteIds([]); setSelectedNoteIds([]);
@@ -5113,51 +5150,120 @@ const Dashboard = (): JSX.Element => {
{/* ════════════════════════════════════════ */} {/* ════════════════════════════════════════ */}
{/* DOCUMENTS À VALIDER (Finance) */} {/* DOCUMENTS À VALIDER (Finance) */}
{/* ════════════════════════════════════════ */} {/* ════════════════════════════════════════ */}
{section === 'docsavalider' && (isFinance || isVerificateurFinance) && ( {section === 'docsavalider' && (isFinance || isVerificateurFinance) && (
<div style={{ maxWidth: 900, margin: '0 auto' }}> <div style={{ maxWidth: 900, margin: '0 auto' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)', marginBottom: 24, display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<ShieldCheck size={22} /> Documents à valider <div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 10 }}>
{docsAValider.length > 0 && <span style={{ background: '#6366f1', color: '#fff', fontSize: 12, fontWeight: 700, padding: '2px 10px', borderRadius: 20 }}>{docsAValider.length}</span>} <ShieldCheck size={22} /> Documents
{vueDocsValider === 'avalider' && docsAValider.length > 0 && (
<span style={{ background: '#6366f1', color: '#fff', fontSize: 12, fontWeight: 700, padding: '2px 10px', borderRadius: 20 }}>{docsAValider.length}</span>
)}
</div>
<div style={{ display: 'flex', gap: 6, background: 'var(--bg-input)', borderRadius: 10, padding: 4 }}>
<button onClick={() => setVueDocsValider('avalider')} style={{
padding: '7px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
background: vueDocsValider === 'avalider' ? '#6366f1' : 'transparent',
color: vueDocsValider === 'avalider' ? '#fff' : 'var(--text-secondary)',
transition: 'all 0.15s',
}}>À valider</button>
<button onClick={() => setVueDocsValider('historique')} style={{
padding: '7px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
background: vueDocsValider === 'historique' ? '#6366f1' : 'transparent',
color: vueDocsValider === 'historique' ? '#fff' : 'var(--text-secondary)',
transition: 'all 0.15s',
}}>Historique</button>
</div>
</div> </div>
{docsAValiderLoading ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div> {vueDocsValider === 'avalider' ? (
) : docsAValider.length === 0 ? ( docsAValiderLoading ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Aucun document en attente de validation</div> <div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div>
) : ( ) : docsAValider.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Aucun document en attente de validation</div>
{docsAValider.map((doc: any) => { ) : (
const tl: Record<string, string> = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' }; <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
return ( {docsAValider.map((doc: any) => {
<div key={doc.id} style={{ ...cardStyle, padding: '18px 22px' }}> const tl: Record<string, string> = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' };
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}> return (
<div> <div key={doc.id} style={{ ...cardStyle, padding: '18px 22px' }}>
<div style={{ fontWeight: 800, fontSize: 16, color: 'var(--text-primary)' }}>{doc.prenom} {doc.nom}</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>📍 {doc.campus} · {doc.departement}</div> <div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ fontWeight: 800, fontSize: 16, color: 'var(--text-primary)' }}>{doc.prenom} {doc.nom}</div>
<span style={{ background: '#eef2ff', color: '#6366f1', padding: '3px 10px', borderRadius: 6, fontWeight: 700, fontSize: 12 }}>{tl[doc.type] || doc.type}</span> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>📍 {doc.campus} · {doc.departement}</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Soumis le {new Date(doc.DateModification).toLocaleDateString('fr-FR')}</span> <div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ background: '#eef2ff', color: '#6366f1', padding: '3px 10px', borderRadius: 6, fontWeight: 700, fontSize: 12 }}>{tl[doc.type] || doc.type}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Envoyé le {new Date(doc.DateModification).toLocaleDateString('fr-FR')}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => setPreviewUrl({ url: proxyUrl(doc.sharepointUrl), name: doc.fileName })} style={{ padding: '8px 14px', background: '#eef2ff', color: '#6366f1', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}>👁 Voir</button>
<button onClick={async () => {
await fetch(`${API}/api/finance/documents/${doc.id}/valider`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ action: 'valider' }) });
setDocsAValider(prev => prev.filter((d: any) => d.id !== doc.id));
showToast('✅ Document validé', 'success');
}} style={{ padding: '8px 14px', background: '#dcfce7', color: '#15803d', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}> Valider</button>
<button onClick={async () => {
const motif = window.prompt('Motif du refus :');
if (motif === null) return;
await fetch(`${API}/api/finance/documents/${doc.id}/valider`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ action: 'refuser', commentaire: motif }) });
setDocsAValider(prev => prev.filter((d: any) => d.id !== doc.id));
showToast('❌ Document refusé', 'error');
}} style={{ padding: '8px 14px', background: '#fee2e2', color: '#dc2626', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}> Refuser</button>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> </div>
<button onClick={() => setPreviewUrl({ url: proxyUrl(doc.sharepointUrl), name: doc.fileName })} style={{ padding: '8px 14px', background: '#eef2ff', color: '#6366f1', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}>👁 Voir</button> );
<button onClick={async () => { })}
await fetch(`${API}/api/finance/documents/${doc.id}/valider`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ action: 'valider' }) }); </div>
setDocsAValider(prev => prev.filter((d: any) => d.id !== doc.id)); )
showToast('✅ Document validé', 'success'); ) : (
}} style={{ padding: '8px 14px', background: '#dcfce7', color: '#15803d', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}> Valider</button> docsHistoriqueLoading ? (
<button onClick={async () => { <div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div>
const motif = window.prompt('Motif du refus :'); ) : docsHistorique.length === 0 ? (
if (motif === null) return; <div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}>📭 Aucun document traité pour le moment</div>
await fetch(`${API}/api/finance/documents/${doc.id}/valider`, { method: 'PUT', headers: hdrs, body: JSON.stringify({ action: 'refuser', commentaire: motif }) }); ) : (
setDocsAValider(prev => prev.filter((d: any) => d.id !== doc.id)); <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
showToast('❌ Document refusé', 'error'); {docsHistorique.map((doc: any) => {
}} style={{ padding: '8px 14px', background: '#fee2e2', color: '#dc2626', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit' }}> Refuser</button> const tl: Record<string, string> = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' };
const isValide = doc.statut === 'valide';
return (
<div key={doc.id} style={{
...cardStyle, padding: '18px 22px',
borderLeft: `4px solid ${isValide ? '#15803d' : '#dc2626'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontWeight: 800, fontSize: 16, color: 'var(--text-primary)' }}>{doc.prenom} {doc.nom}</div>
<span style={{
fontSize: 11, fontWeight: 700, padding: '2px 9px', borderRadius: 20,
background: isValide ? '#dcfce7' : '#fee2e2',
color: isValide ? '#15803d' : '#dc2626',
}}>{isValide ? '✅ Validé' : '❌ Refusé'}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>📍 {doc.campus} · {doc.departement}</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ background: '#eef2ff', color: '#6366f1', padding: '3px 10px', borderRadius: 6, fontWeight: 700, fontSize: 12 }}>{tl[doc.type] || doc.type}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{isValide ? 'Validé' : 'Refusé'} le {doc.dateValidation ? new Date(doc.dateValidation).toLocaleDateString('fr-FR') : '—'}
{doc.validateurNom && ` par ${doc.validateurNom}`}
</span>
</div>
{!isValide && doc.commentaire && (
<div style={{ fontSize: 12, color: '#dc2626', marginTop: 6, background: '#fef2f2', padding: '6px 10px', borderRadius: 6 }}>
Motif : {doc.commentaire}
</div>
)}
</div>
<button onClick={() => setPreviewUrl({ url: proxyUrl(doc.sharepointUrl), name: doc.fileName })} style={{ padding: '8px 14px', background: '#eef2ff', color: '#6366f1', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit', flexShrink: 0 }}>👁 Voir</button>
</div> </div>
</div> </div>
</div> );
); })}
})} </div>
</div> )
)} )}
</div> </div>
)} )}
@@ -6630,6 +6736,73 @@ const Dashboard = (): JSX.Element => {
</div> </div>
</div> </div>
)} )}
{showProfilIncompletModal && profilCompletude && (
<div style={{
position: 'fixed', inset: 0, zIndex: 10001,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16,
padding: 28, maxWidth: 440, width: '100%',
boxShadow: '0 24px 60px rgba(0,0,0,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20, paddingBottom: 16, borderBottom: '1px solid var(--border-divider)' }}>
<div style={{ width: 44, height: 44, borderRadius: 12, flexShrink: 0, background: 'linear-gradient(135deg,#f59e0b,#d97706)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22 }}></div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--text-primary)' }}>Profil incomplet</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
Complétez les informations ci-dessous avant de créer une note.
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 22 }}>
{[
{ ok: profilCompletude.adresseOk, label: 'Adresse postale complète' },
{ ok: profilCompletude.ibanOk, label: 'IBAN renseigné' },
{ ok: profilCompletude.bicOk, label: 'BIC renseigné' },
].map(({ ok, label }) => (
<div key={label} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
background: ok ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${ok ? '#86efac' : '#fecaca'}`,
borderRadius: 8,
}}>
<span style={{ fontSize: 16 }}>{ok ? '✅' : '❌'}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: ok ? '#15803d' : '#dc2626' }}>{label}</span>
</div>
))}
</div>
<div style={{
fontSize: 11, color: 'var(--text-muted)', background: '#eef2ff',
border: '1px solid #c7d2fe', borderRadius: 8, padding: '8px 12px', marginBottom: 16,
}}>
💡 Pour les frais kilométriques, vous devrez aussi renseigner votre véhicule et faire valider votre carte grise / permis dans votre profil avant soumission.
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={() => setShowProfilIncompletModal(false)} style={{
flex: 1, padding: '11px 16px', background: 'var(--bg-input)',
border: '1px solid var(--border-input)', borderRadius: 9, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)',
}}>Annuler</button>
<button onClick={() => {
setShowProfilIncompletModal(false);
window.history.pushState({ section: 'profil' }, '', '#profil');
setSection('profil');
}} style={{
flex: 2, padding: '11px 16px',
background: 'linear-gradient(135deg,#f59e0b,#d97706)',
color: '#fff', border: 'none', borderRadius: 9, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
}}>👤 Compléter mon profil</button>
</div>
</div>
</div>
)}
<NDFChatbot /> <NDFChatbot />
+101 -70
View File
@@ -1883,6 +1883,8 @@ export default function NouvelleNote({
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true); const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId); const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
const errorBannerRef = useRef<HTMLDivElement>(null);
const depenseRefs = useRef<Record<number, HTMLDivElement | null>>({});
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const needsResaveRef = useRef(false); const needsResaveRef = useRef(false);
@@ -2197,66 +2199,93 @@ export default function NouvelleNote({
fetchBrouillons(); fetchBrouillons();
} catch { } } catch { }
}; };
const scrollToError = (depenseId?: number) => {
const handleSubmit = async () => { requestAnimationFrame(() => {
setSubmitError(""); const target = depenseId ? depenseRefs.current[depenseId] : errorBannerRef.current;
if (submitting) return; if (target) {
if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); return; } target.scrollIntoView({ behavior: "smooth", block: "center" });
if (dateDebut && isDateFutureMonth(dateDebut)) { } else {
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."); errorBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
return; }
} });
for (const d of depenses) { };
if (!d.date || !d.libelle.trim()) { const handleSubmit = async () => {
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`); setSubmitError("");
setExpandedId(d.id); return; if (submitting) return;
} if (!titre.trim()) {
// ✅ Bloquer les dates dans un mois futur setSubmitError("Veuillez saisir un titre.");
if (isDateFutureMonth(d.date)) { scrollToError();
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.`); return;
setExpandedId(d.id); return; }
} if (dateDebut && isDateFutureMonth(dateDebut)) {
const isKmLine = d.categorie.toLowerCase().includes("kilom"); 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.");
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) { scrollToError();
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`); return;
setExpandedId(d.id); return; }
} for (const d of depenses) {
if (!isKmLine) { if (!d.date || !d.libelle.trim()) {
const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0; setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
if (!hasFiles) { setExpandedId(d.id);
setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`); scrollToError(d.id);
setExpandedId(d.id); return; return;
} }
} // ✅ Bloquer les dates dans un mois futur
if (d.categorie.toLowerCase().includes("repas")) { if (isDateFutureMonth(d.date)) {
for (let pi = 0; pi < d.participants.length; pi++) { 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.`);
const p = d.participants[pi]; setExpandedId(d.id);
if (!p.nom?.trim() || !p.prenom?.trim()) { scrollToError(d.id);
setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`); return;
setExpandedId(d.id); return; }
} const isKmLine = d.categorie.toLowerCase().includes("kilom");
} if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
// Pas de blocage sur le dépassement 25€ — c'est un warning uniquement setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
} setExpandedId(d.id);
} scrollToError(d.id);
setSubmitting(true); return;
try { }
const depensesBackend = depenses.map(d => { if (!isKmLine) {
// Fusionner : store (fichiers pas encore uploadés) + d.files (fichiers React) const hasFiles = d.files.length > 0 || (d.filesMeta ?? []).length > 0 || (d.qrFiles ?? []).length > 0;
const storedFiles = getStoredFiles(d.id); if (!hasFiles) {
const allFiles = storedFiles.length > 0 setSubmitError(`"${d.libelle}" — un justificatif est obligatoire pour cette catégorie.`);
? [...d.files, ...storedFiles.filter(sf => !d.files.some(rf => rf.name === sf.name && rf.size === sf.size))] setExpandedId(d.id);
: d.files; scrollToError(d.id);
return { return;
...d, }
files: allFiles, }
tvaItems: d.tvaItems.map(tvaItemToBackend), if (d.categorie.toLowerCase().includes("repas")) {
qrFiles: d.qrFiles ?? [], for (let pi = 0; pi < d.participants.length; pi++) {
}; const p = d.participants[pi];
}); if (!p.nom?.trim() || !p.prenom?.trim()) {
await onSubmit(titre, dateDebut, dateFin, commentaire, depensesBackend); setSubmitError(`"${d.libelle}" — le nom et prénom du participant ${pi + 1} sont obligatoires.`);
} catch (e: any) { setSubmitError(e.message || "Erreur lors de la soumission"); setSubmitting(false); } 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 // ✅ Calcul des totaux globaux — gestion du cas MIXED
const { totalTTC, totalHT, totalTVA } = useMemo(() => { const { totalTTC, totalHT, totalTVA } = useMemo(() => {
@@ -2420,7 +2449,7 @@ export default function NouvelleNote({
</div> </div>
</div> </div>
{submitError && <div className="nn-error"> {submitError}</div>} {submitError && <div ref={errorBannerRef} className="nn-error"> {submitError}</div>}
<div className="nn-meta"> <div className="nn-meta">
<div className="nn-meta-field"> <div className="nn-meta-field">
@@ -2453,15 +2482,17 @@ export default function NouvelleNote({
</button> </button>
</div> </div>
{depenses.map((d, i) => ( {depenses.map((d, i) => (
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length} <div key={d.id} ref={el => { depenseRefs.current[d.id] = el; }}>
expanded={expandedId === d.id} <DepenseCard depense={d} index={i} total={depenses.length}
onToggle={handleToggle} onUpdate={handleUpdateDepense} expanded={expandedId === d.id}
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense} onToggle={handleToggle} onUpdate={handleUpdateDepense}
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule} onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
onNavigateToProfil={onNavigateToProfil} disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
/> onNavigateToProfil={onNavigateToProfil}
))} />
</div>
))}
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}> <button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
+ Ajouter une dépense + Ajouter une dépense