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
+220 -47
View File
@@ -1742,6 +1742,15 @@ const Dashboard = (): JSX.Element => {
});
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<{
chevaux: number;
marque: string;
@@ -1790,6 +1799,9 @@ const Dashboard = (): JSX.Element => {
const [profilDocsUploading, setProfilDocsUploading] = useState<string | null>(null);
const [docsAValider, setDocsAValider] = useState<any[]>([]);
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 ────────────────────────────
const [notesAVerifier, setNotesAVerifier] = useState<Note[]>([]);
@@ -1907,14 +1919,31 @@ const Dashboard = (): JSX.Element => {
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 ────────────
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) && (
<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 }}>
<ShieldCheck size={22} /> Documents à valider
{docsAValider.length > 0 && <span style={{ background: '#6366f1', color: '#fff', fontSize: 12, fontWeight: 700, padding: '2px 10px', borderRadius: 20 }}>{docsAValider.length}</span>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 10 }}>
<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>
{docsAValiderLoading ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div>
) : docsAValider.length === 0 ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Aucun document en attente de validation</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{docsAValider.map((doc: any) => {
const tl: Record<string, string> = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' };
return (
<div key={doc.id} style={{ ...cardStyle, padding: '18px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 16, color: 'var(--text-primary)' }}>{doc.prenom} {doc.nom}</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' }}>
<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)' }}>Soumis le {new Date(doc.DateModification).toLocaleDateString('fr-FR')}</span>
{vueDocsValider === 'avalider' ? (
docsAValiderLoading ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div>
) : docsAValider.length === 0 ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Aucun document en attente de validation</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{docsAValider.map((doc: any) => {
const tl: Record<string, string> = { rib: '🏦 RIB', carte_grise: '🚗 Carte grise', cartegrise: '🚗 Carte grise', permis: '📄 Permis de conduire' };
return (
<div key={doc.id} style={{ ...cardStyle, padding: '18px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 16, color: 'var(--text-primary)' }}>{doc.prenom} {doc.nom}</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' }}>
<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 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>
)
) : (
docsHistoriqueLoading ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}> Chargement...</div>
) : docsHistorique.length === 0 ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}>📭 Aucun document traité pour le moment</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{docsHistorique.map((doc: any) => {
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>
)}
@@ -6630,6 +6736,73 @@ const Dashboard = (): JSX.Element => {
</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 />
+101 -70
View File
@@ -1883,6 +1883,8 @@ export default function NouvelleNote({
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true);
const activeBrouillonIdRef = useRef<number | null>(initialBrouillonId);
const errorBannerRef = useRef<HTMLDivElement>(null);
const depenseRefs = useRef<Record<number, HTMLDivElement | null>>({});
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({
</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-field">
@@ -2453,15 +2482,17 @@ export default function NouvelleNote({
</button>
</div>
{depenses.map((d, i) => (
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
expanded={expandedId === d.id}
onToggle={handleToggle} onUpdate={handleUpdateDepense}
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
onNavigateToProfil={onNavigateToProfil}
/>
))}
{depenses.map((d, i) => (
<div key={d.id} ref={el => { depenseRefs.current[d.id] = el; }}>
<DepenseCard depense={d} index={i} total={depenses.length}
expanded={expandedId === d.id}
onToggle={handleToggle} onUpdate={handleUpdateDepense}
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
onNavigateToProfil={onNavigateToProfil}
/>
</div>
))}
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
+ Ajouter une dépense