// © 2026 EHINON ARRIKO DESIRE EBENEZER — Tous droits réservés. Application « CVVR SODECI » conçue et développée par EHINON ARRIKO DESIRE EBENEZER. /* ═══════════════════════════════════════════════════════════ CVVR SODECI — frontend branché sur l'API Flask (backend/app.py) Scripts (React, Babel, SheetJS) chargés par index.html → pas d'import ici. ═══════════════════════════════════════════════════════════ */ const { useState, useRef, useMemo, useEffect } = React; // Identité de l'auteur (miroir de backend/auteur.py ; vérité technique : GET /api/about) const AUTEUR = "EADE", COPYRIGHT = "© 2026 EADE"; /* ═══════════════════════════════════════════════════════════ CLIENT API — le jeton vit dans sessionStorage (fermeture de l'onglet = déconnexion) ═══════════════════════════════════════════════════════════ */ const TOKEN_KEY = "gestifleet_token"; const getToken = () => { try{ return sessionStorage.getItem(TOKEN_KEY); }catch(e){ return null; } }; const setToken = t => { try{ t ? sessionStorage.setItem(TOKEN_KEY,t) : sessionStorage.removeItem(TOKEN_KEY); }catch(e){} }; const MSG_RESEAU = "Impossible de joindre le serveur. Vérifiez votre connexion internet puis réessayez."; // Message lisible quand le serveur ne renvoie pas de message d'erreur (page d'erreur de l'hébergeur, coupure…) const msgHttp = s => s===413 ? "Le fichier envoyé est trop volumineux." : s===429 ? "Trop de requêtes en peu de temps. Patientez quelques instants puis réessayez." : s===408||s===504 ? "Le serveur met trop de temps à répondre. Réessayez dans quelques instants." : s>=500 ? "Le serveur est momentanément indisponible. Réessayez dans quelques instants." : "La demande n'a pas pu aboutir (code "+s+"). Actualisez la page puis réessayez."; async function api(method, path, body) { const headers = {}; if(body!==undefined) headers["Content-Type"]="application/json"; const tok = getToken(); if(tok) headers.Authorization = "Bearer "+tok; let r; try{ r = await fetch("/api"+path,{method,headers,body:body!==undefined?JSON.stringify(body):undefined}); } catch(e){ throw new Error(MSG_RESEAU); } const j = await r.json().catch(()=>({})); if(!r.ok){ if(r.status===401 && tok && path!=="/auth/login") window.dispatchEvent(new Event("gestifleet:expired")); const e = new Error(j.error||msgHttp(r.status)); e.status=r.status; e.reason=j.reason; throw e; } return j; } const fmtDT = v => { if(!v) return "—"; const d=new Date(v); return isNaN(d)?String(v):d.toLocaleString("fr-FR",{dateStyle:"short",timeStyle:"short"}); }; // Acte d'attribution signé (PDF généré par le serveur) : ouverture/impression et téléchargement async function acteBlob(id) { const r = await fetch(`/api/resultats/${id}/acte.pdf`,{headers:{Authorization:"Bearer "+getToken()}}); if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||msgHttp(r.status)); } return r.blob(); } async function telechargerActe(id,mat,showToast) { try{ const a=document.createElement("a"); a.href=URL.createObjectURL(await acteBlob(id)); a.download=`Acte_attribution_${mat}.pdf`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),10000); }catch(e){ showToast("❌ "+e.message); } } async function ouvrirActe(id,mat,showToast) { // s'ouvre dans le lecteur PDF du navigateur (bouton Imprimer inclus) const w=window.open("","_blank"); if(!w){ telechargerActe(id,mat,showToast); return; } try{ w.location.href=URL.createObjectURL(await acteBlob(id)); } catch(e){ w.close(); showToast("❌ "+e.message); } } // Mot d'information pour un employé selon l'issue de sa candidature (null si véhicule attribué) const messageResultat = (res, camp) => { const lib = camp?.libelle||""; if(!res || res.statut==="attribue") return null; if(res.statut==="abandon") return {type:"warning",titre:"Véhicule abandonné",texte:`Vous avez abandonné le véhicule qui vous avait été attribué (campagne « ${lib} »).`}; if(res.statut==="en_attente"){ // Même message que la personne ait été retirée par la commission ou n'ait jamais obtenu de véhicule : // le motif d'une décision de la commission n'est pas communiqué au candidat. return {type:"info",titre:"Aucun véhicule attribué",texte:`Votre candidature à la campagne « ${lib} » était recevable, mais aucun véhicule n'a pu vous être attribué : ceux que vous aviez choisis ont été attribués à des candidats ayant plus d'ancienneté. Vous restez sur la liste d'attente : en cas de désistement, un véhicule peut vous être attribué automatiquement, selon l'ordre de mérite.`}; } // Seul le rejet pour ancienneté insuffisante (< 5 ans) est expliqué au candidat. Sanction ou acquisition récente : // jamais mentionnées (le serveur masque d'ailleurs ces motifs) — simple « aucun véhicule attribué ». if(res.statut==="rejete_anciennete") return {type:"danger",titre:"Candidature non retenue",texte:`Votre candidature à la campagne « ${lib} » n'a pas pu être retenue : ${res.motif_rejet||"ancienneté insuffisante (minimum 5 ans)"}.`}; if(res.statut==="non_attribue" || String(res.statut).startsWith("rejete")) return {type:"info",titre:"Aucun véhicule attribué",texte:`Aucun véhicule ne vous a été attribué à l'issue de la campagne « ${lib} ».`}; return null; }; // Résultats « attribué » d'un employé, toutes campagnes confondues (le plus récent d'abord) const mesAttributions = (state,mat) => state.resultats .filter(r=>cleanMat(r.matricule)===cleanMat(mat)&&r.valide&&r.statut==="attribue") .sort((a,b)=>b.campagne_id-a.campagne_id); async function apiForm(path, formData) { // envoi de fichier (multipart) let r; try{ r = await fetch("/api"+path,{method:"POST",headers:{Authorization:"Bearer "+getToken()},body:formData}); } catch(e){ throw new Error(MSG_RESEAU); } const j = await r.json().catch(()=>({})); if(!r.ok){ if(r.status===401) window.dispatchEvent(new Event("gestifleet:expired")); throw new Error(j.error||msgHttp(r.status)); } return j; } async function fichierBlob(url) { const r = await fetch(url,{headers:{Authorization:"Bearer "+getToken()}}); if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||msgHttp(r.status)); } return r.blob(); } async function ouvrirCourrier(id,showToast) { const w=window.open("","_blank"); try{ const url=URL.createObjectURL(await fichierBlob(`/api/abandons/${id}/courrier`)); if(w) w.location.href=url; else window.location.href=url; } catch(e){ if(w) w.close(); showToast("❌ "+e.message); } } async function telechargerCourrier(a,showToast) { try{ const l=document.createElement("a"); l.href=URL.createObjectURL(await fichierBlob(`/api/abandons/${a.id}/courrier`)); l.download=a.filename||"courrier.pdf"; document.body.appendChild(l); l.click(); l.remove(); setTimeout(()=>URL.revokeObjectURL(l.href),10000); }catch(e){ showToast("❌ "+e.message); } } const EMPTY_STATE = { users:[], employes:[], vehicules:[], sanctions:[], acquisitions:[], abandons:[], notifications:[], campagnes:[], demandes:[], resultats:[], inscriptions:[], connexions_log:[], raw:{} }; /* ═══════════════════════════════════════════════════════════ HELPERS ═══════════════════════════════════════════════════════════ */ // "AAAA-MM-JJ" (renvoyé par l'API) → Date locale, sans décalage de fuseau const toDate = v => { if(v instanceof Date) return v; const m=typeof v==="string"&&v.match(/^(\d{4})-(\d{2})-(\d{2})/); return m?new Date(+m[1],+m[2]-1,+m[3]):new Date(v); }; // Date Excel → "AAAA-MM-JJ" (arrondi au jour le plus proche : SheetJS peut décaler de quelques secondes) const dateIso = d => { const x=new Date(d.getTime()+12*3600*1000); return `${x.getFullYear()}-${String(x.getMonth()+1).padStart(2,"0")}-${String(x.getDate()).padStart(2,"0")}`; }; const jsonCell = v => v instanceof Date ? dateIso(v) : v; const jsonRows = rows => rows.map(r=>r.map(jsonCell)); const uid = () => Date.now() + Math.floor(Math.random()*99999); // simple identifiant local pour les lignes en prévisualisation const fmt = n => Number(n||0).toLocaleString("fr-FR")+" FCFA"; const fmtD = v => { if(!v) return "—"; const d=toDate(v); return isNaN(d)?String(v):d.toLocaleDateString("fr-FR"); }; const calcAnc = v => { if(!v) return 0; const d=toDate(v); return isNaN(d.getTime())?0:Math.max(0,Math.floor((Date.now()-d.getTime())/(365.25*86400000))); }; const mkPwd = dn => { if(!dn) return "sodeci2025"; const d=toDate(dn); return isNaN(d.getTime())?"sodeci2025":String(d.getDate()).padStart(2,"0")+String(d.getMonth()+1).padStart(2,"0")+d.getFullYear(); }; const parsePx = r => { const s=String(r||"").replace(/\xa0|\u00a0|\s/g,"").replace(/FCFA/gi,"").replace(/,/g,""); return parseFloat(s)||0; }; // Affichage propre d'une cellule Excel : // - Date → jj/mm/aaaa (ex: 27/12/1983) // - Nombre entier → affiché sans décimales (ex: 4, pas 4.00) // - Nombre décimal → 2 décimales max // - null/undefined/"" → "—" // - Tout le reste → String const fmtCell = v => { if(v instanceof Date) return fmtD(v); if(typeof v==="string"&&/^\d{4}-\d{2}-\d{2}$/.test(v)) return fmtD(v); // dates revenues de l'API if(v===null||v===undefined||v==="") return "—"; if(typeof v==="number"){ if(Number.isInteger(v)) return v.toLocaleString("fr-FR"); return v.toLocaleString("fr-FR",{maximumFractionDigits:2}); } return String(v); }; // Matricule : nettoie les * et espaces → uppercase const cleanMat = raw => String(raw||"").replace(/\*/g,"").trim().toUpperCase(); // Clé de jointure Employés → Comptabilité : // matricule SANS le préfixe "01" = Reference client dans Comptabilité // Ex : '012572*' → cleanMat → '012572' → matToRef → '2572' // (même règle que backend/rules.py : lettres retirées, préfixe 01 retiré, zéros de tête retirés) const matToRef = raw => cleanMat(raw).replace(/[A-Z]/g,"").replace(/^01/,"").replace(/^0+/,""); const normRef = raw => String(raw||"").replace(/\D/g,"").replace(/^0+/,""); // Trouver l'index d'une colonne (insensible à la casse) const ci = (hdrs,...names) => { for(const n of names){ const i=hdrs.findIndex(h=>h&&String(h).trim().toLowerCase()===n.toLowerCase()); if(i!==-1) return i; } return -1; }; /* ═══════════════════════════════════════════════════════════ DESIGN TOKENS ═══════════════════════════════════════════════════════════ */ const C = { navy:"#087f60", navy2:"#0a8a86", blue:"#009050", sky:"#4cc58a", sur:"#f3f4f1", wh:"#ffffff", brd:"#d8dcd6", txt:"#1c2a23", mut:"#5d6a62", grn:"#0b7a4b", ylw:"#b45309", red:"#b91c1c", pur:"#6d4aa8" }; /* ═══════════════════════════════════════════════════════════ COMPOSANTS UI RÉUTILISABLES ═══════════════════════════════════════════════════════════ */ /* ═══════════════════════════════════════════════════════════ LOGO CVVR SODECI — goutte d'eau (SODECI) et route en perspective (V de véhicule) ═══════════════════════════════════════════════════════════ */ const Logo = ({size=40,light=false}) => light ? ( // version claire pour les fonds colorés (menu, connexion) : goutte nacrée, route verte ) : ( ); /* ═══════════════════════════════════════════════════════════ ICÔNES (traits fins, une seule couleur) — remplacent les émojis ═══════════════════════════════════════════════════════════ */ const ICONS = { dashboard:<>, imports:<>, users:<>, journal:<>, campagnes:<>, candidatures:<>, demande:<>, resultats:<>, remise:<>, motdepasse:<>, statut:<>, historique:<>, signature:<>, abandon:<>, power:<>, check:<>, clock:<>, alert:<>, phone:<>, menu:<> }; const Icon = ({n,size=18,color="currentColor"}) => ( ); /* Effet « eau » : vagues animées au fond + bulles qui montent (parent : classe .water) */ const BULLES = [[8,10,9,0],[22,6,13,3],[38,14,11,6],[55,8,15,2],[70,12,10,8],[84,7,14,5],[92,9,12,10]]; const Water = ({h=70,bulles=true,rise="-90vh"}) => ( ); const BADGE_COLORS = { green:{bg:"#e3f1e8",t:"#14532d"}, yellow:{bg:"#fbf1d6",t:"#7a4a05"}, red:{bg:"#f8e1e1",t:"#8f1d1d"}, blue:{bg:"#dcebe1",t:"#0b5230"}, gray:{bg:"#e9ece7",t:"#3b4640"}, purple:{bg:"#ebe5f5",t:"#4c3480"} }; const Badge = ({color="gray",children}) => { const s=BADGE_COLORS[color]||BADGE_COLORS.gray; return {children}; }; const BtnColors = { primary:{bg:"#008a4f",t:"#fff"}, success:{bg:"#0b8a55",t:"#fff"}, warning:{bg:"#b45309",t:"#fff"}, danger:{bg:"#b91c1c",t:"#fff"}, ghost:{bg:"#eef0eb",t:"#2f3b34",b:"#d3d8d1"}, purple:{bg:"#4c3480",t:"#fff"} }; const Btn = ({color="primary",onClick,children,full,small,disabled}) => { const s=BtnColors[color]||BtnColors.primary; return ( ); }; const Card = ({children,style}) => (
{children}
); const Alert = ({type,children}) => { const s={info:["#eef3ee","#009050","#1c2a23"],success:["#e8f3ec","#0b7a4b","#14402b"], warning:["#fbf4e2","#b45309","#5b3a06"],danger:["#fbeaea","#b91c1c","#7a1d1d"]}[type]||["#eef3ee","#009050","#1c2a23"]; return
{children}
; }; const FInput = ({label,type="text",value,onChange,placeholder,required,hint,readOnly}) => (
{label && ( )} {hint &&

{hint}

}
); const FSel = ({label,value,onChange,options}) => (
{label && }
); const THead = ({cols,hide=[]}) => ( {cols.map((col,i)=>( {col} ))} ); function useIsMobile(bp=760) { const [m,setM] = useState(()=>window.innerWidth<=bp); useEffect(()=>{ const f = () => setM(window.innerWidth<=bp); window.addEventListener("resize",f); return ()=>window.removeEventListener("resize",f); },[bp]); return m; } function usePager(items, initial=10, resetKey="") { const [page,setPage] = useState(0); const [size,setSizeRaw] = useState(initial); useEffect(()=>{ setPage(0); },[resetKey,size]); const pages = Math.max(1,Math.ceil(items.length/size)); const p = Math.min(page,pages-1); return { slice:items.slice(p*size,p*size+size), page:p, pages, size, total:items.length, setPage, setSize:setSizeRaw }; } const Pager = ({pg,label="résultat(s)"}) => pg.total===0 ? null : (
pg.setPage(0)}>« pg.setPage(p=>p-1)}>← Préc. Page {pg.page+1} / {pg.pages} =pg.pages-1} onClick={()=>pg.setPage(p=>p+1)}>Suiv. → =pg.pages-1} onClick={()=>pg.setPage(pg.pages-1)}>» Afficher {pg.total} {label}
); // Conteneur de tableau : défilement horizontal ET vertical dans le cadre, barre du bas toujours visible const TableScroll = ({children,maxH="60vh"}) => (
{children}
); const TCell = ({children,mono,muted,className}) => ( {children} ); const Modal = ({show,onClose,title,children,width=520}) => { if(!show) return null; return (
e.stopPropagation()} style={{background:C.wh,borderRadius:4,padding:26,width,maxWidth:"100%",maxHeight:"90vh",overflowY:"auto",borderTop:`4px solid ${C.blue}`}}>
{title}
{children}
); }; const Toast = ({msg,show}) => { // Les messages commencent par un marqueur (réussite / erreur / alerte) : il devient la couleur de la barre latérale const m = String(msg||""); // (codes Unicode : ✅ = U+2705, ❌ = U+274C, ⚠ = U+26A0) const type = m.startsWith("❌") ? "err" : (m.startsWith("⚠") ? "warn" : "ok"); const text = m.replace(/^(❌|✅|⚠️?)\s*/,""); const col = {err:"#e05252",warn:"#e0a040",ok:C.sky}[type]; return (
{text}
); }; const Topbar = ({title,sub,action,mobile,onMenu}) => (
{mobile && ( )}
{title}
{sub && !mobile &&
{sub}
}
{!mobile && {new Date().toLocaleDateString("fr-FR")}} {action}
); /* ═══════════════════════════════════════════════════════════ MODAL VISUALISATION DONNÉES IMPORTÉES ═══════════════════════════════════════════════════════════ */ function DataViewModal({show,onClose,title,headers,rows}) { const [search,setSearch] = useState(""); const [page,setPage] = useState(0); const PER = 15; if(!show) return null; const filtered = rows.filter(row=> !search || row.some(v=>String(v||"").toLowerCase().includes(search.toLowerCase())) ); const pages = Math.ceil(filtered.length/PER); const slice = filtered.slice(page*PER, page*PER+PER); return (
e.stopPropagation()} style={{background:C.wh,borderRadius:8,width:"96vw",maxWidth:1100, maxHeight:"90vh",display:"flex",flexDirection:"column"}}> {/* Header */}
{title} {filtered.length} ligne(s){search?` sur ${rows.length}`:""} · {headers.length} colonne(s)
{setSearch(e.target.value);setPage(0);}} placeholder="Rechercher…" style={{border:`1px solid ${C.brd}`,borderRadius:4,padding:"6px 12px", fontSize:12,fontFamily:"inherit",outline:"none",width:200}}/>
{/* Table */}
{slice.length===0 && ( )} {slice.map((row,ri)=>( {row.map((v,cIdx)=>( {fmtCell(v)} ))} ))}
Aucun résultat
{/* Pagination */} {pages>1 && (
setPage(p=>p-1)}>← Préc. Page {page+1} / {pages} setPage(p=>p+1)}>Suiv. →
)}
); } /* ═══════════════════════════════════════════════════════════ MODAL TÉLÉPHONE (connexion employé) ═══════════════════════════════════════════════════════════ */ function PhoneModal({employe,onSave}) { const [tel,setTel] = useState(employe?.telephone||""); const hasPhone = !!(employe?.telephone); return (

Mise à jour du profil

{hasPhone ? "Vérifiez et mettez à jour votre numéro de téléphone." : "Renseignez votre numéro de téléphone pour compléter votre profil (obligatoire)."}

{employe && (
{[["Matricule",employe.matricule],["Nom & Prénom",`${employe.prenom} ${employe.nom}`], ["Emploi",employe.emploi||"—"],["Direction",employe.direction||"—"]].map(([l,v])=>(
{l} {v}
))}
)} setTel(e.target.value)} placeholder="Ex: 07 00 00 00 00" hint="Mobile ou fixe — format libre" required={!hasPhone}/>
onSave(tel)} disabled={!hasPhone && !tel.trim()} full> Enregistrer et continuer {hasPhone && ( onSave(employe.telephone)}> Passer )}
{!hasPhone && !tel.trim() && (

Le numéro de téléphone est obligatoire.

)}
); } /* ═══════════════════════════════════════════════════════════ LOGIN ═══════════════════════════════════════════════════════════ */ function LoginPage({onLogin,onInscription,notice}) { const [mat,setMat] = useState(""); const [pwd,setPwd] = useState(""); const [show,setShow] = useState(false); const [err,setErr] = useState(notice||""); const [busy,setBusy] = useState(false); const handle = async () => { if(busy) return; const m = cleanMat(mat); setBusy(true); try{ await onLogin(m,pwd); }catch(e){ if(e.reason==="unknown_matricule") onInscription(m); else setErr(e.message); }finally{ setBusy(false); } }; const champ = {width:"100%",background:"#fff",border:"1px solid #c6ccc4",borderRadius:4,color:C.txt, padding:"11px 12px",fontSize:15,fontFamily:"inherit",outline:"none",boxSizing:"border-box"}; return (
CVVR SODECI
Attribution des véhicules
Attribution des véhicules de la SODECI
Candidature, traitement des résultats, signature de l'acte et remise du véhicule : le suivi complet d'une campagne, au même endroit.
Direction Financière et Comptable

Connexion

Accédez à votre espace avec votre matricule.

{err && {err}} {setMat(e.target.value.toUpperCase());setErr("");}} onKeyDown={e=>e.key==="Enter"&&handle()} placeholder="Ex : 012572" autoComplete="username" style={{...champ,textTransform:"uppercase",marginBottom:16,fontFamily:"'IBM Plex Mono',ui-monospace,monospace"}}/>
{setPwd(e.target.value);setErr("");}} onKeyDown={e=>e.key==="Enter"&&handle()} placeholder="Ex : 27121983" autoComplete="current-password" style={{...champ,paddingRight:84}}/>

Matricule inconnu ? Une demande d'accès vous sera proposée, à valider par l'administrateur.

Application conçue et développée par {AUTEUR}
{COPYRIGHT}

); } /* ═══════════════════════════════════════════════════════════ INSCRIPTION (matricule inconnu) ═══════════════════════════════════════════════════════════ */ function InscriptionPage({matricule,onBack,onSubmit}) { const [f,setF] = useState({matricule,nom:"",prenom:"",date_naissance:"",telephone:"",email:"",emploi:"",direction:""}); const [err,setErr] = useState(""); const s = (k,v) => setF(p=>({...p,[k]:v})); const submit = async () => { if(!f.nom||!f.prenom||!f.date_naissance){ setErr("Nom, Prénom et Date de naissance sont obligatoires."); return; } try{ await onSubmit(f); }catch(e){ setErr(e.message); } }; return (
CVVR SODECI
Demande d'accès à la plateforme

Matricule {matricule} non trouvé dans la base

Renseignez vos informations. L'administrateur devra valider votre demande avant que vous puissiez postuler.

Formulaire de demande d'accès

{err && {err}}
s("matricule",e.target.value.toUpperCase())} required/>
s("nom",e.target.value.toUpperCase())} required/> s("prenom",e.target.value)} required/> s("date_naissance",e.target.value)} hint="Servira de mot de passe (JJMMAAAA)" required/> s("telephone",e.target.value)} placeholder="07 00 00 00 00"/>
s("email",e.target.value)} placeholder="prenom.nom@sodeci.ci"/>
s("emploi",e.target.value)}/> s("direction",e.target.value)}/>
L'administrateur sera notifié. Vous ne pourrez postuler qu'après validation.
Soumettre ma demande → ← Retour
); } /* ═══════════════════════════════════════════════════════════ SIDEBAR ═══════════════════════════════════════════════════════════ */ function Sidebar({user,mat,page,setPage,onLogout,badge,mobile,open,onClose}) { const menus = { admin:[ {id:"dashboard",l:"Tableau de bord",i:"⊞"}, {id:"imports",l:"Import XLSX",i:""}, {id:"users",l:"Utilisateurs & Rôles",i:"",b:badge}, {id:"journal",l:"Journal d'audit",i:""}, {id:"campagnes",l:"Campagnes",i:""}, {id:"candidatures",l:"Candidatures",i:""}, {id:"resultats",l:"Résultats",i:""}, {id:"remise",l:"Remise des véhicules",i:""}, {id:"motdepasse",l:"Mon mot de passe",i:""} ], commission:[ {id:"dashboard",l:"Tableau de bord",i:"⊞"}, {id:"candidatures",l:"Candidatures",i:""}, {id:"resultats",l:"Résultats",i:""}, {id:"remise",l:"Remise des véhicules",i:""}, {id:"campagnes",l:"Campagnes",i:""}, {id:"motdepasse",l:"Mon mot de passe",i:""} ], superviseur:[ {id:"dashboard",l:"Tableau de bord",i:"⊞"}, {id:"remise",l:"Remise des véhicules",i:""}, {id:"candidatures",l:"Candidatures",i:""}, {id:"resultats",l:"Résultats & actes",i:""}, {id:"campagnes",l:"Campagnes",i:""}, {id:"motdepasse",l:"Mon mot de passe",i:""} ], employe:[ {id:"dashboard",l:"Mon espace",i:"⊞"}, {id:"demande",l:"Ma demande",i:""}, {id:"statut",l:"Mon résultat",i:""}, {id:"historique",l:"Mon historique",i:""}, {id:"signature",l:"Signer mon acte",i:""}, {id:"abandon",l:"Abandon de véhicule",i:""}, {id:"motdepasse",l:"Mon mot de passe",i:""} ] }; return ( <> {mobile && open &&
}
{/* Logo */}
CVVR SODECI
Attribution des véhicules
{/* Nav */} {/* User */}
{(user.name||"A")[0]}
{user.name}
{mat} · {user.role}
Conçu et développé par {AUTEUR}
{COPYRIGHT}
); } /* ═══════════════════════════════════════════════════════════ PAGE IMPORT XLSX ═══════════════════════════════════════════════════════════ */ function ImportsPage({state,act,showToast}) { // ── Données en attente de validation (non encore commitées) ── const [pending,setPending] = useState({ employes:null, vehicules:null, sanctions:null, comptabilite:null }); // Campagne choisie pour les véhicules const [campVehId,setCampVehId] = useState(""); const [view,setView] = useState(null); const refEmp=useRef(); const refVeh=useRef(); const refSan=useRef(); const refCpt=useRef(); const fileRefs={employes:refEmp,vehicules:refVeh,sanctions:refSan,comptabilite:refCpt}; // ── Parse : lit le fichier → stocke dans pending (SANS committer) ── const parseFile=(file,type)=>{ setPending(p=>({...p,[type]:"loading"})); const reader=new FileReader(); reader.onload=e=>{ try{ // Lecture unique avec cellDates:true → les dates deviennent des objets Date JavaScript const wb=XLSX.read(e.target.result,{type:"binary",cellDates:true}); const ws=wb.Sheets[wb.SheetNames[0]]; // En-têtes : raw:false pour avoir les noms de colonnes en texte const hdrs=(XLSX.utils.sheet_to_json(ws,{header:1,defval:null,raw:false})[0])||[]; // Données : raw:true → dates = objets Date, nombres = Number, textes = String // Un seul parse pour la prévisualisation ET le traitement const dRows=XLSX.utils.sheet_to_json(ws,{header:1,defval:null,raw:true}) .slice(1).filter(r=>r.some(v=>v!==null&&v!=="")); const rawRows=dRows; // même données pour la visualisation (fmtD gère les Date) // Colonnes indispensables : sans elles, l'import afficherait « 0 ligne » sans explication const requises={employes:[["Matricule"]],vehicules:[["Immatriculations","Immatriculation"]], sanctions:[["Matricule"]],comptabilite:[["NUMERO DE COMPTE","Numero de compte"],["Reference client","Ref client"]]}[type]||[]; const manquantes=requises.filter(noms=>ci(hdrs,...noms)===-1).map(noms=>"« "+noms[0]+" »"); if(manquantes.length) throw new Error(`Colonne${manquantes.length>1?"s":""} introuvable${manquantes.length>1?"s":""} : ${manquantes.join(", ")}. Vérifiez que vous avez choisi le bon fichier et que la première ligne contient les titres des colonnes.`); if(dRows.length===0) throw new Error("Le fichier ne contient aucune ligne de données sous les titres de colonnes."); if(type==="employes"){ const iMat=ci(hdrs,"Matricule"),iNom=ci(hdrs,"Nom"),iPrn=ci(hdrs,"Prénom","Prenom"); const iSex=ci(hdrs,"Sexe"),iDdN=ci(hdrs,"Date de Naissance","Date Naissance"); const iDdE=ci(hdrs,"Date Entrée","Date Entree"),iAnc=ci(hdrs,"Anc"); const iNat=ci(hdrs,"Nationalité","Nationalite"),iCat=ci(hdrs,"Catégorie","Categorie"); const iCol=ci(hdrs,"Collège","College"),iEmp=ci(hdrs,"Emploi"); const iCI=ci(hdrs,"CI"),iDir=ci(hdrs,"Sigle Direction","Direction"); const parsed=[]; const newUsers=[]; dRows.forEach(r=>{ const mat=cleanMat(r[iMat]); if(!mat) return; const dn=r[iDdN],de=r[iDdE],anc=parseInt(r[iAnc])||calcAnc(de); const emp={id:uid(),matricule:mat,nom:String(r[iNom]||"").trim(),prenom:String(r[iPrn]||"").trim(), sexe:String(r[iSex]||"").trim(),date_naissance:dn,date_embauche:de,anciennete:anc, nationalite:String(r[iNat]||"").trim(),categorie:String(r[iCat]||"").trim(), college:String(r[iCol]||"").trim(),emploi:String(r[iEmp]||"").trim(), ci:r[iCI],direction:String(r[iDir]||"").trim(),telephone:""}; const dejaPresentE=state.employes.find(x=>x.matricule===mat); parsed.push({...emp,_action:dejaPresentE?"mise_a_jour":"nouveau"}); // Les comptes sont créés par le serveur (mot de passe = date de naissance JJMMAAAA, stocké haché) if(!state.users.find(u=>cleanMat(u.matricule)===mat)) newUsers.push({matricule:mat}); }); setPending(p=>({...p,employes:{parsed,newUsers,hdrs,rawRows, stats:{total:dRows.length,nouveaux:parsed.filter(x=>x._action==="nouveau").length, mises_a_jour:parsed.filter(x=>x._action==="mise_a_jour").length,comptes:newUsers.length}}})); } else if(type==="vehicules"){ const iNum=ci(hdrs,"N°","N"),iMrq=ci(hdrs,"Marques et Types","Marque"); const iImm=ci(hdrs,"Immatriculations","Immatriculation"); const iImmo=ci(hdrs,"N° Immo","Immo"),iPrix=ci(hdrs,"Prix de Vente TTC","Prix"); const iObs=ci(hdrs,"Observations"); const parsed=[]; const vus=new Set(); dRows.forEach(r=>{ const immat=String(r[iImm]||"").trim(); if(!immat||immat==="null") return; const dejaPresent=vus.has(immat); // doublon dans le fichier (le serveur remplace les véhicules de la campagne) vus.add(immat); parsed.push({id:uid(),numero:r[iNum],marque_type:String(r[iMrq]||"").trim(), immatriculation:immat,num_immo:r[iImmo],prix:parsePx(r[iPrix]), observations:String(r[iObs]||"").trim(), _action:dejaPresent?"doublon":"nouveau"}); }); const nouveaux=parsed.filter(x=>x._action==="nouveau"); setPending(p=>({...p,vehicules:{parsed:nouveaux,hdrs,rawRows, stats:{total:dRows.length,nouveaux:nouveaux.length,doublons:parsed.filter(x=>x._action==="doublon").length}}})); } else if(type==="sanctions"){ const iMat=ci(hdrs,"Matricule"),iNom=ci(hdrs,"Nom"),iPrn=ci(hdrs,"Prénoms","Prenoms","Prenom"); const iEmp=ci(hdrs,"Emploi"),iNat=ci(hdrs,"Nature de la sanction","Nature"); const iDur=ci(hdrs,"Durée de la sanction","Duree"),iAnn=ci(hdrs,"Année","Annee"); const iTyp=ci(hdrs,"Types de faute","Type faute"),iDir=ci(hdrs,"Direction"); const parsed=[]; dRows.forEach(r=>{ const mat=cleanMat(r[iMat]); if(!mat) return; parsed.push({id:uid(),matricule:mat,nom:String(r[iNom]||"").trim(), prenom:String(r[iPrn]||"").trim(),emploi:String(r[iEmp]||"").trim(), motif:String(r[iNat]||"").trim(),duree:String(r[iDur]||"").trim(), annee:r[iAnn],type_faute:String(r[iTyp]||"").trim(),direction:String(r[iDir]||"").trim()}); }); setPending(p=>({...p,sanctions:{parsed,hdrs,rawRows,stats:{total:dRows.length,importes:parsed.length}}})); } else if(type==="comptabilite"){ const iCpt=ci(hdrs,"NUMERO DE COMPTE","Numero de compte"); const iRefC=ci(hdrs,"Reference client","Ref client"); const iExpR=ci(hdrs,"Expli_Remarq","Expliq_Remarq","Remarq"); const iDate=ci(hdrs,"DATE COMPTABLE","Date comptable"); const iExpl=ci(hdrs,"Explication"),iMnt=ci(hdrs,"MONTANT","Montant"); const parsed=[]; let ignored=0; dRows.forEach(r=>{ const compte=String(r[iCpt]||"").trim(); if(!["471108","471130"].includes(compte)){ignored++;return;} const refC=r[iRefC]; if(!refC||refC===0||refC==="0"||refC===""){ignored++;return;} // Filtre ORD : on ne garde que les lignes sans "ORD" dans Expli_Remarq const expR=String(r[iExpR]||"").toUpperCase(); if(expR.includes("ORD")){ignored++;return;} const dateC=r[iDate]; const annee=dateC instanceof Date?dateC.getFullYear():(dateC?parseInt(String(dateC).substring(0,4)):new Date().getFullYear()); parsed.push({id:uid(),compte,reference_client:String(refC).trim(), expli_remarq:String(r[iExpR]||"").trim(),date_comptable:dateC, explication:String(r[iExpl]||"").trim(),montant:r[iMnt],annee_acquisition:annee}); }); setPending(p=>({...p,comptabilite:{parsed,hdrs,rawRows,stats:{total:dRows.length,retenus:parsed.length,ignores:ignored}}})); } }catch(err2){ setPending(p=>({...p,[type]:null})); showToast(`❌ Lecture du fichier impossible. ${/^(Colonne|Le fichier)/.test(err2.message)?err2.message:"Vérifiez qu'il s'agit bien d'un fichier Excel (.xlsx ou .xls) valide, non protégé par un mot de passe."}`); } }; reader.readAsBinaryString(file); }; // ── VALIDER : committer les données pending → state global ── const [busy,setBusy] = useState(false); const valider=async (type)=>{ const p=pending[type]; if(!p||!p.parsed||busy) return; const campId=campVehId?parseInt(campVehId):null; if(type==="vehicules"&&!campId){ showToast("❌ Veuillez sélectionner une campagne avant de valider les véhicules."); return; } // Dates Excel → "AAAA-MM-JJ" avant envoi ; le serveur valide et enregistre const ser=o=>Object.fromEntries(Object.entries(o).map(([k,v])=>[k,jsonCell(v)])); const msgs={ employes:r=>`✅ ${r.nouveaux} nouveaux · ${r.mises_a_jour} mis à jour · ${r.comptes} comptes créés`, vehicules:r=>`✅ ${r.importes} véhicules ajoutés pour la campagne sélectionnée${r.ignores?` (${r.ignores} ignorés : doublons ou déjà rattachés à une autre campagne)`:""}`, sanctions:r=>`✅ ${r.importes} sanctions chargées (liste actualisée)`, comptabilite:r=>`✅ ${r.retenus} lignes comptables retenues · ${p.stats.ignores} ignorées` }; setBusy(true); showToast("Import en cours… ne fermez pas la page"); const r=await act(api("POST",`/import/${type}`, {rows:p.parsed.map(ser),hdrs:p.hdrs,rawRows:jsonRows(p.rawRows),campagne_id:campId}),msgs[type]); setBusy(false); if(r) setPending(pp=>({...pp,[type]:null})); }; // ── Effacer les données commitées ── const effacer=async (type)=>{ const r=await act(api("DELETE",`/import/${type}`),r=>`Données "${type}" effacées${r.note?"\n"+r.note:""}`); if(r) setPending(pp=>({...pp,[type]:null})); }; // ── Voir les données importées (chargées à la demande) ── const voirRaw=async (cfg)=>{ try{ const raw=await api("GET",`/import/${cfg.id}/raw`); setView({title:`Données importées — ${cfg.label} (${raw.rows.length} lignes)`,headers:raw.headers,rows:raw.rows}); }catch(e){ showToast("❌ "+e.message); } }; const configs=[ {id:"employes", label:"Employés", icon:"",color:C.blue, colonnes:"Matricule | Nom | Prénom | Sexe | Date de Naissance | Date Entrée | Anc | Nationalité | Catégorie | Collège | Emploi | CI | Sigle Direction", note:"Données globales — remplace et actualise à chaque import. Crée les comptes automatiquement."}, {id:"vehicules", label:"Véhicules", icon:"",color:C.grn, colonnes:"N° | Marques et Types | Immatriculations | N° Immo | Prix de Vente TTC | Observations", note:"Spécifique à une campagne — vous devez choisir la campagne avant de valider."}, {id:"sanctions", label:"Sanctions disciplinaires", icon:"",color:C.red, colonnes:"Ordre | Matricule | Nom | Prénoms | Emploi | Genre | College | CI | Direction | Nature de la sanction | Durée | Année | Types de faute", note:"Données globales — l'import remplace toute la liste précédente."}, {id:"comptabilite",label:"Comptabilité (471108/471130)",icon:"",color:C.ylw, colonnes:"NUMERO DE COMPTE | DATE COMPTABLE | Explication | Expli_Remarq | Reference client | MONTANT | …", note:"Données globales — filtre : 471108/471130 · Reference client ≠ 0 · Expli_Remarq SANS 'ORD'."} ]; const counts={employes:state.employes.length,vehicules:state.vehicules.length,sanctions:state.sanctions.length,comptabilite:state.acquisitions.length}; return (
Ordre d'import : Employés → Véhicules (par campagne) → Comptabilité → Sanctions. Chaque import doit être prévisualisé puis validé pour être pris en compte.
{configs.map(cfg=>{ const pend=pending[cfg.id]; const isLoading=pend==="loading"; const hasPending=pend&&pend!=="loading"&&pend.parsed; const hasCommitted=counts[cfg.id]>0; return (
{/* En-tête */}
{cfg.icon}

{cfg.label}

{cfg.colonnes}

{cfg.note}

{/* Statut committed */} {hasCommitted && !hasPending && (
{counts[cfg.id]} ligne(s) en base {cfg.id==="vehicules" && ` — ${state.campagnes.map(c=>{const n=state.vehicules.filter(v=>v.campagne_id===c.id).length;return n?`${c.libelle} : ${n}`:null;}).filter(Boolean).join(" · ")}`}
{/* Bouton voir les données importées */} {state.raw[cfg.id==="comptabilite"?"comptabilite":cfg.id] && ( )}
)} {/* Zone upload */} {!hasPending && ( <> { if(e.target.files[0]){parseFile(e.target.files[0],cfg.id); e.target.value="";} }}/> )} {/* ═══ ZONE PRÉVISUALISATION + VALIDATION ═══ */} {hasPending && (
{/* Bandeau orange */}

En attente de validation

Prévisualisez les données ci-dessous puis validez pour les appliquer.

{/* Stats prévisualisation */}
{cfg.id==="employes" && (<> {pend.stats.total} lignes lues {pend.stats.nouveaux} nouveaux {pend.stats.mises_a_jour} mises à jour {pend.stats.comptes} comptes à créer )} {cfg.id==="vehicules" && (<> {pend.stats.total} lignes lues {pend.stats.nouveaux} à importer {pend.stats.doublons>0&&{pend.stats.doublons} doublons ignorés} )} {cfg.id==="sanctions" && {pend.stats.importes} sanctions sur {pend.stats.total} lignes} {cfg.id==="comptabilite" && (<> {pend.stats.retenus} retenues {pend.stats.ignores} ignorées (ORD/ref=0) )}
{/* Mini table preview (5 premières lignes) */}
{pend.hdrs.slice(0,6).map((h,i)=>( ))} {pend.hdrs.length>6&&} {pend.rawRows.slice(0,5).map((row,ri)=>( {row.slice(0,6).map((v,ci2)=>( ))} {row.length>6&&} ))}
{h||`Col ${i+1}`}+{pend.hdrs.length-6} col.
{fmtCell(v)} …
{/* Sélecteur campagne pour les véhicules */} {cfg.id==="vehicules" && (
{!state.campagnes.length && (

Aucune campagne créée. Créez d'abord une campagne dans le menu Campagnes.

)} {campVehId && (

{pend.stats.nouveaux} véhicules seront associés à : {state.campagnes.find(c=>c.id===parseInt(campVehId))?.libelle}

)}
)} {/* Bouton valider */}
valider(cfg.id)} disabled={busy||(cfg.id==="vehicules"&&!campVehId)} full> {busy?"Import en cours…":"Valider et appliquer cet import"} setPending(pp=>({...pp,[cfg.id]:null}))}>Annuler
)}
); })}
{view && ( setView(null)} title={view.title} headers={view.headers} rows={view.rows}/> )}
); } /* ═══════════════════════════════════════════════════════════ GESTION UTILISATEURS & RÔLES ═══════════════════════════════════════════════════════════ */ function UsersPage({state,act,showToast}) { const [search,setSearch] = useState(""); const [filterR,setFilterR] = useState("tous"); const [editMdl,setEditMdl] = useState(null); const [newMdl,setNewMdl] = useState(false); const [nu,setNu] = useState({matricule:"",name:"",role:"employe",password:""}); const [inscMdl,setInscMdl] = useState(null); const [motif,setMotif] = useState(""); const ROLES = ["employe","superviseur","commission","admin"]; const upd = (id,ch,ok) => act(api("PATCH",`/users/${id}`,ch),ok); const filtered = state.users.filter(u=>{ if(filterR!=="tous"&&u.role!==filterR) return false; const q=search.toLowerCase(); return !q||(u.name.toLowerCase().includes(q)||u.matricule.toLowerCase().includes(q)); }); const pg = usePager(filtered,10,`${search}|${filterR}`); // Le mot de passe n'est jamais relu depuis le serveur (stocké haché) : il n'est affiché qu'à la réinitialisation. const resetPwd = u => act(api("POST",`/users/${u.id}/reset-password`), r=>`Mot de passe réinitialisé\n${u.name} → ${r.password}`); const addUser = async () => { if(!nu.matricule||!nu.name){ showToast("❌ Le matricule et le nom sont obligatoires."); return; } const m=nu.matricule.toUpperCase(); const r=await act(api("POST","/users",{matricule:m,name:nu.name,role:nu.role,password:nu.password}),()=>`✅ Compte ${m} créé`); if(r){ setNewMdl(false); setNu({matricule:"",name:"",role:"employe",password:""}); } }; const validerInscription = async ins => { const r=await act(api("POST",`/inscriptions/${ins.id}/valider`), r=>`✅ Compte créé : ${ins.prenom} ${ins.nom}\nMot de passe : ${r.password}`); if(r) setInscMdl(null); }; const rejeterInscription = async ins => { if(!motif){ showToast("❌ Veuillez indiquer le motif."); return; } const r=await act(api("POST",`/inscriptions/${ins.id}/rejeter`,{motif}),()=>"❌ Demande rejetée"); if(r){ setInscMdl(null); setMotif(""); } }; const pending = state.inscriptions.filter(i=>i.statut==="en_attente"); return (
{/* Stats */}
{[ ["Total",state.users.length,"",""], ["Admins",state.users.filter(u=>u.role==="admin").length,"",C.blue], ["Commission",state.users.filter(u=>u.role==="commission").length,"",C.pur], ["Superviseurs",state.users.filter(u=>u.role==="superviseur").length,"",C.ylw], ["Employés",state.users.filter(u=>u.role==="employe").length,"",C.grn] ].map(([l,v,ic,col])=>(
{l} {ic}
{v}
))}
{/* Inscriptions en attente */} {pending.length>0 && (
{pending.length} demande(s) d'accès en attente
{pending.map(ins=>(

{ins.prenom} {ins.nom}

{ins.matricule}

{setInscMdl(ins);setMotif("");}}>Traiter
))}
)} {/* Barre outils */}
setSearch(e.target.value)} placeholder="Rechercher par nom ou matricule…" style={{flex:1,minWidth:200,border:`1px solid ${C.brd}`,borderRadius:4, padding:"8px 12px",fontSize:13,fontFamily:"inherit",outline:"none"}}/> setNewMdl(true)}>+ Créer un compte
{/* Tableau */} {filtered.length===0 && ( )} {pg.slice.map(u=>( {u.matricule}{u.name} {/* Changement de rôle inline */} {u.telephone||"—"}{u.est_actif?"Actif":"Inactif"}{u.connexions||0}
setEditMdl({...u,new_password:""})}>Modifier upd(u.id,{est_actif:!u.est_actif},()=>`${u.est_actif?"Désactivé":"Activé"} : ${u.name}`)}> {u.est_actif?"Désactiver":"Activer"} resetPwd(u)}>Réinitialiser {u.matricule!=="ADMIN001" && ( act(api("DELETE",`/users/${u.id}`),()=>"Compte supprimé")}>Supprimer )}
))}
Aucun utilisateur trouvé
{/* Modal édition */} setEditMdl(null)} title={`Modifier — ${editMdl?.name}`}> {editMdl && ( <> {}} readOnly/> setEditMdl(p=>({...p,name:e.target.value}))} required/> setEditMdl(p=>({...p,role:e.target.value}))} options={ROLES.map(r=>({v:r,l:r}))}/> setEditMdl(p=>({...p,telephone:e.target.value}))} placeholder="07 00 00 00 00"/> setEditMdl(p=>({...p,new_password:e.target.value}))}/>
setEditMdl(p=>({...p,est_actif:e.target.checked}))} id="ea"/>
{ const r=await upd(editMdl.id,{name:editMdl.name,role:editMdl.role,password:editMdl.new_password,est_actif:editMdl.est_actif,telephone:editMdl.telephone}, ()=>`✅ ${editMdl.name} mis à jour`); if(r) setEditMdl(null); }}>Enregistrer setEditMdl(null)}>Annuler
)}
{/* Modal nouveau compte */} setNewMdl(false)} title="Créer un compte manuellement"> setNu(p=>({...p,matricule:e.target.value.toUpperCase()}))} required/> setNu(p=>({...p,name:e.target.value}))} required/> setNu(p=>({...p,role:e.target.value}))} options={ROLES.map(r=>({v:r,l:r}))}/> setNu(p=>({...p,password:e.target.value}))} placeholder="Vide = sodeci2025" hint="Employés : date de naissance JJMMAAAA"/>
Créer setNewMdl(false)}>Annuler
{/* Modal inscription */} setInscMdl(null)} title={`Demande d'accès — ${inscMdl?.prenom} ${inscMdl?.nom}`}> {inscMdl && ( <>
{[["Matricule",inscMdl.matricule],["Nom & Prénom",`${inscMdl.prenom} ${inscMdl.nom}`], ["Date naissance",fmtD(inscMdl.date_naissance)],["Emploi",inscMdl.emploi||"—"], ["Direction",inscMdl.direction||"—"],["Téléphone",inscMdl.telephone||"—"]].map(([l,v])=>(
{l}{v}
))}
Si validé : mot de passe = {mkPwd(inscMdl.date_naissance)} (date de naissance) setMotif(e.target.value)} placeholder="Ex: Matricule non reconnu…"/>
validerInscription(inscMdl)} full>Valider & créer compte rejeterInscription(inscMdl)} disabled={!motif}>Rejeter
)}
); } /* ═══════════════════════════════════════════════════════════ CAMPAGNES ═══════════════════════════════════════════════════════════ */ function CampagnesPage({state,act,showToast,setPage,role}) { const [modal,setModal] = useState(false); const [f,setF] = useState({libelle:"",date_debut:"",date_fin:""}); const [delId,setDelId] = useState(null); const isAdmin = role==="admin"; // la commission consulte ; le serveur refuse de toute façon les autres const creer = async () => { if(!f.libelle||!f.date_debut||!f.date_fin){ showToast("❌ Renseignez le libellé, la date de début et la date de fin."); return; } const r = await act(api("POST","/campagnes",f),()=>"✅ Campagne créée"); if(r){ setModal(false); setF({libelle:"",date_debut:"",date_fin:""}); } }; const statut = (c,s,msg) => act(api("POST",`/campagnes/${c.id}/statut`,{statut:s}),()=>msg); const del = async id => { await act(api("DELETE",`/campagnes/${id}`),()=>"Campagne supprimée"); setDelId(null); }; // Éligibilité + attribution : calculées par le serveur (backend/rules.py) const traiter = async camp => { const r = await act(api("POST",`/campagnes/${camp.id}/traiter`), r=>`Traitement terminé ! ${r.attribues} attribués · ${r.rejetes} rejetés · ${r.en_attente} en attente`); if(r) setTimeout(()=>setPage("resultats"),1200); }; const SC={brouillon:"gray",ouverte:"green",cloturee:"yellow",traitee:"blue",validee:"purple"}; const SL={brouillon:"Brouillon",ouverte:"Ouverte",cloturee:"Clôturée",traitee:"Traitée",validee:"Validée"}; return (
{isAdmin && setModal(true)}>+ Nouvelle campagne}
{state.campagnes.length===0 && Aucune campagne. Cliquez sur "+ Nouvelle campagne" pour commencer.} {state.campagnes.length===0 && ( )} {state.campagnes.map(c=>{ const nbD=state.demandes.filter(d=>d.campagne_id===c.id&&!d.est_doublon).length; return ( {c.libelle}{c.date_debut}{c.date_fin}{nbD}{SL[c.statut]}
{isAdmin && c.statut==="brouillon" && ( <> statut(c,"ouverte","Campagne lancée !")}>Lancer setDelId(c.id)}>Supprimer )} {isAdmin && c.statut==="ouverte" && statut(c,"cloturee","Campagne clôturée")}>Clôturer} {isAdmin && c.statut==="cloturee" && traiter(c)}>Traiter} {["traitee","validee"].includes(c.statut) && setPage("resultats")}>Résultats}
); })}
Aucune campagne
{/* Modal création */} setModal(false)} title="Nouvelle campagne"> setF(p=>({...p,libelle:e.target.value}))} placeholder="Ex: Attribution Véhicules T3 2025" required/>
setF(p=>({...p,date_debut:e.target.value}))} required/> setF(p=>({...p,date_fin:e.target.value}))} required/>
Créer setModal(false)}>Annuler
{/* Modal confirmation suppression */} setDelId(null)} title="Confirmer la suppression" width={400}>

Êtes-vous sûr de vouloir supprimer cette campagne ?

del(delId)} full>Supprimer setDelId(null)}>Annuler
); } /* ═══════════════════════════════════════════════════════════ RÉSULTATS ═══════════════════════════════════════════════════════════ */ function ResultatsPage({state,act,showToast,role}) { const [camId,setCamId] = useState(""); const [tab,setTab] = useState("attribues"); const [showBackups,setShowBackups] = useState(false); // afficher les 3 candidats suivants const [q,setQ] = useState(""); const [reMdl,setReMdl] = useState(null); // bénéficiaire dont on réattribue le véhicule const [reSel,setReSel] = useState(""); const [reMotif,setReMotif] = useState(""); const [reBusy,setReBusy] = useState(false); const traitees = state.campagnes.filter(c=>["traitee","validee"].includes(c.statut)); const camp = traitees.find(c=>c.id===parseInt(camId))||traitees[0]; const res = camp ? state.resultats.filter(r=>r.campagne_id===camp.id) : []; const attribues = res.filter(r=>r.statut==="attribue").sort((a,b)=>a.rang-b.rang); const enAttente = res.filter(r=>r.statut==="en_attente"); const rejetes = res.filter(r=>r.statut.startsWith("rejete")); const getEmp = m => state.employes.find(e=>cleanMat(e.matricule)===cleanMat(m)); const getVeh = id => state.vehicules.find(v=>v.id===id); const getTel = m => { const emp=state.employes.find(e=>cleanMat(e.matricule)===cleanMat(m)); const usr=state.users.find(u=>cleanMat(u.matricule)===cleanMat(m)); return emp?.telephone||usr?.telephone||"—"; }; // ── 3 candidats suivants par véhicule ────────────────────── // Pour chaque véhicule attribué : les 3 prochains éligibles les plus anciens // qui avaient ce véhicule dans leurs choix (excl. le bénéficiaire attribué) // Candidats à un véhicule = éligibles SANS véhicule (liste « En attente »), triés comme l'attribution initiale // (ancienneté décroissante, puis ordre de dépôt). Ceux qui ont choisi ce véhicule passent en premier ; // les personnes déjà retirées de ce véhicule par la commission sont exclues. // Même règle que backend/app.py::suivants — le serveur revérifie à chaque réattribution. const candidatsPour = (vehicule_id, camp_id, hors_mat) => { if(!camp_id) return []; const dems = {}; state.demandes.filter(d=>d.campagne_id===camp_id&&!d.est_doublon).forEach(d=>{ dems[cleanMat(d.matricule)]=d; }); return state.resultats .filter(r=>r.campagne_id===camp_id&&r.statut==="en_attente"&&cleanMat(r.matricule)!==cleanMat(hors_mat)) .filter(r=>{ try{ return !JSON.parse(r.exclu_vehicules||"[]").includes(vehicule_id); }catch(e){ return true; } }) .map(r=>{ const mat=cleanMat(r.matricule), d=dems[mat], emp=getEmp(r.matricule); const rangChoix = d ? [d.choix_1,d.choix_2,d.choix_3].indexOf(vehicule_id)+1 : 0; return { matricule:r.matricule, nom:emp?.nom||"—", prenom:emp?.prenom||"", emploi:emp?.emploi||"—", direction:emp?.direction||"—", telephone:getTel(r.matricule), anciennete:r.anciennete||0, choisi:rangChoix>0, rangChoix, depot:d?.id||0 }; }) .sort((a,b)=>(b.choisi-a.choisi)||(b.anciennete-a.anciennete)||(a.depot-b.depot)); }; // Les 3 suivants affichés sous chaque bénéficiaire : recalculés après chaque réattribution const getBackups = (vehicule_id, camp_id, beneficiaire_mat) => candidatsPour(vehicule_id, camp_id, beneficiaire_mat).filter(c=>c.choisi).slice(0,3); // Bénéficiaires triés par N° de véhicule, filtrés par la recherche (véhicule, matricule, nom) const vnum = v => parseInt(String(v?.numero||"0").replace(/\D/g,""))||0; const attribuesF = attribues .filter(r=>{ if(!q.trim()) return true; const e=getEmp(r.matricule), v=getVeh(r.vehicule_id); return [r.matricule,e?.nom,e?.prenom,v?.numero,v?.marque_type,v?.immatriculation].some(x=>String(x||"").toLowerCase().includes(q.trim().toLowerCase())); }) .sort((a,b)=>vnum(getVeh(a.vehicule_id))-vnum(getVeh(b.vehicule_id))||a.rang-b.rang); const pgAttr = usePager(attribuesF,10,`${camp?.id}|${q}`); const pgRej = usePager(rejetes,10,camp?.id); const pgAtt = usePager(enAttente,10,camp?.id); const ouvrirReattribution = (r, preselection="") => { setReMdl(r); setReSel(preselection); setReMotif(""); }; const reattribuer = async () => { if(reBusy) return; setReBusy(true); const r = await act(api("POST",`/resultats/${reMdl.id}/reattribuer`,{nouveau_matricule:reSel,motif:reMotif}), r=>"✅ Véhicule réattribué — enregistré dans le journal"+(r.conforme_algorithme?"":"\nHors ordre de l'algorithme (signalé)")); setReBusy(false); if(r) setReMdl(null); }; const peutReattribuer = role==="admin"||role==="commission"; // ── Export PowerPoint (chargement CDN pptxgenjs) ────────── const exportPPT = async () => { if(!camp){ showToast("❌ Aucune campagne sélectionnée."); return; } showToast("Génération du PowerPoint en cours…"); try { // Charger pptxgenjs depuis CDN si pas encore disponible if(!window.PptxGenJS) { await new Promise((resolve,reject)=>{ const s=document.createElement("script"); s.src="/vendor/pptxgen.js"; s.onload=resolve; s.onerror=()=>reject(new Error("Une bibliothèque externe n'a pas pu être chargée. Vérifiez votre connexion internet.")); document.head.appendChild(s); }); } const PG = window.PptxGenJS; const pptx = new PG(); pptx.layout="LAYOUT_WIDE"; pptx.author="SODECI DFC"; pptx.title=`Rapport ${camp.libelle}`; const NAVY="0b2b1d",BLUE="009050",SKY="57d08c",WHITE="FFFFFF", GREEN="059669",RED="dc2626",YELLOW="d97706",LGRAY="f3f4f1"; const total=res.length; const txPartic=state.employes.length>0?Math.round(res.length/state.employes.length*100):0; const txAttrib=total>0?Math.round(attribues.length/total*100):0; const txRejet=total>0?Math.round(rejetes.length/total*100):0; // ── Slide 1 : Couverture ── const s1=pptx.addSlide(); s1.background={color:NAVY}; s1.addShape(pptx.ShapeType.rect,{x:0,y:0,w:0.08,h:7.5,fill:{color:BLUE},line:{type:"none"}}); s1.addText("SODECI",{x:0.4,y:0.8,w:9,h:1.1,fontSize:64,bold:true,color:WHITE,fontFace:"Arial"}); s1.addText("Société de Distribution d'Eau de Côte d'Ivoire",{x:0.4,y:1.9,w:9,h:0.4,fontSize:15,color:"9be0ba",fontFace:"Arial",italic:true}); s1.addShape(pptx.ShapeType.rect,{x:0.4,y:2.45,w:6,h:0.04,fill:{color:SKY},line:{type:"none"}}); s1.addText("RAPPORT DE CAMPAGNE",{x:0.4,y:2.65,w:9,h:0.55,fontSize:24,bold:true,color:SKY,fontFace:"Arial"}); s1.addText(camp.libelle,{x:0.4,y:3.25,w:9,h:0.7,fontSize:30,bold:true,color:WHITE,fontFace:"Arial"}); s1.addText(`${camp.date_debut} → ${camp.date_fin}`,{x:0.4,y:4.05,w:6,h:0.4,fontSize:16,color:"9be0ba",fontFace:"Arial"}); s1.addText(`Direction Financière et Comptable — Généré le ${new Date().toLocaleDateString("fr-FR")}`,{x:0.4,y:6.2,w:9,h:0.35,fontSize:12,color:"475569",fontFace:"Arial"}); // ── Slide 2 : Synthèse chiffrée ── const s2=pptx.addSlide(); s2.background={color:LGRAY}; s2.addShape(pptx.ShapeType.rect,{x:0,y:0,w:"100%",h:1.1,fill:{color:NAVY},line:{type:"none"}}); s2.addText("SYNTHÈSE CHIFFRÉE",{x:0.4,y:0.1,w:9,h:0.5,fontSize:22,bold:true,color:SKY,fontFace:"Arial"}); s2.addText(`${camp.libelle} | ${camp.date_debut} → ${camp.date_fin}`,{x:0.4,y:0.65,w:9,h:0.35,fontSize:13,color:"9be0ba",fontFace:"Arial"}); const kpis=[["Demandes\nreçues",total,BLUE,"dcebe1"],["Dossiers\nattribués",attribues.length,GREEN,"DCFCE7"],["Dossiers\nrejetés",rejetes.length,RED,"FEE2E2"],["En attente\nde véhicule",enAttente.length,YELLOW,"FEF9C3"]]; kpis.forEach(([label,val,col,bg],i)=>{ const x=0.3+i*2.85, y=1.4; s2.addShape(pptx.ShapeType.roundRect,{x,y,w:2.55,h:4,fill:{color:bg},line:{color:"d8dcd6",pt:1},rounding:0.15}); s2.addText(String(val),{x,y:y+0.4,w:2.55,h:1.5,fontSize:54,bold:true,color:col,fontFace:"Arial",align:"center"}); s2.addText(label,{x,y:y+2.1,w:2.55,h:0.8,fontSize:13,color:"374151",fontFace:"Arial",align:"center"}); }); // ── Slide 3 : Motifs de rejet ── const s3=pptx.addSlide(); s3.addShape(pptx.ShapeType.rect,{x:0,y:0,w:"100%",h:1.1,fill:{color:NAVY},line:{type:"none"}}); s3.addText("ANALYSE DES REJETS",{x:0.4,y:0.1,w:9,h:0.5,fontSize:22,bold:true,color:SKY,fontFace:"Arial"}); s3.addText(`${rejetes.length} dossier(s) non retenus sur ${total} demandes`,{x:0.4,y:0.65,w:9,h:0.35,fontSize:13,color:"9be0ba",fontFace:"Arial"}); const rjTypes=[["Ancienneté\ninsuffisante",res.filter(r=>r.statut==="rejete_anciennete").length,RED],["Acquisition\nrécente (< 5 ans)",res.filter(r=>r.statut==="rejete_acquisition").length,YELLOW],["Sanction\ndisciplinaire",res.filter(r=>r.statut==="rejete_sanction").length,"7c3aed"]]; const maxRj=Math.max(...rjTypes.map(([,v])=>v),1); rjTypes.forEach(([label,val,col],i)=>{ const x=0.5+i*3.5; s3.addText(String(val),{x,y:1.3,w:3.0,h:1.1,fontSize:52,bold:true,color:col,fontFace:"Arial",align:"center"}); const bh=maxRj>0?(val/maxRj)*2.5:0.1; s3.addShape(pptx.ShapeType.rect,{x:x+0.3,y:5.2-bh,w:2.3,h:bh,fill:{color:col},line:{type:"none"}}); s3.addText(label,{x,y:5.3,w:3.0,h:0.8,fontSize:12,color:"374151",fontFace:"Arial",align:"center"}); }); // ── Slide 4 : Bénéficiaires ── const s4=pptx.addSlide(); s4.addShape(pptx.ShapeType.rect,{x:0,y:0,w:"100%",h:1.1,fill:{color:NAVY},line:{type:"none"}}); s4.addText("BÉNÉFICIAIRES",{x:0.4,y:0.1,w:9,h:0.5,fontSize:22,bold:true,color:SKY,fontFace:"Arial"}); s4.addText(`${attribues.length} véhicule(s) attribué(s) — classés par ancienneté décroissante`,{x:0.4,y:0.65,w:9,h:0.35,fontSize:13,color:"9be0ba",fontFace:"Arial"}); const tableRows=[ [{text:"Rang",options:{bold:true,color:WHITE}},{text:"Matricule",options:{bold:true,color:WHITE}},{text:"Nom & Prénom",options:{bold:true,color:WHITE}},{text:"Téléphone",options:{bold:true,color:WHITE}},{text:"Ancienneté",options:{bold:true,color:WHITE}},{text:"Véhicule",options:{bold:true,color:WHITE}},{text:"Prix TTC",options:{bold:true,color:WHITE}}], ...attribues.slice(0,8).map(r=>{ const e=getEmp(r.matricule),v=getVeh(r.vehicule_id); return [{text:`#${r.rang}`},{text:r.matricule},{text:e?`${e.prenom} ${e.nom}`:r.matricule},{text:getTel(r.matricule)},{text:`${r.anciennete} ans`},{text:v?`${v.numero?"N°"+v.numero+" - ":""}${v.marque_type}`:"—"},{text:v?Number(v.prix).toLocaleString("fr-FR")+" FCFA":"—"}]; }) ]; s4.addTable(tableRows,{x:0.3,y:1.2,w:10.1,h:5.5,fontSize:11,fontFace:"Arial",border:{type:"solid",color:"d8dcd6",pt:0.5},fill:{color:WHITE},firstRowFill:NAVY,color:"374151",rowH:0.55,autoPage:false,headerRow:true}); // ── Slide 5 : Indicateurs ── const s5=pptx.addSlide(); s5.background={color:LGRAY}; s5.addShape(pptx.ShapeType.rect,{x:0,y:0,w:"100%",h:1.1,fill:{color:NAVY},line:{type:"none"}}); s5.addText("INDICATEURS DE PERFORMANCE",{x:0.4,y:0.1,w:9,h:0.5,fontSize:22,bold:true,color:SKY,fontFace:"Arial"}); const indics=[["Participation",`${res.length} / ${state.employes.length}`,txPartic+"%",BLUE],["Attribution",`${attribues.length} / ${total}`,txAttrib+"%",GREEN],["Rejet",`${rejetes.length} / ${total}`,txRejet+"%",RED]]; indics.forEach(([icon,detail,pct,col],i)=>{ const x=0.4+i*3.6; s5.addShape(pptx.ShapeType.roundRect,{x,y:1.4,w:3.2,h:4.5,fill:{color:WHITE},line:{color:"d8dcd6",pt:1},rounding:0.15}); s5.addText(icon,{x,y:1.6,w:3.2,h:0.6,fontSize:18,color:col,fontFace:"Arial",align:"center"}); s5.addText(pct,{x,y:2.3,w:3.2,h:1.4,fontSize:52,bold:true,color:col,fontFace:"Arial",align:"center"}); s5.addText(detail,{x,y:3.9,w:3.2,h:0.5,fontSize:14,color:"64748b",fontFace:"Arial",align:"center"}); }); // ── Slide 6 : Conclusion ── const s6=pptx.addSlide(); s6.background={color:NAVY}; s6.addText("PROCHAINES ÉTAPES",{x:0.5,y:1.0,w:10,h:0.8,fontSize:34,bold:true,color:SKY,fontFace:"Arial"}); s6.addShape(pptx.ShapeType.rect,{x:0.5,y:2.0,w:4,h:0.04,fill:{color:SKY},line:{type:"none"}}); ["Signature des actes d'attribution par les bénéficiaires","Remise physique des véhicules aux bénéficiaires","Archivage complet du dossier de campagne","Planification de la prochaine campagne d'attribution"].forEach((step,i)=>{ s6.addText(`→ ${step}`,{x:0.5,y:2.3+i*0.9,w:10,h:0.7,fontSize:16,color:"e2e8f0",fontFace:"Arial"}); }); s6.addText(`SODECI — Direction Financière et Comptable — ${new Date().toLocaleDateString("fr-FR")}`,{x:0.5,y:6.8,w:10,h:0.35,fontSize:11,color:"475569",fontFace:"Arial"}); await pptx.writeFile({fileName:`rapport_${camp.libelle.replace(/[^a-z0-9]/gi,"_")}.pptx`}); showToast("PowerPoint téléchargé !\n6 slides : couverture, synthèse, rejets, bénéficiaires, indicateurs, conclusion"); } catch(err) { showToast("❌ L'export PowerPoint a échoué. Vérifiez votre connexion internet puis réessayez."); } }; const valider = () => { if(!camp) return; // Les employés voient leur résultat dès la validation (les e-mails de notification sont prévus en v7) act(api("POST",`/campagnes/${camp.id}/valider`),()=>"✅ Résultats validés par la commission !\nLes participants peuvent consulter leur résultat."); }; // ── Export Excel réel ───────────────────────────────────── const exportExcel = () => { if(!camp||attribues.length===0){ showToast("❌ Aucun résultat à exporter."); return; } const dataBen = attribues.map(r=>{ const e=getEmp(r.matricule); const v=getVeh(r.vehicule_id); return {"Rang":r.rang,"Matricule":r.matricule,"Nom":e?.nom||"","Prénom":e?.prenom||"", "Téléphone":getTel(r.matricule),"Emploi":e?.emploi||"","Direction":e?.direction||"", "Ancienneté (ans)":r.anciennete,"N° Véhicule":v?.numero||"","Marque/Type":v?.marque_type||"", "Immatriculation":v?.immatriculation||"","Prix TTC (FCFA)":v?.prix||0}; }); const dataRej = rejetes.map(r=>{ const e=getEmp(r.matricule); return {"Matricule":r.matricule,"Nom":e?.nom||"","Prénom":e?.prenom||"", "Téléphone":getTel(r.matricule),"Emploi":e?.emploi||"","Ancienneté (ans)":r.anciennete||"","Motif":r.motif_rejet||""}; }); const wsBen = XLSX.utils.json_to_sheet(dataBen); const wsRej = XLSX.utils.json_to_sheet(dataRej.length?dataRej:[{"Info":"Aucun rejet"}]); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, wsBen, "Bénéficiaires"); XLSX.utils.book_append_sheet(wb, wsRej, "Rejetés"); // XLSX.writeFile gère le téléchargement nativement (plus fiable que a.click) try { XLSX.writeFile(wb, `resultats_${camp.libelle.replace(/[^a-z0-9]/gi,"_")}.xlsx`); showToast("Fichier Excel téléchargé !"); } catch(e) { // Fallback : blob + data URL const buf = XLSX.write(wb, {type:"base64", bookType:"xlsx"}); const link = document.createElement("a"); link.href = `data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,${buf}`; link.download = `resultats_${camp.libelle.replace(/[^a-z0-9]/gi,"_")}.xlsx`; link.click(); showToast("Fichier Excel téléchargé !"); } }; // ── Export PDF (impression) ──────────────────────────────── const exportPDF = () => { if(!camp){ showToast("❌ Aucune campagne sélectionnée."); return; } const lignesBen = attribues.map(r=>{ const e=getEmp(r.matricule); const v=getVeh(r.vehicule_id); return `#${r.rang}${r.matricule}${e?e.prenom+" "+e.nom:r.matricule} ${getTel(r.matricule)}${e?.emploi||"—"}${r.anciennete} ans ${v?.numero?"N°"+v.numero+" — ":""}${v?.marque_type||"—"} ${v?.immatriculation||"—"}${v?Number(v.prix).toLocaleString("fr-FR")+" FCFA":"—"}`; }).join(""); const lignesRej = rejetes.map(r=>{ const e=getEmp(r.matricule); return `${r.matricule}${e?e.prenom+" "+e.nom:r.matricule} ${e?.emploi||"—"}${r.anciennete||"—"} ans${r.motif_rejet||"—"}`; }).join(""); const html=` Résultats — ${camp.libelle}

SODECI — Résultats Attribution Véhicules

Campagne : ${camp.libelle}  |  Période : ${camp.date_debut} → ${camp.date_fin}  |  Date : ${new Date().toLocaleDateString("fr-FR")}

Bénéficiaires (${attribues.length})

${lignesBen||""}
RangMatriculeNom & PrénomTéléphoneEmploiAnciennetéVéhiculeImmat.Prix
Aucun bénéficiaire
${rejetes.length>0?`

Dossiers rejetés (${rejetes.length})

${lignesRej}
MatriculeNom & PrénomEmploiAnciennetéMotif
`:""} `; // Blob URL → ouverture dans nouvel onglet → impression automatique const blob = new Blob([html], {type:"text/html;charset=utf-8"}); const url = URL.createObjectURL(blob); const win = window.open(url, "_blank"); if(win){ win.focus(); showToast("PDF ouvert — utilisez Ctrl+P ou le menu Impression"); } else { // Fallback si popup bloquée : téléchargement HTML const a = document.createElement("a"); a.href = url; a.download = `resultats_${camp.libelle.replace(/[^a-z0-9]/gi,"_")}.html`; a.click(); showToast("Fichier téléchargé (ouvrez-le dans votre navigateur pour imprimer)"); } setTimeout(()=>URL.revokeObjectURL(url), 5000); }; if(traitees.length===0) { return
Aucun résultat disponible. Traitez d'abord une campagne clôturée.
; } return (
{/* Sélecteur campagne + actions */}
Excel PDF PowerPoint {(role==="admin"||role==="commission") && camp?.statut==="traitee" && Valider (Commission)} {camp?.statut==="validee" && Validé par la commission}
{/* Stats */}
{[ ["Attribués",attribues.length,C.grn,"#eef5ef","#c3dccb"], ["Abandons",res.filter(r=>r.statut==="abandon").length,"#9a3412","#fff7ed","#fed7aa"], ["Actes signés",`${attribues.filter(r=>r.signe).length} / ${attribues.length}`,C.blue,"#eef3ee","#c3dccb"], ["En attente",enAttente.length,C.ylw,"#fbf4e2","#fde68a"], ["Rej. Ancienneté",res.filter(r=>r.statut==="rejete_anciennete").length,C.red,"#fef2f2","#fecaca"], ["Rej. Acquisition",res.filter(r=>r.statut==="rejete_acquisition").length,C.red,"#fef2f2","#fecaca"], ["Rej. Sanction",res.filter(r=>r.statut==="rejete_sanction").length,C.red,"#fef2f2","#fecaca"] ].map(([l,v,col,bg,br])=>(

{l}

{v}

))}
{/* Onglets */}
{[["attribues","Bénéficiaires"],["abandons",`Abandons (${res.filter(r=>r.statut==="abandon").length})`],["rejetes","Rejetés"],["attente","En attente"]].map(([id,l])=>( ))}
{tab==="attribues" && attribues.length>0 && ( )}
{tab==="attribues" && ( <>
setQ(e.target.value)} placeholder="Véhicule, matricule ou nom…" style={{border:`1px solid ${C.brd}`,borderRadius:4,padding:"7px 12px",fontSize:12,fontFamily:"inherit",outline:"none",width:280}}/> Classé par n° de véhicule · chaque véhicule est suivi de son attributaire
{pgAttr.total===0 && } {pgAttr.slice.map(r=>{ const emp=getEmp(r.matricule); const veh=getVeh(r.vehicule_id); const backups = showBackups ? getBackups(r.vehicule_id, camp.id, r.matricule) : []; const backupStyles=[ {bg:"#fff7ed",fg:"#ea580c",label:"②"}, {bg:"#f5f3ff",fg:"#7c3aed",label:"③"}, {bg:"#f0f9ff",fg:"#0369a1",label:"④"}, ]; const cell = {padding:"9px 12px",borderBottom:`1px solid ${C.brd}`,fontSize:12,whiteSpace:"nowrap"}; return ( {/* ① Véhicule puis attributaire */} {showBackups && } {/* ② ③ ④ Candidats suivants : le véhicule est le même, seul le candidat change */} {showBackups && backups.map((bk,bi)=>{ const {bg,fg,label}=backupStyles[bi]; const c2 = {...cell,background:bg,borderBottom:"1px solid #e8ece7"}; return ( ); })} {showBackups && backups.length===0 && ( )} ); })}
Aucun bénéficiaire
①{veh?.numero||"—"} {veh?.marque_type||"—"} {veh?.immatriculation||"—"} {veh?fmt(veh.prix):"—"} #{r.rang} {r.matricule} {emp?`${emp.prenom} ${emp.nom}`:r.matricule} {getTel(r.matricule)} {emp?.emploi||"—"} {emp?.direction||"—"} {r.anciennete} ans {r.signe ? (
{fmtDT(r.signed_at)} ouvrirActe(r.id,r.matricule,showToast)}>Voir / Imprimer telechargerActe(r.id,r.matricule,showToast)}>PDF
) : Non signé}
{peutReattribuer && !r.signe && !r.achat_valide && ouvrirReattribution(r)}>Réattribuer}
{label} Prend ce véhicule si {backupStyles.slice(0,bi).map(x=>x.label).join(", ")||"①"} {bi===0?"est refusé":"sont refusés"} — {bk.matricule} {bk.prenom} {bk.nom} {bk.telephone||"—"} {bk.emploi||"—"} {bk.direction||"—"} {bk.anciennete} ans {peutReattribuer && !r.signe && !r.achat_valide && ouvrirReattribution(r,bk.matricule)}>Attribuer à {bk.prenom}}
Aucun autre candidat éligible n'avait choisi ce véhicule
)} {reMdl && (() => { const cands = candidatsPour(reMdl.vehicule_id, camp.id, reMdl.matricule); const premier = cands.find(c=>c.choisi); const veh = getVeh(reMdl.vehicule_id), emp = getEmp(reMdl.matricule); const choix = cands.find(c=>cleanMat(c.matricule)===cleanMat(reSel)); const lab = ["①","②","③"]; const Ligne = ({c,i}) => ( ); const choisis = cands.filter(c=>c.choisi), autres = cands.filter(c=>!c.choisi); return ( setReMdl(null)} title="Réattribuer le véhicule" width={680}>
Véhicule : {veh?.numero?`N°${veh.numero} — `:""}{veh?.marque_type} · {veh?.immatriculation}
{reMdl.statut==="abandon"?"A abandonné le véhicule : ":"Bénéficiaire actuel : "}{emp?`${emp.prenom} ${emp.nom}`:reMdl.matricule} ({reMdl.matricule}, {reMdl.anciennete} ans)

Candidats suivants (algorithme : ancienneté décroissante)

{choisis.length===0 &&

Aucun autre candidat éligible n'a choisi ce véhicule.

} {choisis.map((c,i)=>(
{i<3 && {lab[i]} suivant}
))} {autres.length>0 && ( <>
AUTRES CANDIDATS ÉLIGIBLES SANS VÉHICULE (hors choix — exception à justifier)
{autres.slice(0,60).map((c,i)=>)} )}
{choix && premier && cleanMat(choix.matricule)!==cleanMat(premier.matricule) && ( Ce candidat n'est pas le suivant selon l'algorithme ({premier.prenom} {premier.nom}). La décision sera signalée « hors ordre » dans le journal. )}