version_Rôle_President Version_Chatbot
This commit is contained in:
@@ -28,6 +28,7 @@ interface AuthContextType {
|
||||
isSuperUser: boolean;
|
||||
isVerificateurFinance: boolean;
|
||||
isValidateurFinance: boolean;
|
||||
isPresident: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -38,7 +39,7 @@ const VALID_ROLES = [
|
||||
'Collaborateur', 'Collaboratrice',
|
||||
'Validateur', 'Validatrice',
|
||||
'Finance', 'VerificateurFinance', 'ValidateurFinance',
|
||||
'superUtilisateur'
|
||||
'superUtilisateur','President'
|
||||
];
|
||||
|
||||
function parseRoles(input: string | string[]): string[] {
|
||||
@@ -199,6 +200,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const isSuperUser = hasRole('superUtilisateur');
|
||||
const isVerificateurFinance = hasRole('VerificateurFinance');
|
||||
const isValidateurFinance = hasRole('ValidateurFinance');
|
||||
const isPresident = hasRole('President');
|
||||
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
@@ -217,7 +220,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
isFinance,
|
||||
isSuperUser,
|
||||
isVerificateurFinance,
|
||||
isValidateurFinance
|
||||
isValidateurFinance,
|
||||
isPresident
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
+272
-32
@@ -5,9 +5,17 @@ import { ThemeToggleButton } from '../context/ThemeContext';
|
||||
import NouvelleNote from './NouvelleNote';
|
||||
import VerificateurFinanceLight from './VerificateurFinanceLight';
|
||||
import NDFChatbot from './NdfChatbot';
|
||||
<<<<<<< HEAD
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
|
||||
=======
|
||||
import PresidentValidation from './PresidentValidation';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
|
||||
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
import {
|
||||
LayoutDashboard, PlusCircle, FileText, CheckSquare, History,
|
||||
CreditCard, User, LogOut, Receipt, Upload, Send,
|
||||
@@ -64,15 +72,15 @@ interface Note {
|
||||
participants?: string;
|
||||
nombreParticipants?: number;
|
||||
validateurN1Id?: number;
|
||||
validateurN2Id?: number;
|
||||
|
||||
nomValidateurN1?: string;
|
||||
nomValidateurN2?: string;
|
||||
|
||||
nomVerificateur?: string;
|
||||
datePaiement?: string;
|
||||
moisPaiement?: number;
|
||||
anneePaiement?: number;
|
||||
commentaireN1?: string;
|
||||
commentaireN2?: string;
|
||||
|
||||
commentaireVerification?: string;
|
||||
dateVerification?: string;
|
||||
motifRefus?: string;
|
||||
@@ -329,9 +337,7 @@ const tagStatut = (statut: string) => {
|
||||
'validn1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
||||
'validen1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
||||
'valide_n1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
||||
'validn2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' },
|
||||
'validen2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' },
|
||||
'valide_n2': { bg: '#ede9fe', color: '#7c3aed', label: 'Valide N2' },
|
||||
|
||||
'approuve': { bg: '#dcfce7', color: '#15803d', label: 'Approuve' },
|
||||
'verifie': { bg: '#ede9fe', color: '#7c3aed', label: 'Vérifié' },
|
||||
'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
|
||||
@@ -367,8 +373,8 @@ const StatutStepper = ({ statut }: { statut: string }) => {
|
||||
|
||||
const steps = [
|
||||
{ key: 'enattente', label: 'Soumise', icon: '📋' },
|
||||
{ key: 'validen1', label: 'Validée N1', icon: '✅' },
|
||||
{ key: 'validen2', label: 'Validée N2', icon: '✅' },
|
||||
{ key: 'validen1', label: 'Validation', icon: '✅' },
|
||||
|
||||
{ key: 'approuve', label: 'Approuvée', icon: '🎉' },
|
||||
{ key: 'verifie', label: 'Vérifiée', icon: '🔍' },
|
||||
{ key: 'paiementenattente', label: 'En att. paiement', icon: '⏳' },
|
||||
@@ -382,7 +388,7 @@ const StatutStepper = ({ statut }: { statut: string }) => {
|
||||
if (s === 'paiementenattente' || s === 'paiement_en_attente') return 5;
|
||||
if (s === 'verifie') return 4;
|
||||
if (s === 'approuve') return 3;
|
||||
if (['validen2', 'valide_n2', 'validn2'].includes(s)) return 2;
|
||||
|
||||
if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
|
||||
if (s === 'non_conforme_verif') return -1;
|
||||
return 0;
|
||||
@@ -1318,6 +1324,7 @@ const InlinePreviewViewer = ({ item }: { item: { url: string; name: string } })
|
||||
// ══════════════════════════════════════════════════════
|
||||
const Dashboard = (): JSX.Element => {
|
||||
const { user, logout, isValidateur, isFinance, isSuperUser, isVerificateurFinance, isValidateurFinance } = useAuth();
|
||||
const isPresident = user?.roles?.includes('President') ?? false;
|
||||
|
||||
// ── Rôles nouveaux ──────────────────────────────────
|
||||
|
||||
@@ -1329,6 +1336,10 @@ const Dashboard = (): JSX.Element => {
|
||||
const [pending, setPending] = useState<Note[]>([]);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [inlinePreview, setInlinePreview] = useState<{ url: string; name: string } | null>(null);
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
const [notesEnAttentePresident, setNotesEnAttentePresident] = useState<Note[]>([]);
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
|
||||
const [adresseForm, setAdresseForm] = useState({
|
||||
adresse_rue: '', adresse_cp: '', adresse_ville: '', adresse_pays: 'France', societe: ''
|
||||
@@ -1721,6 +1732,12 @@ const Dashboard = (): JSX.Element => {
|
||||
.then(d => Array.isArray(d) && setNotesAVerifier(d))
|
||||
.catch(() => { });
|
||||
}
|
||||
if (section === 'accueil' && (isFinance || isVerificateurFinance)) {
|
||||
fetch(`${API}/api/finance/documents-a-valider`, { headers: hdrs })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(d => Array.isArray(d) && setDocsAValider(d))
|
||||
.catch(() => { });
|
||||
}
|
||||
// ✅ Charger pending aussi sur l'accueil si validateur
|
||||
if (section === 'validation' || (section === 'accueil' && canValidate)) {
|
||||
fetch(`${API}/api/notes/pending`, { headers: hdrs }).then(r => r.ok ? r.json() : []).then(d => Array.isArray(d) && setPending(d)).catch(() => { });
|
||||
@@ -1787,7 +1804,12 @@ const Dashboard = (): JSX.Element => {
|
||||
.then(d => setFiltresDisponibles(d))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
if ((section === 'president' || section === 'accueil') && isPresident) {
|
||||
fetch(`${API}/api/president/notes`, { headers: hdrs })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(d => Array.isArray(d) && setNotesEnAttentePresident(d))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
// ── NOUVEAU : Vérificateur Finance ──
|
||||
if (section === 'verification' && isVerificateurFinance) {
|
||||
@@ -1950,6 +1972,21 @@ const Dashboard = (): JSX.Element => {
|
||||
showToast('⛔ Veuillez renseigner votre adresse complète dans "Mon profil" avant de soumettre une note.', 'error');
|
||||
return;
|
||||
}
|
||||
// Vérification documents obligatoires pour notes kilométriques
|
||||
const hasKmLine = lignesExterne.some(l => l.categorie?.toLowerCase().includes('kilom'));
|
||||
if (hasKmLine) {
|
||||
const docs = await fetch(`${API}/api/profil/documents`, { headers: hdrs as HeadersInit })
|
||||
.then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
|
||||
if (!docs?.carte_grise || docs.carte_grise.statut === 'refuse') {
|
||||
showToast('⛔ Votre carte grise doit être soumise dans "Mon profil" avant une note kilométrique.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!docs?.permis || docs.permis.statut === 'refuse') {
|
||||
showToast('⛔ Votre permis de conduire doit être soumis dans "Mon profil" avant une note kilométrique.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lignesPayload = lignesExterne.map(l => {
|
||||
const isKm = l.categorie.toLowerCase().includes('kilom');
|
||||
@@ -2050,6 +2087,7 @@ const Dashboard = (): JSX.Element => {
|
||||
docsavalider: 'Documents à valider',
|
||||
historiquepaiements: 'Historique des paiements',
|
||||
xmlgenerés: 'XML virements générés',
|
||||
president: 'Validation Président — Virements bancaires',
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
@@ -2062,8 +2100,14 @@ const Dashboard = (): JSX.Element => {
|
||||
// ── Nouveaux rôles ──
|
||||
...(isVerificateurFinance ? [{ id: 'verification', icon: <Eye size={16} />, label: 'Notes à vérifier', badge: notesAVerifier.length || undefined }] : []),
|
||||
...(isValidateurFinance ? [{ id: 'paiements', icon: <CreditCard size={16} />, label: 'Valider paiements', badge: notesVerifiees.length || undefined }] : []),
|
||||
...(isPresident ? [{
|
||||
id: 'president',
|
||||
icon: <ShieldCheck size={16} />,
|
||||
label: 'Validation Président',
|
||||
badge: notesEnAttentePresident.length || undefined
|
||||
}] : []),
|
||||
// ── Rôles Finance classiques ──
|
||||
...(isFinance ? [{ id: 'docsavalider', icon: <ShieldCheck size={16} />, label: 'Documents à valider', badge: docsAValider.filter((d: any) => d.statut === 'en_attente').length || undefined }] : []),
|
||||
...((isFinance || isVerificateurFinance) ? [{ id: 'docsavalider', icon: <ShieldCheck size={16} />, label: 'Documents à valider', badge: docsAValider.filter((d: any) => d.statut === 'en_attente').length || undefined }] : []),
|
||||
...(isRHAdmin ? [{ id: 'paiements', icon: <CreditCard size={16} />, label: 'Paiements', badge: exceptions.length || undefined }] : []),
|
||||
...((isRHAdmin || isValidateurFinance) ? [{ id: 'historiquepaiements', icon: <CreditCard size={16} />, label: 'Historique paiements' }] : []),
|
||||
...(isSuperUser ? [{ id: 'supervision', icon: <ShieldCheck size={16} />, label: 'Supervision' }] : []),
|
||||
@@ -2482,7 +2526,7 @@ const Dashboard = (): JSX.Element => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: (canValidate || isVerificateurFinance || isValidateurFinance) ? '1fr 1fr' : '1fr', gap: 20 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: (canValidate || isVerificateurFinance || isValidateurFinance || isPresident) ? '1fr 1fr' : '1fr', gap: 20 }}>
|
||||
{/* ── SECTION 1 : MES NOTES ── */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{
|
||||
@@ -2727,6 +2771,48 @@ const Dashboard = (): JSX.Element => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isVerificateurFinance && docsAValider.length > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--border-divider)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: 'linear-gradient(90deg, #fef3c7, var(--bg-card))',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'linear-gradient(135deg,#d97706,#b45309)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15
|
||||
}}>📄</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--text-primary)' }}>
|
||||
Documents à valider
|
||||
<span style={{
|
||||
marginLeft: 8, background: '#ef4444', color: '#fff',
|
||||
fontSize: 11, fontWeight: 800, padding: '2px 7px', borderRadius: 10
|
||||
}}>{docsAValider.filter((d: any) => d.statut === 'en_attente').length}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
RIB, cartes grises et permis à contrôler
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => nav('docsavalider')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 14px',
|
||||
background: 'linear-gradient(135deg,#d97706,#b45309)',
|
||||
color: '#fff', border: 'none', borderRadius: 8,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
}}>
|
||||
Voir tout →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isValidateurFinance && (
|
||||
<div style={cardStyle}>
|
||||
<div style={{
|
||||
@@ -2871,19 +2957,126 @@ const Dashboard = (): JSX.Element => {
|
||||
);
|
||||
})()}
|
||||
|
||||
<<<<<<< HEAD
|
||||
{notesVerifiees.length > 0 && (
|
||||
=======
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── SECTION : VIREMENTS À VALIDER (Président) ── */}
|
||||
{isPresident && (
|
||||
<div style={cardStyle}>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--border-divider)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: 'linear-gradient(90deg, #eff6ff, var(--bg-card))',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
|
||||
}}>💼</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--text-primary)' }}>
|
||||
Virements à valider
|
||||
{notesEnAttentePresident.length > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 8, background: '#ef4444', color: '#fff',
|
||||
fontSize: 11, fontWeight: 800, padding: '2px 7px', borderRadius: 10,
|
||||
}}>{notesEnAttentePresident.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{notesEnAttentePresident.length === 0
|
||||
? 'Aucun virement en attente'
|
||||
: `${notesEnAttentePresident.length} virement(s) requièrent votre validation`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => nav('president')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 14px',
|
||||
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
color: '#fff', border: 'none', borderRadius: 8,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
}}>
|
||||
Voir tout →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini stat */}
|
||||
<div style={{ borderBottom: '1px solid var(--border-divider)', padding: '12px 16px', textAlign: 'center', background: '#eff6ff' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: '#1d4ed8' }}>
|
||||
{notesEnAttentePresident.length}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginTop: 2 }}>
|
||||
En attente de validation Président
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{notesEnAttentePresident.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '32px 16px', textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 13,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<span style={{ fontSize: 32 }}>✅</span>
|
||||
Aucun virement à valider !
|
||||
</div>
|
||||
) : notesEnAttentePresident.slice(0, 4).map(note => (
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => nav('president')}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
border: '1.5px solid var(--border-card)',
|
||||
borderRadius: 10, cursor: 'pointer',
|
||||
background: 'var(--bg-card)', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#1d4ed8'; e.currentTarget.style.background = '#eff6ff'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-card)'; e.currentTarget.style.background = 'var(--bg-card)'; }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#1d4ed8', fontWeight: 700, marginBottom: 2 }}>
|
||||
{note.reference || `#${note.id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.collaborateur}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
|
||||
{note.libelle}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 900, color: '#1d4ed8', flexShrink: 0, marginLeft: 12 }}>
|
||||
{fmt(note.montant || 0)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{notesEnAttentePresident.length > 0 && (
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<button
|
||||
onClick={() => nav('paiements')}
|
||||
onClick={() => nav('president')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '11px 22px', marginTop: 4,
|
||||
background: 'linear-gradient(135deg,#10b981,#059669)',
|
||||
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
color: '#fff', border: 'none', borderRadius: 10,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
boxShadow: '0 4px 14px rgba(16,185,129,.35)',
|
||||
boxShadow: '0 4px 14px rgba(29,78,216,.35)',
|
||||
}}>
|
||||
💶 Générer XML — {notesVerifiees.length} paiement{notesVerifiees.length > 1 ? 's' : ''}
|
||||
💼 Valider {notesEnAttentePresident.length} virement{notesEnAttentePresident.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -3048,6 +3241,8 @@ const Dashboard = (): JSX.Element => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
{/* ════════════════════════════════════════ */}
|
||||
{/* NOUVELLE NOTE */}
|
||||
@@ -3065,6 +3260,8 @@ const Dashboard = (): JSX.Element => {
|
||||
dateDebutInitiale={date}
|
||||
commentaireInitial={description}
|
||||
depensesInitiales={lignes}
|
||||
onNavigateToProfil={() => nav('profil')}
|
||||
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3104,7 +3301,7 @@ const Dashboard = (): JSX.Element => {
|
||||
<option value="">Tous les statuts</option>
|
||||
|
||||
<option value="enattente">En attente</option>
|
||||
<option value="valide">Validé N1</option>
|
||||
<option value="valide">Validation</option>
|
||||
<option value="approuve">Approuvé</option>
|
||||
<option value="verifie">Vérifié</option>
|
||||
<option value="paiementenattente">Paiement en attente</option>
|
||||
@@ -3741,12 +3938,20 @@ const Dashboard = (): JSX.Element => {
|
||||
{notesFiltreesPaiements.length}
|
||||
</span>
|
||||
</h3>
|
||||
<<<<<<< HEAD
|
||||
<button
|
||||
=======
|
||||
<button
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
style={{
|
||||
...btnPrimary,
|
||||
background: paiementLoading
|
||||
? 'linear-gradient(135deg,#4b5563,#374151)'
|
||||
<<<<<<< HEAD
|
||||
: 'linear-gradient(135deg,#10b981,#059669)',
|
||||
=======
|
||||
: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
opacity: selectedNoteIds.length === 0 ? 0.5 : 1,
|
||||
cursor: paiementLoading ? 'wait' : 'pointer',
|
||||
}}
|
||||
@@ -3755,11 +3960,12 @@ const Dashboard = (): JSX.Element => {
|
||||
if (!selectedNoteIds.length) return;
|
||||
setPaiementLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/paiements/generer-xml`, {
|
||||
const res = await fetch(`${API}/api/paiements/soumettre-president`, {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({ noteIds: selectedNoteIds })
|
||||
});
|
||||
<<<<<<< HEAD
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
if (res.status === 422 && e.details?.length) {
|
||||
@@ -3775,14 +3981,30 @@ const Dashboard = (): JSX.Element => {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`✅ XML généré — ${selectedNoteIds.length} virement(s)`, 'success');
|
||||
=======
|
||||
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`,
|
||||
'success'
|
||||
);
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
setSelectedNoteIds([]);
|
||||
await refreshNotesPaiements();
|
||||
} catch (e: any) { showToast(e.message, 'error'); }
|
||||
finally { setPaiementLoading(false); }
|
||||
} catch (e: any) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setPaiementLoading(false);
|
||||
}
|
||||
}}>
|
||||
{paiementLoading
|
||||
<<<<<<< HEAD
|
||||
? <><span>⏳</span><span>Génération...</span></>
|
||||
: <><span>🏦</span><span>Générer XML ({selectedNoteIds.length})</span></>
|
||||
=======
|
||||
? <><span>⏳</span><span>Envoi...</span></>
|
||||
: <><span>💼</span><span>Soumettre au Président ({selectedNoteIds.length})</span></>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -4177,7 +4399,19 @@ const Dashboard = (): JSX.Element => {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{section === 'president' && isPresident && (
|
||||
<PresidentValidation
|
||||
apiBaseUrl={API}
|
||||
authToken={token || ''}
|
||||
user={{
|
||||
prenom: profile?.prenom || '',
|
||||
nom: profile?.nom || '',
|
||||
email: user?.email || '',
|
||||
roles: user?.roles || [],
|
||||
}}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{/* ════════════════════════════════════════ */}
|
||||
{/* DOCUMENTS À VALIDER (Finance) */}
|
||||
{/* ════════════════════════════════════════ */}
|
||||
@@ -4295,6 +4529,7 @@ const Dashboard = (): JSX.Element => {
|
||||
{[
|
||||
{ label: 'Poste', value: profile?.poste },
|
||||
{ label: 'Département', value: profile?.departement },
|
||||
{ label: 'Société', value: profile?.societe },
|
||||
{ label: 'Campus', value: profile?.campus ? normalizeCampus(profile.campus) : undefined },
|
||||
{ label: 'Type de contrat', value: profile?.TypeContrat },
|
||||
{ label: "Date d'entrée", value: profile?.DateEntree ? new Date(profile.DateEntree).toLocaleDateString('fr-FR') : undefined },
|
||||
@@ -4320,7 +4555,11 @@ const Dashboard = (): JSX.Element => {
|
||||
textTransform: 'uppercase', letterSpacing: '0.6px',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
🏠 Votre Adresse postale & Société
|
||||
=======
|
||||
🏠 Votre Adresse postale personnelle
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
{(!profile?.adresse_rue || !profile?.adresse_cp || !profile?.adresse_ville || !profile?.adresse_pays) && (
|
||||
<span style={{
|
||||
background: '#fef3c7', color: '#b45309',
|
||||
@@ -4355,7 +4594,7 @@ const Dashboard = (): JSX.Element => {
|
||||
{!adresseEditing ? (
|
||||
<>
|
||||
{[
|
||||
{ label: 'Société', value: profile?.societe },
|
||||
|
||||
{ label: 'Rue', value: profile?.adresse_rue },
|
||||
{ label: 'Code postal', value: profile?.adresse_cp },
|
||||
{ label: 'Ville', value: profile?.adresse_ville },
|
||||
@@ -4378,36 +4617,32 @@ const Dashboard = (): JSX.Element => {
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<label style={labelSm}>Société</label>
|
||||
<input style={{ ...inputSm, opacity: 0.6, cursor: 'not-allowed' }}
|
||||
value={profile?.societe || ''} disabled title="La société ne peut pas être modifiée ici" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelSm}>Rue</label>
|
||||
<input style={inputSm} value={adresseForm.adresse_rue}
|
||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_rue: e.target.value }))}
|
||||
placeholder="12 rue de la Paix" />
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={labelSm}>Code postal</label>
|
||||
<input style={inputSm} value={adresseForm.adresse_cp}
|
||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_cp: e.target.value }))}
|
||||
placeholder="75001" maxLength={10} />
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelSm}>Ville</label>
|
||||
<input style={inputSm} value={adresseForm.adresse_ville}
|
||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_ville: e.target.value }))}
|
||||
placeholder="Paris" />
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelSm}>Pays</label>
|
||||
<input style={inputSm} value={adresseForm.adresse_pays}
|
||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_pays: e.target.value }))}
|
||||
placeholder="France" />
|
||||
/>
|
||||
</div>
|
||||
{adresseError && (
|
||||
<div style={{ fontSize: 12, color: '#dc2626', background: '#fee2e2', padding: '8px 12px', borderRadius: 8 }}>
|
||||
@@ -5544,7 +5779,7 @@ const Dashboard = (): JSX.Element => {
|
||||
label: 'Motif de refus', value:
|
||||
ancienneNoteComparaison.motifRefus ||
|
||||
(ancienneNoteComparaison as any).ancienMotifRefus ||
|
||||
ancienneNoteComparaison.commentaireN2 ||
|
||||
|
||||
ancienneNoteComparaison.commentaireN1 || '— Non renseigné'
|
||||
},
|
||||
].map(({ label, value }) => (
|
||||
@@ -5702,6 +5937,11 @@ const Dashboard = (): JSX.Element => {
|
||||
</div>
|
||||
)}
|
||||
<NDFChatbot />
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
|
||||
{/* ── URL PREVIEW ── */}
|
||||
{previewUrl && <UrlPreviewPopup item={previewUrl} onClose={() => setPreviewUrl(null)} />}
|
||||
|
||||
@@ -449,6 +449,7 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
||||
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
||||
animation: "ndfFade 0.2s ease",
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
{isBot && (
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: "50%",
|
||||
@@ -456,6 +457,14 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 12, flexShrink: 0,
|
||||
}}>🤖</div>
|
||||
=======
|
||||
{isBot && (
|
||||
<img
|
||||
src="/img/emma-avatar.jpg"
|
||||
alt="Emma"
|
||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
)}
|
||||
<div style={{ maxWidth: "83%" }}>
|
||||
<div style={{
|
||||
@@ -490,12 +499,20 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
|
||||
<<<<<<< HEAD
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: "50%",
|
||||
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 12, flexShrink: 0,
|
||||
}}>🤖</div>
|
||||
=======
|
||||
<img
|
||||
src="/img/emma-avatar.png"
|
||||
alt="Emma"
|
||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<div style={{
|
||||
padding: "11px 15px",
|
||||
background: "var(--bg-card,#fff)",
|
||||
@@ -579,7 +596,11 @@ export default function NDFChatbot() {
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
<<<<<<< HEAD
|
||||
content: "Bonjour ! 👋 Je suis l'assistant NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
|
||||
=======
|
||||
content: "Bonjour ! 👋 Je suis Emma l'assistante NDF d'ENSUP Group.\n\nJe peux répondre à tes questions sur la plateforme : soumission, justificatifs, validations, remboursements, profil...\n\nComment puis-je t'aider ?",
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
topic: null,
|
||||
},
|
||||
]);
|
||||
@@ -723,7 +744,11 @@ export default function NDFChatbot() {
|
||||
>✕</button>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
||||
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
|
||||
<<<<<<< HEAD
|
||||
Je suis <strong>NDF BOT</strong>, je peux t'aider si besoin.
|
||||
=======
|
||||
Je suis <strong>Emma</strong>, je peux t'aider si besoin.
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
</div>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -8, right: 18,
|
||||
@@ -780,6 +805,7 @@ export default function NDFChatbot() {
|
||||
padding: "13px 16px",
|
||||
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
@@ -788,6 +814,15 @@ export default function NDFChatbot() {
|
||||
}}>🤖</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Assistant NDF</div>
|
||||
=======
|
||||
<img
|
||||
src="/img/emma-avatar.jpg"
|
||||
alt="Emma"
|
||||
style={{ width: 26, height: 26, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Emma</div>
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
||||
@@ -894,7 +929,11 @@ export default function NDFChatbot() {
|
||||
textAlign: "center", fontSize: 9.5,
|
||||
color: "var(--text-muted,#94a3b8)",
|
||||
}}>
|
||||
<<<<<<< HEAD
|
||||
Assistant NDF · ENSUP Group
|
||||
=======
|
||||
Emma · ENSUP Group
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ interface NouvelleNoteProps {
|
||||
dateDebutInitiale?: string;
|
||||
commentaireInitial?: string;
|
||||
depensesInitiales?: any[];
|
||||
onNavigateToProfil?: () => void;
|
||||
}
|
||||
|
||||
// ── CONSTANTES ────────────────────────────────────────
|
||||
@@ -207,6 +208,26 @@ function calcIndemnite(km: number, cv: number): number {
|
||||
return parseFloat((km * b.t3).toFixed(2));
|
||||
}
|
||||
function getTranche(km: number) { return km <= 0 ? 0 : km <= 5000 ? 1 : km <= 20000 ? 2 : 3; }
|
||||
// ── Helper — vérifie qu'une date n'est pas dans un mois futur ──────────
|
||||
function isDateFutureMonth(dateStr: string): boolean {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
const now = new Date();
|
||||
// Date du 1er jour du mois suivant
|
||||
const firstDayNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return d >= firstDayNextMonth;
|
||||
}
|
||||
|
||||
// Retourne la date max autorisée pour les inputs (dernier jour du mois courant)
|
||||
function getMaxAllowedDate(): string {
|
||||
const now = new Date();
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const y = lastDay.getFullYear();
|
||||
const m = String(lastDay.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(lastDay.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// ── PROFIL VÉHICULE — interface partagée ─────────────────
|
||||
interface ProfilVehiculeData {
|
||||
@@ -1085,17 +1106,20 @@ function isRepasEvenementiel(libelle: string, description: string): boolean {
|
||||
|
||||
// ── DEPENSE CARD ──────────────────────────────────────
|
||||
const DepenseCard = React.memo(({
|
||||
depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule
|
||||
depense, index, expanded, onToggle, onUpdate, onDelete, onGenerateQR, disabled, apiBaseUrl, profilVehicule,onNavigateToProfil,
|
||||
}: {
|
||||
depense: Depense; index: number; total: number; expanded: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
onUpdate: (id: number, f: keyof Depense, v: any) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onGenerateQR?: (id: number) => void;
|
||||
onNavigateToProfil?: () => void;
|
||||
disabled?: boolean;
|
||||
apiBaseUrl: string;
|
||||
profilVehicule?: ProfilVehiculeData | null;
|
||||
|
||||
}) => {
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const set = (f: keyof Depense, v: any) => onUpdate(depense.id, f, v);
|
||||
|
||||
@@ -1121,7 +1145,24 @@ const DepenseCard = React.memo(({
|
||||
|
||||
// Détection repas événementiel — désactive l'alerte 25€
|
||||
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
||||
<<<<<<< HEAD
|
||||
const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
|
||||
=======
|
||||
const [alerteRepasVue, setAlerteRepasVue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRepas && ttcParPersonne > 25 && !isEvenementiel) {
|
||||
setAlerteRepasVue(true);
|
||||
}
|
||||
}, [ttcParPersonne, isRepas, isEvenementiel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ttcTotal <= 25) setAlerteRepasVue(false);
|
||||
}, [ttcTotal]);
|
||||
|
||||
// Remplace l'ancienne ligne repasAlerte :
|
||||
const repasAlerte = isRepas && alerteRepasVue && ttcTotal > 25 && !isEvenementiel;
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
|
||||
// ✅ Calcul HT — gestion du cas MIXED
|
||||
const htTotal = isKm
|
||||
@@ -1240,7 +1281,13 @@ const DepenseCard = React.memo(({
|
||||
</div>
|
||||
<div className="nn-field">
|
||||
<label className="nn-fl">Date <span>*</span></label>
|
||||
<input type="date" className="nn-input" value={depense.date} onChange={e => set("date", e.target.value)} />
|
||||
<input
|
||||
type="date"
|
||||
className="nn-input"
|
||||
value={depense.date}
|
||||
max={getMaxAllowedDate()}
|
||||
onChange={e => set("date", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="nn-field">
|
||||
<label className="nn-fl">Libellé <span>*</span></label>
|
||||
@@ -1267,7 +1314,11 @@ const DepenseCard = React.memo(({
|
||||
où vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
|
||||
</div>
|
||||
|
||||
<<<<<<< HEAD
|
||||
{profilVehicule ? (
|
||||
=======
|
||||
{profilVehicule && (
|
||||
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
background: "rgba(124,58,237,.07)", border: "1px solid rgba(124,58,237,.22)",
|
||||
@@ -1276,34 +1327,33 @@ const DepenseCard = React.memo(({
|
||||
<span style={{ fontSize: 18 }}>🚘</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "#7c3aed" }}>
|
||||
Véhicule depuis votre profil
|
||||
Véhicule depuis votre profil — vous pouvez ajuster les CV ci-dessous
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#4b3f80" }}>
|
||||
{profilVehicule.marque} {profilVehicule.modele}
|
||||
{profilVehicule.immatriculation && ` · ${profilVehicule.immatriculation}`}
|
||||
{" "}— <strong>{profilVehicule.chevaux} CV fiscaux</strong>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/profil" style={{ fontSize: 10, color: "#7c3aed", textDecoration: "none", whiteSpace: "nowrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateToProfil?.()} // ← le vrai fix
|
||||
style={{
|
||||
fontSize: 10, color: "#7c3aed", background: "none",
|
||||
border: "none", cursor: "pointer", fontFamily: "inherit",
|
||||
fontWeight: 700, padding: 0, whiteSpace: "nowrap",
|
||||
}}>
|
||||
Modifier ↗
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: "#f59e0b", background: "rgba(245,158,11,.08)", border: "1px solid rgba(245,158,11,.2)", borderRadius: 7, padding: "6px 10px", marginBottom: 8 }}>
|
||||
⚠️ Aucun véhicule renseigné dans votre profil. <a href="/profil" style={{ color: "#d97706", fontWeight: 700 }}>Configurer maintenant ↗</a>
|
||||
</div>
|
||||
<div className="nn-km-bareme">
|
||||
{BAREME_KM.map(b => (
|
||||
<div key={b.cv} className={`nn-km-bareme-item${cv === b.cv ? " sel" : ""}`}
|
||||
onClick={() => set("chevaux", b.cv)}>
|
||||
<span className="nn-km-bareme-label">{b.label}</span>
|
||||
<span className="nn-km-bareme-rate">× {b.t1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!profilVehicule && (
|
||||
<div style={{ fontSize: 11, color: "#f59e0b", background: "rgba(245,158,11,.08)", border: "1px solid rgba(245,158,11,.2)", borderRadius: 7, padding: "6px 10px", marginBottom: 8 }}>
|
||||
⚠️ Aucun véhicule renseigné dans votre profil. <a href="/profil" style={{ color: "#d97706", fontWeight: 700 }}>Configurer maintenant ↗</a>
|
||||
</div>
|
||||
)}
|
||||
{/* Sélecteur CV — toujours visible */}
|
||||
|
||||
|
||||
<div className="nn-km-grid">
|
||||
<div className="nn-field">
|
||||
<label className="nn-fl" style={{ color: "#7c3aed" }}>Kilométrage <span>*</span></label>
|
||||
@@ -1686,8 +1736,9 @@ export default function NouvelleNote({
|
||||
initialBrouillonId = null,
|
||||
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
||||
depensesInitiales,
|
||||
onNavigateToProfil,
|
||||
}: NouvelleNoteProps) {
|
||||
|
||||
|
||||
const initDepenses = (defaultCv = 7): Depense[] => {
|
||||
if (depensesInitiales && depensesInitiales.length > 0) {
|
||||
return depensesInitiales.map((l: any) => {
|
||||
@@ -1963,11 +2014,20 @@ export default function NouvelleNote({
|
||||
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.`);
|
||||
@@ -2174,7 +2234,14 @@ export default function NouvelleNote({
|
||||
</div>
|
||||
<div className="nn-meta-field">
|
||||
<label>Date <span>*</span></label>
|
||||
<input type="date" className="nn-input" value={dateDebut} onChange={e => setDateDebut(e.target.value)} disabled={submitting} />
|
||||
<input
|
||||
type="date"
|
||||
className="nn-input"
|
||||
value={dateDebut}
|
||||
max={getMaxAllowedDate()}
|
||||
onChange={e => setDateDebut(e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="nn-meta-field">
|
||||
<label>Commentaire</label>
|
||||
@@ -2191,13 +2258,15 @@ 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} />
|
||||
))}
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
||||
+ Ajouter une dépense
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
// PresidentValidation.tsx
|
||||
// Page dédiée au rôle Président pour valider les notes de frais et générer le XML
|
||||
// Ajouter dans src/pages/ ou src/components/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
interface Note {
|
||||
id: number;
|
||||
reference: string;
|
||||
libelle: string;
|
||||
montant: number;
|
||||
date: string;
|
||||
categorie: string;
|
||||
statut: string;
|
||||
collaborateur: string;
|
||||
collaborateurEmail: string;
|
||||
departement: string;
|
||||
campus: string;
|
||||
societe?: string;
|
||||
nomN1?: string;
|
||||
nomVerificateur?: string;
|
||||
dateVerification?: string;
|
||||
commentaireVerification?: string;
|
||||
lignesJson?: string;
|
||||
fichiers?: string;
|
||||
}
|
||||
|
||||
interface HistoriqueXml {
|
||||
dateXmlJour: string;
|
||||
dateXmlExacte: string;
|
||||
nbNotes: number;
|
||||
totalMontant: number;
|
||||
noteIds: string;
|
||||
listeReferences: string;
|
||||
presidentNom?: string;
|
||||
dateValidationPresident?: string;
|
||||
commentairePresident?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
apiBaseUrl: string;
|
||||
authToken: string;
|
||||
user: { prenom: string; nom: string; email: string; roles: string[] };
|
||||
onShowToast: (msg: string, type?: string) => void;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (n: number) =>
|
||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(n || 0);
|
||||
|
||||
const normalizeCampus = (campus?: string): string => {
|
||||
if (!campus) return '';
|
||||
const c = campus.toUpperCase();
|
||||
if (c.includes('SQY') || c.includes('SAINT')) return 'SQY';
|
||||
if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
|
||||
if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
|
||||
if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
|
||||
return campus;
|
||||
};
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────
|
||||
const card: React.CSSProperties = {
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--border-card)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px',
|
||||
border: '1.5px solid var(--border-input)', borderRadius: 8,
|
||||
background: 'var(--bg-input)', fontFamily: 'inherit',
|
||||
fontSize: 14, color: 'var(--text-primary)', outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const tagStatut = (s: string) => {
|
||||
const map: Record<string, { bg: string; color: string; label: string }> = {
|
||||
'en_attente_president': { bg: '#ede9fe', color: '#7c3aed', label: '⏳ Attente Président' },
|
||||
'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: '🏦 Paiement en attente' },
|
||||
'verifie': { bg: '#ede9fe', color: '#7c3aed', label: '🔍 Vérifié' },
|
||||
'approuve': { bg: '#dcfce7', color: '#15803d', label: '✅ Approuvé' },
|
||||
};
|
||||
const k = s?.toLowerCase().trim() ?? '';
|
||||
const st = map[k] ?? { bg: '#f1f5f9', color: '#64748b', label: s };
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', fontSize: 11, fontWeight: 700,
|
||||
padding: '3px 10px', borderRadius: 20, whiteSpace: 'nowrap',
|
||||
background: st.bg, color: st.color,
|
||||
}}>{st.label}</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// COMPOSANT PRINCIPAL
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
const PresidentValidation = ({ apiBaseUrl, authToken, user, onShowToast }: Props) => {
|
||||
const [tab, setTab] = useState<'notes' | 'historique'>('notes');
|
||||
|
||||
// Notes en attente
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [notesLoading, setNotesLoading] = useState(false);
|
||||
|
||||
// Sélection
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [commentaire, setCommentaire] = useState('');
|
||||
|
||||
// Historique XML
|
||||
const [historique, setHistorique] = useState<HistoriqueXml[]>([]);
|
||||
const [historiqueLoading, setHistoriqueLoading] = useState(false);
|
||||
|
||||
// XML generation
|
||||
const [xmlLoading, setXmlLoading] = useState(false);
|
||||
const [xmlFiltreAnnee, setXmlFiltreAnnee] = useState('');
|
||||
const [xmlFiltreMois, setXmlFiltreMois] = useState('');
|
||||
|
||||
const hdrs = { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' };
|
||||
|
||||
// ── Chargement des notes ───────────────────────────────────────────
|
||||
const loadNotes = useCallback(async () => {
|
||||
setNotesLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${apiBaseUrl}/api/president/notes`, { headers: hdrs });
|
||||
const data = await res.json();
|
||||
if (res.ok && Array.isArray(data)) setNotes(data);
|
||||
} catch { }
|
||||
finally { setNotesLoading(false); }
|
||||
}, [apiBaseUrl, authToken]);
|
||||
|
||||
// ── Chargement historique ──────────────────────────────────────────
|
||||
const loadHistorique = useCallback(async () => {
|
||||
setHistoriqueLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (xmlFiltreAnnee) params.append('annee', xmlFiltreAnnee);
|
||||
if (xmlFiltreMois) params.append('mois', xmlFiltreMois);
|
||||
const res = await fetch(`${apiBaseUrl}/api/president/historique?${params}`, { headers: hdrs });
|
||||
const data = await res.json();
|
||||
if (res.ok && Array.isArray(data)) setHistorique(data);
|
||||
} catch { }
|
||||
finally { setHistoriqueLoading(false); }
|
||||
}, [apiBaseUrl, authToken, xmlFiltreAnnee, xmlFiltreMois]);
|
||||
|
||||
useEffect(() => { loadNotes(); }, [loadNotes]);
|
||||
useEffect(() => { if (tab === 'historique') loadHistorique(); }, [tab, loadHistorique]);
|
||||
|
||||
// ── Générer XML ────────────────────────────────────────────────────
|
||||
const handleGenererXml = async () => {
|
||||
if (!selectedIds.length) return;
|
||||
if (!window.confirm(`Valider et générer le virement XML pour ${selectedIds.length} note(s) ?`)) return;
|
||||
|
||||
setXmlLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${apiBaseUrl}/api/president/generer-xml`, {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({ noteIds: selectedIds, commentaire: commentaire.trim() || undefined }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
if (res.status === 422 && e.details?.length) {
|
||||
alert(`❌ ${e.error}\n\n${e.details.map((d: string) => `• ${d}`).join('\n')}`);
|
||||
} else {
|
||||
throw new Error(e.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Télécharger le fichier XML
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `virements-ndf-PRESIDENT-${new Date().toISOString().split('T')[0]}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
onShowToast(`✅ XML généré et signé par ${user.prenom} ${user.nom} — ${selectedIds.length} virement(s)`, 'success');
|
||||
setSelectedIds([]);
|
||||
setCommentaire('');
|
||||
await loadNotes();
|
||||
} catch (e: any) {
|
||||
onShowToast(e.message || 'Erreur lors de la génération', 'error');
|
||||
} finally {
|
||||
setXmlLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Statistiques ───────────────────────────────────────────────────
|
||||
const totalNotes = notes.length;
|
||||
const totalMontant = notes.reduce((s, n) => s + (n.montant || 0), 0);
|
||||
const totalSelectionne = notes
|
||||
.filter(n => selectedIds.includes(n.id))
|
||||
.reduce((s, n) => s + (n.montant || 0), 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* ── BANDEAU IDENTITÉ PRÉSIDENT ── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 20,
|
||||
padding: '20px 28px',
|
||||
background: 'linear-gradient(135deg,#0f172a 0%,#1e3a5f 50%,#1d4ed8 100%)',
|
||||
borderRadius: 16, position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Décoration fond */}
|
||||
<div style={{
|
||||
position: 'absolute', right: -40, top: -40,
|
||||
width: 200, height: 200, borderRadius: '50%',
|
||||
background: 'rgba(99,102,241,0.15)', pointerEvents: 'none',
|
||||
}} />
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 16,
|
||||
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 28, flexShrink: 0,
|
||||
}}>
|
||||
💼
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '1px', marginBottom: 4 }}>
|
||||
Espace Président — Validation des virements
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: 18, fontWeight: 800 }}>
|
||||
{user.prenom} {user.nom}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.65)', fontSize: 12, marginTop: 2 }}>
|
||||
Votre validation est requise avant tout virement bancaire
|
||||
</div>
|
||||
</div>
|
||||
{/* Badge compte notes en attente */}
|
||||
{totalNotes > 0 && (
|
||||
<div style={{ marginLeft: 'auto', textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
background: '#ef4444', color: '#fff',
|
||||
fontSize: 22, fontWeight: 900,
|
||||
padding: '8px 18px', borderRadius: 12,
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
{totalNotes}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 4 }}>
|
||||
en attente
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── ONGLETS ── */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '2px solid var(--border-divider)' }}>
|
||||
{[
|
||||
{ id: 'notes', icon: '📋', label: `Notes à valider${totalNotes > 0 ? ` (${totalNotes})` : ''}` },
|
||||
{ id: 'historique', icon: '📅', label: 'Historique de mes validations' },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id as any)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none', borderBottom: tab === t.id ? '2px solid #1d4ed8' : '2px solid transparent',
|
||||
marginBottom: -2, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: tab === t.id ? 800 : 500,
|
||||
color: tab === t.id ? '#1d4ed8' : 'var(--text-muted)',
|
||||
background: 'transparent', transition: 'all 0.15s',
|
||||
}}>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════
|
||||
ONGLET 1 : NOTES À VALIDER
|
||||
════════════════════════════════════════ */}
|
||||
{tab === 'notes' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* ── Stat cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
{[
|
||||
{ label: 'Notes en attente', value: totalNotes, accent: '#1d4ed8', icon: '📋' },
|
||||
{ label: 'Volume total', value: fmt(totalMontant), accent: '#7c3aed', icon: '💰' },
|
||||
{ label: 'Sélection', value: `${selectedIds.length} note(s) — ${fmt(totalSelectionne)}`, accent: '#15803d', icon: '✅' },
|
||||
].map((c, i) => (
|
||||
<div key={i} style={{ ...card, padding: '16px 20px', borderLeft: `4px solid ${c.accent}` }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
|
||||
{c.icon} {c.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 900, color: c.accent }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notesLoading ? (
|
||||
<div style={{ ...card, padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
⏳ Chargement des notes...
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div style={{ ...card, padding: '64px 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 52, marginBottom: 16 }}>✅</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800, color: 'var(--text-primary)', marginBottom: 8 }}>
|
||||
Aucune note en attente
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Les notes soumises par le ValidateurFinance apparaîtront ici.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Barre d'action fixe ── */}
|
||||
<div style={{
|
||||
...card,
|
||||
padding: '14px 20px',
|
||||
background: 'linear-gradient(90deg,#eff6ff,var(--bg-card))',
|
||||
borderLeft: '4px solid #1d4ed8',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
{/* Sélectionner tout */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === notes.length && notes.length > 0}
|
||||
onChange={e => setSelectedIds(e.target.checked ? notes.map(n => n.id) : [])}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)' }}>
|
||||
Tout sélectionner
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<span style={{ fontSize: 12, color: '#1d4ed8', fontWeight: 600 }}>
|
||||
· {selectedIds.length} note(s) — {fmt(totalSelectionne)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Commentaire optionnel */}
|
||||
<input
|
||||
value={commentaire}
|
||||
onChange={e => setCommentaire(e.target.value)}
|
||||
placeholder="Commentaire de validation (optionnel)"
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 200, fontSize: 12, padding: '8px 12px' }}
|
||||
/>
|
||||
|
||||
{/* Bouton générer XML */}
|
||||
<button
|
||||
disabled={selectedIds.length === 0 || xmlLoading}
|
||||
onClick={handleGenererXml}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '11px 24px',
|
||||
background: (selectedIds.length === 0 || xmlLoading)
|
||||
? '#e2e8f0'
|
||||
: 'linear-gradient(135deg,#1d4ed8,#1e40af)',
|
||||
color: (selectedIds.length === 0 || xmlLoading) ? '#94a3b8' : '#fff',
|
||||
border: 'none', borderRadius: 10,
|
||||
cursor: (selectedIds.length === 0 || xmlLoading) ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 800,
|
||||
boxShadow: selectedIds.length > 0 && !xmlLoading
|
||||
? '0 4px 14px rgba(29,78,216,.4)' : 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{xmlLoading ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: 14, height: 14,
|
||||
border: '2px solid rgba(0,0,0,0.2)',
|
||||
borderTop: '2px solid #64748b',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} />
|
||||
Génération...
|
||||
</>
|
||||
) : (
|
||||
<>🏦 Valider et générer XML ({selectedIds.length})</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau des notes ── */}
|
||||
<div style={card}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 700 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg-thead)' }}>
|
||||
{['', 'Référence', 'Collaborateur', 'Campus', 'Société', 'Libellé', 'Montant', 'Vérificateur', 'Statut'].map(h => (
|
||||
<th key={h} style={{
|
||||
padding: '11px 14px', textAlign: 'left',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
color: 'var(--text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.6px', borderBottom: '1px solid var(--border-divider)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map(note => {
|
||||
const isChecked = selectedIds.includes(note.id);
|
||||
return (
|
||||
<tr
|
||||
key={note.id}
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-divider)',
|
||||
background: isChecked ? '#eff6ff' : undefined,
|
||||
cursor: 'pointer', transition: 'background 0.1s',
|
||||
}}
|
||||
onClick={() => setSelectedIds(isChecked
|
||||
? selectedIds.filter(id => id !== note.id)
|
||||
: [...selectedIds, note.id]
|
||||
)}
|
||||
onMouseEnter={e => { if (!isChecked) e.currentTarget.style.background = 'var(--bg-input)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = isChecked ? '#eff6ff' : ''; }}
|
||||
>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => { }}
|
||||
style={{ width: 16, height: 16, cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px', fontFamily: 'monospace', fontSize: 12, color: '#1d4ed8', fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||
{note.reference}
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{note.collaborateur}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{note.collaborateurEmail}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
{note.campus ? (
|
||||
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700 }}>
|
||||
{normalizeCampus(note.campus)}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{note.societe || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px', fontSize: 13, color: 'var(--text-secondary)', maxWidth: 180 }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.libelle}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
|
||||
{note.date ? new Date(note.date).toLocaleDateString('fr-FR') : '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px', fontWeight: 900, fontSize: 15, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
|
||||
{fmt(note.montant)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
{note.nomVerificateur ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{note.nomVerificateur}</div>
|
||||
{note.dateVerification && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 1 }}>
|
||||
{new Date(note.dateVerification).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
{tagStatut(note.statut)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
{/* Pied de tableau : total sélection */}
|
||||
{selectedIds.length > 0 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid #1d4ed8', background: '#eff6ff' }}>
|
||||
<td colSpan={6} style={{ padding: '10px 14px', fontSize: 12, fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Total sélection ({selectedIds.length} note{selectedIds.length > 1 ? 's' : ''})
|
||||
</td>
|
||||
<td colSpan={3} style={{ padding: '10px 14px', fontSize: 16, fontWeight: 900, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
|
||||
{fmt(totalSelectionne)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Récap bouton bas ── */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div style={{
|
||||
...card,
|
||||
padding: '18px 24px',
|
||||
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
flexWrap: 'wrap', gap: 14,
|
||||
}}>
|
||||
<div style={{ color: '#fff' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800 }}>
|
||||
💼 Prêt à valider {selectedIds.length} virement{selectedIds.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 3 }}>
|
||||
Montant total : <strong>{fmt(totalSelectionne)}</strong>
|
||||
{commentaire.trim() && ` · "${commentaire.trim()}"`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.65, marginTop: 3 }}>
|
||||
La mention "Validé par le Président {user.prenom} {user.nom}" sera inscrite dans le fichier XML
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={xmlLoading}
|
||||
onClick={handleGenererXml}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '14px 32px',
|
||||
background: '#fff',
|
||||
color: '#1d4ed8',
|
||||
border: 'none', borderRadius: 12,
|
||||
cursor: xmlLoading ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 15, fontWeight: 900,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{xmlLoading ? '⏳ Génération...' : '🏦 Valider et générer XML →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════
|
||||
ONGLET 2 : HISTORIQUE
|
||||
════════════════════════════════════════ */}
|
||||
{tab === 'historique' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Filtres */}
|
||||
<div style={{ ...card, padding: '14px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
🔍 Filtrer
|
||||
</span>
|
||||
<select
|
||||
value={xmlFiltreAnnee}
|
||||
onChange={e => setXmlFiltreAnnee(e.target.value)}
|
||||
style={{ ...inputStyle, width: 140 }}>
|
||||
<option value="">Toutes les années</option>
|
||||
{[2024, 2025, 2026, 2027].map(a => (
|
||||
<option key={a} value={String(a)}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={xmlFiltreMois}
|
||||
onChange={e => setXmlFiltreMois(e.target.value)}
|
||||
style={{ ...inputStyle, width: 160 }}>
|
||||
<option value="">Tous les mois</option>
|
||||
{[
|
||||
['1', 'Janvier'], ['2', 'Février'], ['3', 'Mars'], ['4', 'Avril'],
|
||||
['5', 'Mai'], ['6', 'Juin'], ['7', 'Juillet'], ['8', 'Août'],
|
||||
['9', 'Septembre'], ['10', 'Octobre'], ['11', 'Novembre'], ['12', 'Décembre']
|
||||
].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={loadHistorique}
|
||||
style={{
|
||||
padding: '9px 18px', borderRadius: 8, border: 'none',
|
||||
background: '#1d4ed8', color: '#fff', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
|
||||
}}>
|
||||
🔄 Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{historiqueLoading ? (
|
||||
<div style={{ ...card, padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
⏳ Chargement...
|
||||
</div>
|
||||
) : historique.length === 0 ? (
|
||||
<div style={{ ...card, padding: '64px 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>📂</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
Aucune validation trouvée
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 8 }}>
|
||||
Les virements que vous avez validés apparaîtront ici.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{historique.map((batch, i) => {
|
||||
const dateXml = new Date(batch.dateXmlExacte || batch.dateXmlJour);
|
||||
const dateLabel = dateXml.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric'
|
||||
});
|
||||
const heureLabel = dateXml.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div key={i} style={card}>
|
||||
{/* En-tête batch */}
|
||||
<div style={{
|
||||
padding: '16px 22px',
|
||||
background: 'linear-gradient(90deg,#eff6ff,var(--bg-card))',
|
||||
borderBottom: '1px solid var(--border-divider)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg,#1d4ed8,#1e40af)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20,
|
||||
}}>🏦</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
|
||||
XML du {dateLabel}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{heureLabel} · {batch.nbNotes} virement{batch.nbNotes > 1 ? 's' : ''}
|
||||
</div>
|
||||
{/* Badge Président */}
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
marginTop: 6, padding: '4px 10px',
|
||||
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||
borderRadius: 20, fontSize: 11, color: '#fff', fontWeight: 700,
|
||||
}}>
|
||||
💼 Validé par le Président {batch.presidentNom || `${user.prenom} ${user.nom}`}
|
||||
</div>
|
||||
{batch.commentairePresident && (
|
||||
<div style={{ fontSize: 11, color: '#6366f1', marginTop: 4, fontStyle: 'italic' }}>
|
||||
💬 {batch.commentairePresident}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 20, fontWeight: 900, color: '#1d4ed8',
|
||||
background: '#eff6ff', border: '1.5px solid #bfdbfe',
|
||||
padding: '8px 18px', borderRadius: 10,
|
||||
}}>
|
||||
{fmt(parseFloat(String(batch.totalMontant)) || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Références */}
|
||||
<div style={{
|
||||
padding: '10px 22px',
|
||||
background: 'var(--bg-thead)',
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', flexShrink: 0 }}>
|
||||
Virements :
|
||||
</span>
|
||||
{(batch.listeReferences || '').split(', ').slice(0, 8).map((ref, ri) => (
|
||||
<span key={ri} style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: '#eff6ff', color: '#1d4ed8',
|
||||
padding: '2px 8px', borderRadius: 6, fontFamily: 'monospace',
|
||||
}}>
|
||||
{ref.trim()}
|
||||
</span>
|
||||
))}
|
||||
{(batch.listeReferences || '').split(', ').length > 8 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
+{(batch.listeReferences || '').split(', ').length - 8} autres
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation spinner */}
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresidentValidation;
|
||||
Reference in New Issue
Block a user