popremplissgeprofil
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user