version_Rôle_President Version_Chatbot
This commit is contained in:
+927
-84
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -28,6 +28,7 @@ interface AuthContextType {
|
|||||||
isSuperUser: boolean;
|
isSuperUser: boolean;
|
||||||
isVerificateurFinance: boolean;
|
isVerificateurFinance: boolean;
|
||||||
isValidateurFinance: boolean;
|
isValidateurFinance: boolean;
|
||||||
|
isPresident: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
@@ -38,7 +39,7 @@ const VALID_ROLES = [
|
|||||||
'Collaborateur', 'Collaboratrice',
|
'Collaborateur', 'Collaboratrice',
|
||||||
'Validateur', 'Validatrice',
|
'Validateur', 'Validatrice',
|
||||||
'Finance', 'VerificateurFinance', 'ValidateurFinance',
|
'Finance', 'VerificateurFinance', 'ValidateurFinance',
|
||||||
'superUtilisateur'
|
'superUtilisateur','President'
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseRoles(input: string | string[]): string[] {
|
function parseRoles(input: string | string[]): string[] {
|
||||||
@@ -199,6 +200,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const isSuperUser = hasRole('superUtilisateur');
|
const isSuperUser = hasRole('superUtilisateur');
|
||||||
const isVerificateurFinance = hasRole('VerificateurFinance');
|
const isVerificateurFinance = hasRole('VerificateurFinance');
|
||||||
const isValidateurFinance = hasRole('ValidateurFinance');
|
const isValidateurFinance = hasRole('ValidateurFinance');
|
||||||
|
const isPresident = hasRole('President');
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
@@ -217,7 +220,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
isFinance,
|
isFinance,
|
||||||
isSuperUser,
|
isSuperUser,
|
||||||
isVerificateurFinance,
|
isVerificateurFinance,
|
||||||
isValidateurFinance
|
isValidateurFinance,
|
||||||
|
isPresident
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
+272
-32
@@ -5,9 +5,17 @@ import { ThemeToggleButton } from '../context/ThemeContext';
|
|||||||
import NouvelleNote from './NouvelleNote';
|
import NouvelleNote from './NouvelleNote';
|
||||||
import VerificateurFinanceLight from './VerificateurFinanceLight';
|
import VerificateurFinanceLight from './VerificateurFinanceLight';
|
||||||
import NDFChatbot from './NdfChatbot';
|
import NDFChatbot from './NdfChatbot';
|
||||||
|
<<<<<<< HEAD
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
|
|
||||||
|
|
||||||
|
=======
|
||||||
|
import PresidentValidation from './PresidentValidation';
|
||||||
|
import QRCode from 'react-qr-code';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, PlusCircle, FileText, CheckSquare, History,
|
LayoutDashboard, PlusCircle, FileText, CheckSquare, History,
|
||||||
CreditCard, User, LogOut, Receipt, Upload, Send,
|
CreditCard, User, LogOut, Receipt, Upload, Send,
|
||||||
@@ -64,15 +72,15 @@ interface Note {
|
|||||||
participants?: string;
|
participants?: string;
|
||||||
nombreParticipants?: number;
|
nombreParticipants?: number;
|
||||||
validateurN1Id?: number;
|
validateurN1Id?: number;
|
||||||
validateurN2Id?: number;
|
|
||||||
nomValidateurN1?: string;
|
nomValidateurN1?: string;
|
||||||
nomValidateurN2?: string;
|
|
||||||
nomVerificateur?: string;
|
nomVerificateur?: string;
|
||||||
datePaiement?: string;
|
datePaiement?: string;
|
||||||
moisPaiement?: number;
|
moisPaiement?: number;
|
||||||
anneePaiement?: number;
|
anneePaiement?: number;
|
||||||
commentaireN1?: string;
|
commentaireN1?: string;
|
||||||
commentaireN2?: string;
|
|
||||||
commentaireVerification?: string;
|
commentaireVerification?: string;
|
||||||
dateVerification?: string;
|
dateVerification?: string;
|
||||||
motifRefus?: string;
|
motifRefus?: string;
|
||||||
@@ -329,9 +337,7 @@ const tagStatut = (statut: string) => {
|
|||||||
'validn1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
'validn1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
||||||
'validen1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
'validen1': { bg: '#dbeafe', color: '#1d4ed8', label: 'Valide N1' },
|
||||||
'valide_n1': { 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' },
|
'approuve': { bg: '#dcfce7', color: '#15803d', label: 'Approuve' },
|
||||||
'verifie': { bg: '#ede9fe', color: '#7c3aed', label: 'Vérifié' },
|
'verifie': { bg: '#ede9fe', color: '#7c3aed', label: 'Vérifié' },
|
||||||
'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
|
'paiementenattente': { bg: '#fef9c3', color: '#b45309', label: 'Paiement en attente' },
|
||||||
@@ -367,8 +373,8 @@ const StatutStepper = ({ statut }: { statut: string }) => {
|
|||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ key: 'enattente', label: 'Soumise', icon: '📋' },
|
{ key: 'enattente', label: 'Soumise', icon: '📋' },
|
||||||
{ key: 'validen1', label: 'Validée N1', icon: '✅' },
|
{ key: 'validen1', label: 'Validation', icon: '✅' },
|
||||||
{ key: 'validen2', label: 'Validée N2', icon: '✅' },
|
|
||||||
{ key: 'approuve', label: 'Approuvée', icon: '🎉' },
|
{ key: 'approuve', label: 'Approuvée', icon: '🎉' },
|
||||||
{ key: 'verifie', label: 'Vérifiée', icon: '🔍' },
|
{ key: 'verifie', label: 'Vérifiée', icon: '🔍' },
|
||||||
{ key: 'paiementenattente', label: 'En att. paiement', 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 === 'paiementenattente' || s === 'paiement_en_attente') return 5;
|
||||||
if (s === 'verifie') return 4;
|
if (s === 'verifie') return 4;
|
||||||
if (s === 'approuve') return 3;
|
if (s === 'approuve') return 3;
|
||||||
if (['validen2', 'valide_n2', 'validn2'].includes(s)) return 2;
|
|
||||||
if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
|
if (['validen1', 'valide_n1', 'validn1'].includes(s)) return 1;
|
||||||
if (s === 'non_conforme_verif') return -1;
|
if (s === 'non_conforme_verif') return -1;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1318,6 +1324,7 @@ const InlinePreviewViewer = ({ item }: { item: { url: string; name: string } })
|
|||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
const Dashboard = (): JSX.Element => {
|
const Dashboard = (): JSX.Element => {
|
||||||
const { user, logout, isValidateur, isFinance, isSuperUser, isVerificateurFinance, isValidateurFinance } = useAuth();
|
const { user, logout, isValidateur, isFinance, isSuperUser, isVerificateurFinance, isValidateurFinance } = useAuth();
|
||||||
|
const isPresident = user?.roles?.includes('President') ?? false;
|
||||||
|
|
||||||
// ── Rôles nouveaux ──────────────────────────────────
|
// ── Rôles nouveaux ──────────────────────────────────
|
||||||
|
|
||||||
@@ -1329,6 +1336,10 @@ const Dashboard = (): JSX.Element => {
|
|||||||
const [pending, setPending] = useState<Note[]>([]);
|
const [pending, setPending] = useState<Note[]>([]);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [inlinePreview, setInlinePreview] = useState<{ url: string; name: string } | 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({
|
const [adresseForm, setAdresseForm] = useState({
|
||||||
adresse_rue: '', adresse_cp: '', adresse_ville: '', adresse_pays: 'France', societe: ''
|
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))
|
.then(d => Array.isArray(d) && setNotesAVerifier(d))
|
||||||
.catch(() => { });
|
.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
|
// ✅ Charger pending aussi sur l'accueil si validateur
|
||||||
if (section === 'validation' || (section === 'accueil' && canValidate)) {
|
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(() => { });
|
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))
|
.then(d => setFiltresDisponibles(d))
|
||||||
.catch(() => { });
|
.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 ──
|
// ── NOUVEAU : Vérificateur Finance ──
|
||||||
if (section === 'verification' && isVerificateurFinance) {
|
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');
|
showToast('⛔ Veuillez renseigner votre adresse complète dans "Mon profil" avant de soumettre une note.', 'error');
|
||||||
return;
|
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 lignesPayload = lignesExterne.map(l => {
|
||||||
const isKm = l.categorie.toLowerCase().includes('kilom');
|
const isKm = l.categorie.toLowerCase().includes('kilom');
|
||||||
@@ -2050,6 +2087,7 @@ const Dashboard = (): JSX.Element => {
|
|||||||
docsavalider: 'Documents à valider',
|
docsavalider: 'Documents à valider',
|
||||||
historiquepaiements: 'Historique des paiements',
|
historiquepaiements: 'Historique des paiements',
|
||||||
xmlgenerés: 'XML virements générés',
|
xmlgenerés: 'XML virements générés',
|
||||||
|
president: 'Validation Président — Virements bancaires',
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -2062,8 +2100,14 @@ const Dashboard = (): JSX.Element => {
|
|||||||
// ── Nouveaux rôles ──
|
// ── Nouveaux rôles ──
|
||||||
...(isVerificateurFinance ? [{ id: 'verification', icon: <Eye size={16} />, label: 'Notes à vérifier', badge: notesAVerifier.length || undefined }] : []),
|
...(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 }] : []),
|
...(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 ──
|
// ── 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 ? [{ id: 'paiements', icon: <CreditCard size={16} />, label: 'Paiements', badge: exceptions.length || undefined }] : []),
|
||||||
...((isRHAdmin || isValidateurFinance) ? [{ id: 'historiquepaiements', icon: <CreditCard size={16} />, label: 'Historique paiements' }] : []),
|
...((isRHAdmin || isValidateurFinance) ? [{ id: 'historiquepaiements', icon: <CreditCard size={16} />, label: 'Historique paiements' }] : []),
|
||||||
...(isSuperUser ? [{ id: 'supervision', icon: <ShieldCheck size={16} />, label: 'Supervision' }] : []),
|
...(isSuperUser ? [{ id: 'supervision', icon: <ShieldCheck size={16} />, label: 'Supervision' }] : []),
|
||||||
@@ -2482,7 +2526,7 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 ── */}
|
{/* ── SECTION 1 : MES NOTES ── */}
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -2727,6 +2771,48 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{isValidateurFinance && (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -2871,19 +2957,126 @@ const Dashboard = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
{notesVerifiees.length > 0 && (
|
{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
|
<button
|
||||||
onClick={() => nav('paiements')}
|
onClick={() => nav('president')}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
padding: '11px 22px', marginTop: 4,
|
padding: '11px 22px', marginTop: 4,
|
||||||
background: 'linear-gradient(135deg,#10b981,#059669)',
|
background: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||||
color: '#fff', border: 'none', borderRadius: 10,
|
color: '#fff', border: 'none', borderRadius: 10,
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
fontSize: 13, fontWeight: 700,
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3048,6 +3241,8 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* ════════════════════════════════════════ */}
|
{/* ════════════════════════════════════════ */}
|
||||||
{/* NOUVELLE NOTE */}
|
{/* NOUVELLE NOTE */}
|
||||||
@@ -3065,6 +3260,8 @@ const Dashboard = (): JSX.Element => {
|
|||||||
dateDebutInitiale={date}
|
dateDebutInitiale={date}
|
||||||
commentaireInitial={description}
|
commentaireInitial={description}
|
||||||
depensesInitiales={lignes}
|
depensesInitiales={lignes}
|
||||||
|
onNavigateToProfil={() => nav('profil')}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3104,7 +3301,7 @@ const Dashboard = (): JSX.Element => {
|
|||||||
<option value="">Tous les statuts</option>
|
<option value="">Tous les statuts</option>
|
||||||
|
|
||||||
<option value="enattente">En attente</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="approuve">Approuvé</option>
|
||||||
<option value="verifie">Vérifié</option>
|
<option value="verifie">Vérifié</option>
|
||||||
<option value="paiementenattente">Paiement en attente</option>
|
<option value="paiementenattente">Paiement en attente</option>
|
||||||
@@ -3741,12 +3938,20 @@ const Dashboard = (): JSX.Element => {
|
|||||||
{notesFiltreesPaiements.length}
|
{notesFiltreesPaiements.length}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<<<<<<< HEAD
|
||||||
<button
|
<button
|
||||||
|
=======
|
||||||
|
<button
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
style={{
|
style={{
|
||||||
...btnPrimary,
|
...btnPrimary,
|
||||||
background: paiementLoading
|
background: paiementLoading
|
||||||
? 'linear-gradient(135deg,#4b5563,#374151)'
|
? 'linear-gradient(135deg,#4b5563,#374151)'
|
||||||
|
<<<<<<< HEAD
|
||||||
: 'linear-gradient(135deg,#10b981,#059669)',
|
: 'linear-gradient(135deg,#10b981,#059669)',
|
||||||
|
=======
|
||||||
|
: 'linear-gradient(135deg,#1e3a5f,#1d4ed8)',
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
opacity: selectedNoteIds.length === 0 ? 0.5 : 1,
|
opacity: selectedNoteIds.length === 0 ? 0.5 : 1,
|
||||||
cursor: paiementLoading ? 'wait' : 'pointer',
|
cursor: paiementLoading ? 'wait' : 'pointer',
|
||||||
}}
|
}}
|
||||||
@@ -3755,11 +3960,12 @@ const Dashboard = (): JSX.Element => {
|
|||||||
if (!selectedNoteIds.length) return;
|
if (!selectedNoteIds.length) return;
|
||||||
setPaiementLoading(true);
|
setPaiementLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/paiements/generer-xml`, {
|
const res = await fetch(`${API}/api/paiements/soumettre-president`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: hdrs,
|
headers: hdrs,
|
||||||
body: JSON.stringify({ noteIds: selectedNoteIds })
|
body: JSON.stringify({ noteIds: selectedNoteIds })
|
||||||
});
|
});
|
||||||
|
<<<<<<< HEAD
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const e = await res.json();
|
const e = await res.json();
|
||||||
if (res.status === 422 && e.details?.length) {
|
if (res.status === 422 && e.details?.length) {
|
||||||
@@ -3775,14 +3981,30 @@ const Dashboard = (): JSX.Element => {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
showToast(`✅ XML généré — ${selectedNoteIds.length} virement(s)`, 'success');
|
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([]);
|
setSelectedNoteIds([]);
|
||||||
await refreshNotesPaiements();
|
await refreshNotesPaiements();
|
||||||
} catch (e: any) { showToast(e.message, 'error'); }
|
} catch (e: any) {
|
||||||
finally { setPaiementLoading(false); }
|
showToast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setPaiementLoading(false);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{paiementLoading
|
{paiementLoading
|
||||||
|
<<<<<<< HEAD
|
||||||
? <><span>⏳</span><span>Génération...</span></>
|
? <><span>⏳</span><span>Génération...</span></>
|
||||||
: <><span>🏦</span><span>Générer XML ({selectedNoteIds.length})</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4177,7 +4399,19 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</div>
|
</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) */}
|
{/* DOCUMENTS À VALIDER (Finance) */}
|
||||||
{/* ════════════════════════════════════════ */}
|
{/* ════════════════════════════════════════ */}
|
||||||
@@ -4295,6 +4529,7 @@ const Dashboard = (): JSX.Element => {
|
|||||||
{[
|
{[
|
||||||
{ label: 'Poste', value: profile?.poste },
|
{ label: 'Poste', value: profile?.poste },
|
||||||
{ label: 'Département', value: profile?.departement },
|
{ label: 'Département', value: profile?.departement },
|
||||||
|
{ label: 'Société', value: profile?.societe },
|
||||||
{ label: 'Campus', value: profile?.campus ? normalizeCampus(profile.campus) : undefined },
|
{ label: 'Campus', value: profile?.campus ? normalizeCampus(profile.campus) : undefined },
|
||||||
{ label: 'Type de contrat', value: profile?.TypeContrat },
|
{ label: 'Type de contrat', value: profile?.TypeContrat },
|
||||||
{ label: "Date d'entrée", value: profile?.DateEntree ? new Date(profile.DateEntree).toLocaleDateString('fr-FR') : undefined },
|
{ 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',
|
textTransform: 'uppercase', letterSpacing: '0.6px',
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
🏠 Votre Adresse postale & Société
|
🏠 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) && (
|
{(!profile?.adresse_rue || !profile?.adresse_cp || !profile?.adresse_ville || !profile?.adresse_pays) && (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: '#fef3c7', color: '#b45309',
|
background: '#fef3c7', color: '#b45309',
|
||||||
@@ -4355,7 +4594,7 @@ const Dashboard = (): JSX.Element => {
|
|||||||
{!adresseEditing ? (
|
{!adresseEditing ? (
|
||||||
<>
|
<>
|
||||||
{[
|
{[
|
||||||
{ label: 'Société', value: profile?.societe },
|
|
||||||
{ label: 'Rue', value: profile?.adresse_rue },
|
{ label: 'Rue', value: profile?.adresse_rue },
|
||||||
{ label: 'Code postal', value: profile?.adresse_cp },
|
{ label: 'Code postal', value: profile?.adresse_cp },
|
||||||
{ label: 'Ville', value: profile?.adresse_ville },
|
{ label: 'Ville', value: profile?.adresse_ville },
|
||||||
@@ -4378,36 +4617,32 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<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>
|
<div>
|
||||||
<label style={labelSm}>Rue</label>
|
<label style={labelSm}>Rue</label>
|
||||||
<input style={inputSm} value={adresseForm.adresse_rue}
|
<input style={inputSm} value={adresseForm.adresse_rue}
|
||||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_rue: e.target.value }))}
|
onChange={e => setAdresseForm(f => ({ ...f, adresse_rue: e.target.value }))}
|
||||||
placeholder="12 rue de la Paix" />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelSm}>Code postal</label>
|
<label style={labelSm}>Code postal</label>
|
||||||
<input style={inputSm} value={adresseForm.adresse_cp}
|
<input style={inputSm} value={adresseForm.adresse_cp}
|
||||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_cp: e.target.value }))}
|
onChange={e => setAdresseForm(f => ({ ...f, adresse_cp: e.target.value }))}
|
||||||
placeholder="75001" maxLength={10} />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelSm}>Ville</label>
|
<label style={labelSm}>Ville</label>
|
||||||
<input style={inputSm} value={adresseForm.adresse_ville}
|
<input style={inputSm} value={adresseForm.adresse_ville}
|
||||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_ville: e.target.value }))}
|
onChange={e => setAdresseForm(f => ({ ...f, adresse_ville: e.target.value }))}
|
||||||
placeholder="Paris" />
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelSm}>Pays</label>
|
<label style={labelSm}>Pays</label>
|
||||||
<input style={inputSm} value={adresseForm.adresse_pays}
|
<input style={inputSm} value={adresseForm.adresse_pays}
|
||||||
onChange={e => setAdresseForm(f => ({ ...f, adresse_pays: e.target.value }))}
|
onChange={e => setAdresseForm(f => ({ ...f, adresse_pays: e.target.value }))}
|
||||||
placeholder="France" />
|
/>
|
||||||
</div>
|
</div>
|
||||||
{adresseError && (
|
{adresseError && (
|
||||||
<div style={{ fontSize: 12, color: '#dc2626', background: '#fee2e2', padding: '8px 12px', borderRadius: 8 }}>
|
<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:
|
label: 'Motif de refus', value:
|
||||||
ancienneNoteComparaison.motifRefus ||
|
ancienneNoteComparaison.motifRefus ||
|
||||||
(ancienneNoteComparaison as any).ancienMotifRefus ||
|
(ancienneNoteComparaison as any).ancienMotifRefus ||
|
||||||
ancienneNoteComparaison.commentaireN2 ||
|
|
||||||
ancienneNoteComparaison.commentaireN1 || '— Non renseigné'
|
ancienneNoteComparaison.commentaireN1 || '— Non renseigné'
|
||||||
},
|
},
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
@@ -5702,6 +5937,11 @@ const Dashboard = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<NDFChatbot />
|
<NDFChatbot />
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
|
||||||
|
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
|
|
||||||
{/* ── URL PREVIEW ── */}
|
{/* ── URL PREVIEW ── */}
|
||||||
{previewUrl && <UrlPreviewPopup item={previewUrl} onClose={() => setPreviewUrl(null)} />}
|
{previewUrl && <UrlPreviewPopup item={previewUrl} onClose={() => setPreviewUrl(null)} />}
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
|||||||
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
gap: 8, alignItems: "flex-end", marginBottom: 10,
|
||||||
animation: "ndfFade 0.2s ease",
|
animation: "ndfFade 0.2s ease",
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
{isBot && (
|
{isBot && (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 26, height: 26, borderRadius: "50%",
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
@@ -456,6 +457,14 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
|||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 12, flexShrink: 0,
|
fontSize: 12, flexShrink: 0,
|
||||||
}}>🤖</div>
|
}}>🤖</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={{ maxWidth: "83%" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -490,12 +499,20 @@ function MessageBubble({ msg, onNegativeFeedback }: MessageBubbleProps) {
|
|||||||
function TypingIndicator() {
|
function TypingIndicator() {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginBottom: 10 }}>
|
||||||
|
<<<<<<< HEAD
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 26, height: 26, borderRadius: "50%",
|
width: 26, height: 26, borderRadius: "50%",
|
||||||
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
|
background: "linear-gradient(135deg,#6366f1,#4f46e5)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 12, flexShrink: 0,
|
fontSize: 12, flexShrink: 0,
|
||||||
}}>🤖</div>
|
}}>🤖</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={{
|
<div style={{
|
||||||
padding: "11px 15px",
|
padding: "11px 15px",
|
||||||
background: "var(--bg-card,#fff)",
|
background: "var(--bg-card,#fff)",
|
||||||
@@ -579,7 +596,11 @@ export default function NDFChatbot() {
|
|||||||
{
|
{
|
||||||
id: "welcome",
|
id: "welcome",
|
||||||
role: "assistant",
|
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 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,
|
topic: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -723,7 +744,11 @@ export default function NDFChatbot() {
|
|||||||
>✕</button>
|
>✕</button>
|
||||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 4, color: "#4f46e5" }}>Hello 👋</div>
|
||||||
<div style={{ fontSize: 11.5, lineHeight: 1.45, paddingRight: 12 }}>
|
<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>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>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", bottom: -8, right: 18,
|
position: "absolute", bottom: -8, right: 18,
|
||||||
@@ -780,6 +805,7 @@ export default function NDFChatbot() {
|
|||||||
padding: "13px 16px",
|
padding: "13px 16px",
|
||||||
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
|
display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 34, height: 34, borderRadius: "50%",
|
width: 34, height: 34, borderRadius: "50%",
|
||||||
background: "rgba(255,255,255,0.18)",
|
background: "rgba(255,255,255,0.18)",
|
||||||
@@ -788,6 +814,15 @@ export default function NDFChatbot() {
|
|||||||
}}>🤖</div>
|
}}>🤖</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ color: "#fff", fontSize: 13, fontWeight: 700 }}>Assistant NDF</div>
|
<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 }}>
|
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: 10, marginTop: 1 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
display: "inline-block", width: 5, height: 5, borderRadius: "50%",
|
||||||
@@ -894,7 +929,11 @@ export default function NDFChatbot() {
|
|||||||
textAlign: "center", fontSize: 9.5,
|
textAlign: "center", fontSize: 9.5,
|
||||||
color: "var(--text-muted,#94a3b8)",
|
color: "var(--text-muted,#94a3b8)",
|
||||||
}}>
|
}}>
|
||||||
|
<<<<<<< HEAD
|
||||||
Assistant NDF · ENSUP Group
|
Assistant NDF · ENSUP Group
|
||||||
|
=======
|
||||||
|
Emma · ENSUP Group
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ interface NouvelleNoteProps {
|
|||||||
dateDebutInitiale?: string;
|
dateDebutInitiale?: string;
|
||||||
commentaireInitial?: string;
|
commentaireInitial?: string;
|
||||||
depensesInitiales?: any[];
|
depensesInitiales?: any[];
|
||||||
|
onNavigateToProfil?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CONSTANTES ────────────────────────────────────────
|
// ── CONSTANTES ────────────────────────────────────────
|
||||||
@@ -207,6 +208,26 @@ function calcIndemnite(km: number, cv: number): number {
|
|||||||
return parseFloat((km * b.t3).toFixed(2));
|
return parseFloat((km * b.t3).toFixed(2));
|
||||||
}
|
}
|
||||||
function getTranche(km: number) { return km <= 0 ? 0 : km <= 5000 ? 1 : km <= 20000 ? 2 : 3; }
|
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 ─────────────────
|
// ── PROFIL VÉHICULE — interface partagée ─────────────────
|
||||||
interface ProfilVehiculeData {
|
interface ProfilVehiculeData {
|
||||||
@@ -1085,17 +1106,20 @@ function isRepasEvenementiel(libelle: string, description: string): boolean {
|
|||||||
|
|
||||||
// ── DEPENSE CARD ──────────────────────────────────────
|
// ── DEPENSE CARD ──────────────────────────────────────
|
||||||
const DepenseCard = React.memo(({
|
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;
|
depense: Depense; index: number; total: number; expanded: boolean;
|
||||||
onToggle: (id: number) => void;
|
onToggle: (id: number) => void;
|
||||||
onUpdate: (id: number, f: keyof Depense, v: any) => void;
|
onUpdate: (id: number, f: keyof Depense, v: any) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onGenerateQR?: (id: number) => void;
|
onGenerateQR?: (id: number) => void;
|
||||||
|
onNavigateToProfil?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
profilVehicule?: ProfilVehiculeData | null;
|
profilVehicule?: ProfilVehiculeData | null;
|
||||||
|
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const set = (f: keyof Depense, v: any) => onUpdate(depense.id, f, v);
|
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€
|
// Détection repas événementiel — désactive l'alerte 25€
|
||||||
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
const isEvenementiel = isRepas && isRepasEvenementiel(depense.libelle, depense.description);
|
||||||
|
<<<<<<< HEAD
|
||||||
const repasAlerte = isRepas && ttcParPersonne > 25 && !isEvenementiel;
|
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
|
// ✅ Calcul HT — gestion du cas MIXED
|
||||||
const htTotal = isKm
|
const htTotal = isKm
|
||||||
@@ -1240,7 +1281,13 @@ const DepenseCard = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
<div className="nn-field">
|
<div className="nn-field">
|
||||||
<label className="nn-fl">Date <span>*</span></label>
|
<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>
|
||||||
<div className="nn-field">
|
<div className="nn-field">
|
||||||
<label className="nn-fl">Libellé <span>*</span></label>
|
<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.
|
où vous choisirez le <em>« trajet le plus rapide »</em>, en favorisant les trajets sans section à péage.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
{profilVehicule ? (
|
{profilVehicule ? (
|
||||||
|
=======
|
||||||
|
{profilVehicule && (
|
||||||
|
>>>>>>> 78516cb (version_Rôle_President Version_Chatbot)
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", alignItems: "center", gap: 10,
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
background: "rgba(124,58,237,.07)", border: "1px solid rgba(124,58,237,.22)",
|
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>
|
<span style={{ fontSize: 18 }}>🚘</span>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: "#7c3aed" }}>
|
<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>
|
||||||
<div style={{ fontSize: 12, color: "#4b3f80" }}>
|
<div style={{ fontSize: 12, color: "#4b3f80" }}>
|
||||||
{profilVehicule.marque} {profilVehicule.modele}
|
{profilVehicule.marque} {profilVehicule.modele}
|
||||||
{profilVehicule.immatriculation && ` · ${profilVehicule.immatriculation}`}
|
{profilVehicule.immatriculation && ` · ${profilVehicule.immatriculation}`}
|
||||||
{" "}— <strong>{profilVehicule.chevaux} CV fiscaux</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</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 ↗
|
Modifier ↗
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</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-km-grid">
|
||||||
<div className="nn-field">
|
<div className="nn-field">
|
||||||
<label className="nn-fl" style={{ color: "#7c3aed" }}>Kilométrage <span>*</span></label>
|
<label className="nn-fl" style={{ color: "#7c3aed" }}>Kilométrage <span>*</span></label>
|
||||||
@@ -1686,8 +1736,9 @@ export default function NouvelleNote({
|
|||||||
initialBrouillonId = null,
|
initialBrouillonId = null,
|
||||||
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
libelleInitial = '', dateDebutInitiale = '', commentaireInitial = '',
|
||||||
depensesInitiales,
|
depensesInitiales,
|
||||||
|
onNavigateToProfil,
|
||||||
}: NouvelleNoteProps) {
|
}: NouvelleNoteProps) {
|
||||||
|
|
||||||
const initDepenses = (defaultCv = 7): Depense[] => {
|
const initDepenses = (defaultCv = 7): Depense[] => {
|
||||||
if (depensesInitiales && depensesInitiales.length > 0) {
|
if (depensesInitiales && depensesInitiales.length > 0) {
|
||||||
return depensesInitiales.map((l: any) => {
|
return depensesInitiales.map((l: any) => {
|
||||||
@@ -1963,11 +2014,20 @@ export default function NouvelleNote({
|
|||||||
setSubmitError("");
|
setSubmitError("");
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
if (!titre.trim()) { setSubmitError("Veuillez saisir un titre."); 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) {
|
for (const d of depenses) {
|
||||||
if (!d.date || !d.libelle.trim()) {
|
if (!d.date || !d.libelle.trim()) {
|
||||||
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
setSubmitError(`La dépense "${d.libelle || "sans libellé"}" doit avoir une date et un libellé.`);
|
||||||
setExpandedId(d.id); return;
|
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");
|
const isKmLine = d.categorie.toLowerCase().includes("kilom");
|
||||||
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
if (isKmLine && (!d.km || parseFloat(d.km) <= 0)) {
|
||||||
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
setSubmitError(`"${d.libelle}" doit avoir un kilométrage.`);
|
||||||
@@ -2174,7 +2234,14 @@ export default function NouvelleNote({
|
|||||||
</div>
|
</div>
|
||||||
<div className="nn-meta-field">
|
<div className="nn-meta-field">
|
||||||
<label>Date <span>*</span></label>
|
<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>
|
||||||
<div className="nn-meta-field">
|
<div className="nn-meta-field">
|
||||||
<label>Commentaire</label>
|
<label>Commentaire</label>
|
||||||
@@ -2191,13 +2258,15 @@ export default function NouvelleNote({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{depenses.map((d, i) => (
|
{depenses.map((d, i) => (
|
||||||
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
|
<DepenseCard key={d.id} depense={d} index={i} total={depenses.length}
|
||||||
expanded={expandedId === d.id}
|
expanded={expandedId === d.id}
|
||||||
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
onToggle={handleToggle} onUpdate={handleUpdateDepense}
|
||||||
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
onDelete={handleDeleteDepense} onGenerateQR={generateQRForDepense}
|
||||||
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule} />
|
disabled={submitting} apiBaseUrl={apiBaseUrl} profilVehicule={profilVehicule}
|
||||||
))}
|
onNavigateToProfil={onNavigateToProfil}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
<button className="nn-btn-add-card" onClick={handleAddDepense} disabled={submitting}>
|
||||||
+ Ajouter une dépense
|
+ Ajouter une dépense
|
||||||
|
|||||||
@@ -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