SYS_KERNEL
NET_SECURE
AO_CORE
DATA_SYNC
V1.69
FR-HDF
AES-256
TLS 1.3
JFH
VRD
VEILLE AO — ACCÈS SÉCURISÉ
5 nouveaux AO
VEILLE ACTIVE
--:--:--
Accès Veille AO — Crédits disponibles
8 MOIS RESTANTS
Expire le 31/12/2026
Activité des 30 derniers jours — scores détectés
01 Avr
10 Avr
20 Avr
30 Avr
AO DÉTECTÉS
9
+3 vs hier
▶ DERNIERS REÇUS
NON LUS
5
À traiter
▶ EN ATTENTE
SOURCES
4
En ligne
▶ TOP SOURCES
ÉCHÉANCE
J-5
CA Lens-Liévin
▶ COMPTE À REBOURS
Flux live — Moteur de veille
0 AO traités
SYSTÈME ACTIF — COLLECTE EN COURS
Top AO — 7 derniers jours
Maîtrise d'œuvre — Cliquez pour la fiche
NEW
96
MOE assainissement unitaire — ZAC du Rivage, Lens
MOEBOAMPCA Lens-Liévin💵 280 000 €📅 26/04
⚡ APPUI JFHJ-5
NEW
88
Mission MOE VRD — lotissement 42 lots, Douai
MOEMaximilienDouaisis Agglo💵 185 000 €📅 25/04
⚡ APPUI JFHJ-12
NEW
74
MOE requalification voirie + réseaux — centre Arras
MOEBOAMPVille d'Arras💵 320 000 €📅 28/04
⚡ APPUI JFHJ-19
NEW
67
Étude hydraulique + MOE bassin rétention — Amiens Nord
HydrauliqueKLEKOONAmiens Métropole💵 95 000 €📅 24/04
⚡ APPUI JFHJ-24
NEW
51
AMO + MOE aménagement parvis gare — Dunkerque
AMO/MOETEDCUD Dunkerque💵 450 000 €📅 27/04J-31
function setTheme(t) { if (t === 'white') { document.body.classList.add('theme-white'); localStorage.setItem('jfh-theme', 'white'); } else { document.body.classList.remove('theme-white'); localStorage.setItem('jfh-theme', 'black'); } fixTopbar(); } function fixTopbar() { // Force le topbar identique dans les deux modes const topbar = document.querySelector('.topbar'); if (!topbar) return; topbar.style.setProperty('background', 'rgba(18,18,22,0.99)', 'important'); topbar.style.setProperty('border-bottom', '1px solid rgba(255,255,255,0.08)', 'important'); // Boutons nav document.querySelectorAll('.tb-btn').forEach(b => { b.style.setProperty('font-weight', '700', 'important'); if (!b.classList.contains('active')) { b.style.setProperty('color', 'rgba(255,255,255,0.85)', 'important'); } }); document.querySelectorAll('.tb-btn.active').forEach(b => { b.style.setProperty('color', 'rgba(78,168,168,0.95)', 'important'); b.style.setProperty('border-color', 'rgba(78,168,168,0.3)', 'important'); b.style.setProperty('background', 'rgba(78,168,168,0.06)', 'important'); }); // Horloge, séparateurs const clock = document.getElementById('tb-clock'); if (clock) clock.style.setProperty('color', 'rgba(255,255,255,0.85)', 'important'); document.querySelectorAll('.tb-sep').forEach(s => s.style.setProperty('background', 'rgba(255,255,255,0.12)', 'important')); } function initTheme() { const saved = localStorage.getItem('jfh-theme'); if (saved === 'white') document.body.classList.add('theme-white'); } // Le thème ne s'applique qu'après connexion — voir doLogin() /* ============================================================ DONNÉES AO ============================================================ */ const AO_DATA = [ { score:96, scoreClass:'sc-hi', modalGrad:'linear-gradient(90deg,rgba(63,185,80,.85),rgba(78,168,168,.5))', title:"Mission de maîtrise d'œuvre — Réhabilitation réseau assainissement unitaire ZAC du Rivage", acheteur:"Communauté d'Agglomération Lens-Liévin — Direction des Infrastructures", ref:'BOAMP-2026-04-LEN-0482', type:"Maîtrise d'œuvre complète", source:'BOAMP', lieu:'Lens (62300) — Pas-de-Calais', budget:'280 000 € HT', deadline:'5 mai 2026 — 12h00', urgence:'J-5', urgent:true, cpv:'71322000 — Services de conception technique pour ouvrages de génie civil', procedure:'Procédure adaptée (MAPA)', duree:'18 mois — études + suivi travaux', description:"Mission MOE complète (AVP, PRO, ACT, VISA, DET, AOR) pour la réhabilitation de 2,4 km de réseau d'assainissement unitaire en DN400 à DN800. Travaux en zone urbaine dense.", keywords:[{label:'assainissement',type:'must'},{label:"maîtrise d'œuvre",type:'must'},{label:'réseau unitaire',type:'boost'},{label:'VRD',type:'boost'}] }, { score:88, scoreClass:'sc-hi', modalGrad:'linear-gradient(90deg,rgba(63,185,80,.7),rgba(200,169,110,.5))', title:"Mission MOE VRD — Création lotissement Les Acacias, 42 lots individuels", acheteur:"Douaisis Agglomération — Service Aménagement du Territoire", ref:'MAXI-2026-04-DOU-1138', type:"Maîtrise d'œuvre VRD", source:'Maximilien', lieu:'Douai (59500) — Nord', budget:'185 000 € HT', deadline:'12 mai 2026 — 17h00', urgence:'J-12', urgent:true, cpv:'71322000 — Services techniques ingénierie VRD', procedure:"Appel d'offres ouvert", duree:'24 mois', description:"Conception et suivi des travaux VRD pour la création d'un lotissement de 42 lots. Mission comprenant : voirie, réseaux EU/EP/AEP, éclairage public. Terrain de 3,8 ha en zone 1AU.", keywords:[{label:'lotissement',type:'must'},{label:'VRD',type:'must'},{label:'voirie',type:'boost'},{label:'réseaux EU EP',type:'boost'}] }, { score:74, scoreClass:'sc-md', modalGrad:'linear-gradient(90deg,rgba(210,153,34,.8),rgba(200,169,110,.5))', title:"Maîtrise d'œuvre requalification voirie et réseaux — secteur centre-ville historique", acheteur:"Ville d'Arras — Direction de la Voirie et des Déplacements", ref:'BOAMP-2026-04-ARR-0291', type:"MOE voirie + réseaux", source:'BOAMP', lieu:'Arras (62000) — Pas-de-Calais', budget:'320 000 € HT', deadline:'19 mai 2026 — 12h00', urgence:'J-19', urgent:false, cpv:'71311000 — Services de conseil en génie civil', procedure:'Procédure adaptée (MAPA)', duree:'30 mois', description:"Requalification de 1,2 km de voirie en centre-ville classé. Contraintes patrimoniales fortes. Rénovation réseaux (EU séparatifs, AEP, télécom), réfection chaussée et trottoirs.", keywords:[{label:'voirie',type:'must'},{label:'requalification',type:'boost'},{label:'réseaux',type:'boost'},{label:"maîtrise d'œuvre",type:'must'}] }, { score:67, scoreClass:'sc-md', modalGrad:'linear-gradient(90deg,rgba(210,153,34,.7),rgba(78,168,168,.4))', title:"Étude hydraulique et mission MOE — Création bassin rétention eaux pluviales secteur Nord", acheteur:"Amiens Métropole — Direction Eau & Assainissement", ref:'KLEK-2026-04-AMI-0774', type:"Études + MOE hydraulique", source:'KLEKOON', lieu:'Amiens (80000) — Somme', budget:'95 000 € HT', deadline:'24 mai 2026 — 17h00', urgence:'J-24', urgent:false, cpv:'71351914 — Services d\'hydrologie', procedure:'Procédure adaptée (MAPA)', duree:'12 mois', description:"Étude hydraulique complète (modélisation, dimensionnement) puis MOE pour la création d'un bassin de rétention de 8 000 m³ sur secteur à risque inondation. Dossier loi sur l'eau inclus.", keywords:[{label:'hydraulique',type:'must'},{label:'bassin rétention',type:'must'},{label:'eaux pluviales',type:'boost'},{label:'MOE',type:'boost'}] }, { score:51, scoreClass:'sc-lo', modalGrad:'linear-gradient(90deg,rgba(137,87,229,.7),rgba(78,168,168,.4))', title:"AMO et maîtrise d'œuvre — Aménagement du parvis de la gare et liaison douce", acheteur:"Communauté Urbaine de Dunkerque — Pôle Mobilités", ref:'TED-2026-EU-DKQ-0088', type:"AMO + MOE espace public", source:'TED', lieu:'Dunkerque (59140) — Nord', budget:'450 000 € HT', deadline:'30 mai 2026 — 12h00', urgence:'J-31', urgent:false, cpv:"71240000 — Services d'architecture, d'ingénierie et de planification", procedure:"Appel d'offres ouvert (seuil européen)", duree:'36 mois', description:"AMO et MOE pour la requalification du parvis de la gare de Dunkerque (5 200 m²) et création d'une liaison douce de 900 ml vers le centre-ville.", keywords:[{label:'espace public',type:'boost'},{label:'aménagement',type:'boost'},{label:'liaison douce',type:'boost'},{label:'MOE',type:'must'}] } ]; /* ============================================================ TIMELINE */ function buildTimeline() { const pts = document.getElementById('tl-points'); // [position%, score, ville, type_ao] const data = [ [3, 45, 'Béthune', 'MOE voirie'], [8, 72, 'Lille', 'MOE assainissement'], [14, 58, 'Arras', 'Étude hydraulique'], [19, 89, 'Lens', 'MOE VRD lotissement'], [24, 34, 'Amiens', 'AMO espace public'], [28, 67, 'Douai', 'MOE réseaux EP'], [33, 91, 'Valenciennes', 'MOE assainissement'], [38, 55, 'Calais', 'Étude hydraulique'], [42, 78, 'Lille', 'MOE voirie centre'], [47, 62, 'Arras', 'MOE VRD ZAC'], [52, 44, 'Amiens', 'AMO mobilité'], [56, 88, 'Lens', 'MOE assainissement'], [61, 70, 'Douai', 'MOE lotissement'], [65, 53, 'Dunkerque', 'Étude hydraulique'], [70, 95, 'Lille', 'MOE VRD ZAC'], [74, 66, 'Arras', 'MOE voirie'], [79, 82, 'Valenciennes', 'MOE réseaux EU'], [83, 48, 'Amiens', 'AMO espaces verts'], [88, 74, 'Lens', 'MOE VRD'], [92, 96, 'Lens', 'MOE assainissement unitaire'], [96, 88, 'Douai', 'MOE VRD lotissement'], [99, 74, 'Arras', 'MOE voirie + réseaux'], ]; data.forEach(([pos, score, ville, type], idx) => { const col = score >= 80 ? '#3fb950' : score >= 60 ? '#d29922' : '#7b6fe0'; const glow = score >= 80 ? 'rgba(63,185,80,0.5)' : score >= 60 ? 'rgba(210,153,34,0.5)' : 'rgba(123,111,224,0.5)'; const niveau = score >= 80 ? 'Élevé' : score >= 60 ? 'Moyen' : 'Faible'; const pt = document.createElement('div'); pt.className = 'tl-point'; pt.style.left = pos + '%'; pt.style.setProperty('--dot-glow', glow); pt.innerHTML = `
${score}
${score} / 100
${ville}
${type}
Score ${niveau}
`; // 3 derniers points cliquables → fiches AO if (idx >= data.length - 3) { const aoIdx = idx - (data.length - 3); // 0, 1, 2 pt.classList.add('tl-clickable'); pt.addEventListener('click', () => openModal(aoIdx)); // Halo distinctif pour indiquer qu'ils sont cliquables } pts.appendChild(pt); }); } /* ============================================================ FAVORIS */ const favs = new Set(); function toggleFav() { const idx = parseInt(document.getElementById('modal').dataset.idx); const btn = document.getElementById('m-fav-btn'); if (favs.has(idx)) { favs.delete(idx); btn.textContent = '★ Ajouter aux favoris'; btn.classList.remove('added'); document.getElementById('ao-' + idx)?.classList.remove('favorited'); } else { favs.add(idx); btn.textContent = '★ Retiré des favoris (cliquer pour retirer)'; btn.classList.add('added'); document.getElementById('ao-' + idx)?.classList.add('favorited'); showToast('★ Ajouté à Mes AO'); } updateFavCount(); renderMeao(); } function updateFavCount() { const el = document.getElementById('fav-count'); if (favs.size > 0) { el.textContent = favs.size; el.classList.add('show'); } else { el.classList.remove('show'); } } function renderMeao() { const list = document.getElementById('meao-list'); const empty = document.getElementById('meao-empty'); const sub = document.getElementById('meao-sub'); const items = [...favs].map(i => AO_DATA[i]); sub.textContent = items.length ? `${items.length} AO enregistré${items.length>1?'s':''}` : 'Aucun favori enregistré'; // Vider sauf l'empty [...list.children].forEach(c => { if (c !== empty) c.remove(); }); if (items.length === 0) { empty.style.display = 'flex'; return; } empty.style.display = 'none'; items.forEach((ao, i) => { const idx = [...favs][i]; const div = document.createElement('div'); div.className = 'ao-item favorited'; div.innerHTML = `
${ao.score}
${ao.title}
${ao.type} ${ao.source} ${ao.acheteur.split('—')[0].trim()} 💵 ${ao.budget} ${ao.urgence}
`; div.onclick = () => { openModal(idx); }; list.appendChild(div); }); } /* ============================================================ MODAL */ let toastTimeout; function showToast(msg) { let t = document.getElementById('toast'); if (!t) { t = document.createElement('div'); t.id = 'toast'; t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(30,30,40,0.95);border:1px solid rgba(200,169,110,0.4);color:rgba(220,190,130,0.9);font-family:Space Mono,monospace;font-size:9px;letter-spacing:.15em;text-transform:uppercase;padding:8px 18px;border-radius:4px;z-index:50;transition:opacity .3s;'; document.body.appendChild(t); } t.textContent = msg; t.style.opacity = '1'; t.style.display = 'block'; clearTimeout(toastTimeout); toastTimeout = setTimeout(() => { t.style.opacity = '0'; setTimeout(() => { t.style.display = 'none'; }, 350); }, 2800); } function openModal(idx) { const ao = AO_DATA[idx]; const modal = document.getElementById('modal'); modal.dataset.idx = idx; modal.style.setProperty('--mc', ao.modalGrad); const badge = document.getElementById('m-badge'); badge.textContent = ao.score; badge.className = 'modal-score-badge ' + ao.scoreClass; document.getElementById('m-title').textContent = ao.title; document.getElementById('m-ach').textContent = ao.acheteur; document.getElementById('m-ref').textContent = ao.ref; const favBtn = document.getElementById('m-fav-btn'); if (favs.has(idx)) { favBtn.textContent = '★ Retiré des favoris'; favBtn.classList.add('added'); } else { favBtn.textContent = '★ Ajouter aux favoris'; favBtn.classList.remove('added'); } document.getElementById('m-body').innerHTML = ` `; document.getElementById('modal-overlay').classList.add('open'); refreshStatusBar(idx); if (idx < 5) markAsRead(idx); } function closeModal() { document.getElementById('modal-overlay').classList.remove('open'); } function closeModalOutside(e) { if (e.target.id === 'modal-overlay') closeModal(); } /* ============================================================ VUES */ function showView(v) { const views = ['view-dash','view-flux','view-meao','view-reglages','view-sources','view-stats','view-historique']; views.forEach(id => { const el = document.getElementById(id); if(el) el.style.display='none'; }); document.querySelectorAll('.tb-btn').forEach(b => b.classList.remove('active')); const map = { 'meao': ['view-meao', 'btn-meao', () => renderMeao()], 'flux': ['view-flux', 'btn-flux', () => renderFlux()], 'reglages': ['view-reglages', 'btn-reglages', () => { document.getElementById('view-reglages').scrollTop=0; buildPoids(); }], 'sources': ['view-sources', 'btn-sources', null], 'stats': ['view-stats', 'btn-stats', null], 'historique': ['view-historique', 'btn-historique', () => renderHistorique()], }; const entry = map[v]; if (entry) { document.getElementById(entry[0]).style.display = 'flex'; document.getElementById(entry[1]).classList.add('active'); if (entry[2]) entry[2](); } else { document.getElementById('view-dash').style.display = 'flex'; document.getElementById('btn-dashboard').classList.add('active'); } } /* ============================================================ LOGIN */ function doLogin() { const u=document.getElementById('inp-u').value.trim(); const p=document.getElementById('inp-p').value.trim(); const err=document.getElementById('lerr'),ok=document.getElementById('lok'); const lp=document.getElementById('lp'),bar=document.getElementById('lp-bar'); const lbl=document.getElementById('lp-lbl'),btn=document.getElementById('login-btn'); const st=document.getElementById('status-txt'); err.style.display='none'; ok.style.display='none'; if(u!=='demo'||p!=='veille2026!'){ err.style.display='block'; err.style.animation='none'; void err.offsetWidth; err.style.animation='shake 0.3s ease'; st.textContent='TENTATIVE ÉCHOUÉE — ACCÈS REFUSÉ'; setTimeout(()=>st.textContent='VEILLE AO — ACCÈS SÉCURISÉ',2000); return; } btn.disabled=true; lp.style.display='block'; st.textContent='AUTHENTIFICATION EN COURS...'; let pct=0, step=0; const msgs=['INITIALISATION...','KERNEL LOAD...','VÉRIFICATION AUTH...','SYNC DONNÉES...','CHARGEMENT AO...','ACCÈS AUTORISÉ']; const iv=setInterval(()=>{ pct+=Math.random()*18+8; if(pct>100)pct=100; bar.style.width=pct+'%'; const si=Math.min(Math.floor(pct/17),5); if(si!==step){step=si; lbl.textContent=msgs[step];} if(pct>=100){ clearInterval(iv); ok.style.display='block'; st.textContent='CONNEXION ÉTABLIE'; setTimeout(()=>{ document.getElementById('screen-login').classList.add('hidden'); setTimeout(()=>{ document.getElementById('screen-login').style.display='none'; document.getElementById('screen-dash').classList.add('visible'); initTheme(); const _savedTheme = localStorage.getItem("jfh-theme") || "black"; startClock(); updateMoisAo(); buildTimeline(); buildKpiTerminals(); buildLogFeed(); setTimeout(buildPoids, 200); setTimeout(budUpdate, 250); },600); },700); } },110); } function doLogout(){ document.getElementById('screen-dash').classList.remove('visible'); const login=document.getElementById('screen-login'); login.style.display='flex'; setTimeout(()=>login.classList.remove('hidden'),50); document.getElementById('lp').style.display='none'; document.getElementById('lok').style.display='none'; document.getElementById('lerr').style.display='none'; document.getElementById('lp-bar').style.width='0%'; document.getElementById('login-btn').disabled=false; document.getElementById('inp-u').value=''; document.getElementById('inp-p').value=''; document.getElementById('status-txt').textContent='VEILLE AO — ACCÈS SÉCURISÉ'; document.body.classList.remove('theme-white'); } /* ============================================================ TERMINAL KPI FLUX */ function buildKpiTerminals() { const aoFlux = [ {score:96, label:'Assainissement — Lens', color:'rgba(63,185,80,0.95)'}, {score:88, label:'MOE VRD — Douai', color:'rgba(63,185,80,0.88)'}, {score:74, label:'Voirie — Arras', color:'rgba(210,153,34,0.95)'}, ]; const nonLus = [ {score:96, label:'Assainissement — Lens', color:'rgba(63,185,80,0.95)'}, {score:88, label:'MOE VRD — Douai', color:'rgba(63,185,80,0.88)'}, {score:74, label:'Voirie — Arras', color:'rgba(210,153,34,0.95)'}, ]; const sources = [ {src:'BOAMP', count:4, color:'rgba(78,168,168,1)'}, {src:'Maximilien', count:3, color:'rgba(78,168,168,1)'}, {src:'KLEKOON', count:1, color:'rgba(78,168,168,1)'}, {src:'TED', count:1, color:'rgba(78,168,168,1)'}, ]; const deadlines = [ {label:'Assainissement — Lens', dl:'J-5', color:'rgba(220,80,80,1)'}, {label:'MOE VRD — Douai', dl:'J-12', color:'rgba(210,153,34,0.95)'}, {label:'Voirie — Arras', dl:'J-19', color:'rgba(210,153,34,0.75)'}, ]; // Délai entre chaque ligne (ms) const LINE_DELAY = 640; // x2 vs avant // Retourne une promesse qui se résout quand toutes les lignes sont affichées function typeLines(containerId, lines, startDelay) { return new Promise(resolve => { const el = document.getElementById(containerId); if (!el) { resolve(); return; } let cursor = null; lines.forEach((line, i) => { setTimeout(() => { // Retirer le curseur du précédent if (cursor) cursor.remove(); const div = document.createElement('div'); div.className = 'kpi-term-line'; div.innerHTML = line; el.appendChild(div); // Ajouter curseur seulement si c'est la dernière ligne if (i === lines.length - 1) { // Pas de curseur permanent — on le retire après 1.5s cursor = document.createElement('span'); cursor.className = 'kpi-term-cursor'; div.appendChild(cursor); setTimeout(() => { if (cursor) cursor.remove(); }, 1500); setTimeout(resolve, LINE_DELAY); } }, startDelay + i * LINE_DELAY); }); }); } // Enchaîner séquentiellement : AO → NonLus → Sources → Deadlines const totalAo = aoFlux.length * LINE_DELAY; const totalNonLus = nonLus.length * LINE_DELAY; const totalSrc = sources.length * LINE_DELAY; const t0 = 400; // AO démarre à 400ms const t1 = t0 + totalAo + 300; // NonLus après AO + pause const t2 = t1 + totalNonLus + 300; // Sources après NonLus + pause const t3 = t2 + totalSrc + 300; // Deadlines après Sources + pause typeLines('term-ao', aoFlux.map(a => `${a.score}${a.label}` ), t0); typeLines('term-nonlus', nonLus.map(a => `${a.score}${a.label}` ), t1); typeLines('term-sources', sources.map(s => `${s.src}${s.count} AO` ), t2); typeLines('term-dl', deadlines.map(d => `${d.dl}${d.label}` ), t3); // Marquer le terminal comme prêt quand toutes les animations sont finies const totalAll = t3 + deadlines.length * LINE_DELAY + 500; setTimeout(() => { kpiTerminalReady = true; }, totalAll); } /* ============================================================ FLUX LIVE MOTEUR — LOG TERMINAL */ const LOG_AO = [ {src:'BOAMP', score:96, title:'MOE assainissement — Lens', col:'#3fb950'}, {src:'Maximil', score:88, title:'MOE VRD lotissement — Douai', col:'#3fb950'}, {src:'BOAMP', score:74, title:'MOE voirie + réseaux — Arras', col:'#d29922'}, {src:'TED', score:51, title:'AMO parvis gare — Dunkerque', col:'#7b6fe0'}, {src:'BOAMP', score:83, title:'MOE réseaux EU — Valenciennes', col:'#3fb950'}, {src:'Maximil', score:61, title:'MOE voirie — Saint-Omer', col:'#d29922'}, {src:'TED', score:91, title:'MOE ZAC hydraulique — Lille', col:'#3fb950'}, {src:'KLEKOON', score:44, title:'Étude préliminaire — Calais', col:'#7b6fe0'}, {src:'BOAMP', score:78, title:'MOE VRD — Lens', col:'#3fb950'}, {src:'Maximil', score:55, title:'MOE lot. 28 lots — Béthune', col:'#d29922'}, {src:'BOAMP', score:89, title:'MOE assainissement — Valenciennes', col:'#3fb950'}, {src:'TED', score:38, title:'Études préalables — Boulogne', col:'#7b6fe0'}, {src:'KLEKOON', score:72, title:'MOE voirie — Cambrai', col:'#d29922'}, {src:'BOAMP', score:95, title:'MOE réseaux pluviaux — Lille', col:'#3fb950'}, {src:'Maximil', score:82, title:'MOE lot. — Arras', col:'#3fb950'}, {src:'KLEKOON', score:59, title:'Hydraulique — Boulogne-sur-Mer', col:'#d29922'}, {src:'BOAMP', score:93, title:'MOE assainissement — Lens', col:'#3fb950'}, {src:'TED', score:47, title:'AMO mobilité douce — Dunkerque', col:'#7b6fe0'}, {src:'Maximil', score:76, title:'MOE VRD — Douai nord', col:'#d29922'}, ]; let logFeedIdx = 0; let logTotal = 0; const logHistory = []; // stocker les 20 derniers function formatLogDate(d) { const dd = String(d.getDate()).padStart(2,'0'); const mm = String(d.getMonth()+1).padStart(2,'0'); const hh = String(d.getHours()).padStart(2,'0'); const mi = String(d.getMinutes()).padStart(2,'0'); const ss = String(d.getSeconds()).padStart(2,'0'); return dd+'/'+mm+' '+hh+':'+mi+':'+ss; } function renderFeed() { const feed = document.getElementById('log-feed'); if (!feed) return; // Vider et reconstruire depuis logHistory (les 20 derniers, du plus ancien au plus récent) // Retirer le curseur d'abord const oldCur = feed.querySelector('.log-cursor'); if (oldCur) oldCur.remove(); // Supprimer lignes en trop (garder les 20 dernières) while (feed.querySelectorAll('.log-line').length > logHistory.length) { feed.querySelector('.log-line').remove(); } // Ajouter uniquement la nouvelle ligne (la dernière de logHistory) if (logHistory.length > 0) { const ao = logHistory[logHistory.length - 1]; const div = document.createElement('div'); div.className = 'log-line'; div.innerHTML = '' + ao.ts + '' + '' + ao.src + '' + '' + ao.score + '' + '' + ao.title + '' + (ao.score >= 80 ? 'NEW' : ''); feed.appendChild(div); } // Curseur clignotant const cur = document.createElement('div'); cur.className = 'log-cursor'; cur.textContent = '█'; feed.appendChild(cur); // Limiter à 20 lignes visibles const lines = feed.querySelectorAll('.log-line'); if (lines.length > 20) lines[0].remove(); } function addLogLine() { const countEl = document.getElementById('flux-count'); const ao = LOG_AO[logFeedIdx % LOG_AO.length]; logFeedIdx++; logTotal++; const now = new Date(); const entry = { ...ao, ts: formatLogDate(now) }; logHistory.push(entry); if (logHistory.length > 20) logHistory.shift(); if (countEl) countEl.textContent = logTotal + ' AO traités'; renderFeed(); } function buildLogFeed() { // Seed initial : 20 lignes avec timestamps rétroactifs const now = new Date(); for (let i = 19; i >= 0; i--) { const past = new Date(now.getTime() - i * 42000); // espacées de ~42s const ao = LOG_AO[(logFeedIdx) % LOG_AO.length]; logFeedIdx++; logTotal++; logHistory.push({ ...ao, ts: formatLogDate(past) }); } // Afficher toutes les 20 lignes d'un coup const feed = document.getElementById('log-feed'); const countEl = document.getElementById('flux-count'); if (feed) { logHistory.forEach(entry => { const div = document.createElement('div'); div.className = 'log-line'; div.innerHTML = '' + entry.ts + '' + '' + entry.src + '' + '' + entry.score + '' + '' + entry.title + '' + (entry.score >= 80 ? 'NEW' : ''); feed.appendChild(div); }); const cur = document.createElement('div'); cur.className = 'log-cursor'; cur.textContent = '█'; feed.appendChild(cur); } if (countEl) countEl.textContent = logTotal + ' AO traités'; // Puis nouvelles lignes toutes les 4s setInterval(addLogLine, 4000); } /* ============================================================ DONNÉES FLUX COMPLET */ const FLUX_ALL_AO = [ {id:0, score:96, title:"MOE assainissement unitaire — ZAC du Rivage, Lens", src:"BOAMP", type:"MOE", who:"CA Lens-Liévin", budget:"280 000 €", dl:"J-5", dlDays:5, date:"26/04", isNew:true}, {id:1, score:88, title:"Mission MOE VRD — lotissement 42 lots, Douai", src:"Maximil", type:"MOE", who:"Douaisis Agglo", budget:"185 000 €", dl:"J-12", dlDays:12, date:"25/04", isNew:true}, {id:2, score:74, title:"MOE requalification voirie + réseaux — centre Arras", src:"BOAMP", type:"MOE", who:"Ville d'Arras", budget:"320 000 €", dl:"J-19", dlDays:19, date:"28/04", isNew:false}, {id:4, score:51, title:"AMO + MOE aménagement parvis gare — Dunkerque", src:"TED", type:"AMO/MOE", who:"CUD Dunkerque", budget:"450 000 €", dl:"J-31", dlDays:31, date:"27/04", isNew:false}, {id:5, score:83, title:"MOE réseaux EU séparatifs — ZAC Euralille 3", src:"Maximil", type:"MOE", who:"Métropole Européenne Lille", budget:"210 000 €", dl:"J-8", dlDays:8, date:"29/04", isNew:true}, {id:6, score:91, title:"Mission hydraulique + MOE bassin — Valenciennes", src:"BOAMP", type:"MOE", who:"CA Valenciennes Métrople", budget:"175 000 €", dl:"J-6", dlDays:6, date:"30/04", isNew:true}, {id:7, score:62, title:"MOE voirie + aménagement paysager — Saint-Omer", src:"KLEKOON", type:"MOE", who:"CA Saint-Omer", budget:"130 000 €", dl:"J-22", dlDays:22, date:"23/04", isNew:false}, {id:8, score:78, title:"MOE VRD — lotissement Les Chênes, 28 lots, Béthune", src:"Maximil", type:"MOE", who:"CA Béthune-Bruay", budget:"145 000 €", dl:"J-15", dlDays:15, date:"24/04", isNew:false}, {id:9, score:44, title:"Étude préliminaire hydraulique — bassin Calais Est", src:"TED", type:"Études", who:"Ville de Calais", budget:"55 000 €", dl:"J-35", dlDays:35, date:"22/04", isNew:false}, {id:10,score:89, title:"MOE réseaux pluviaux — ZAC Nouveau Monde, Lille", src:"BOAMP", type:"MOE", who:"Lille Métropole", budget:"290 000 €", dl:"J-9", dlDays:9, date:"29/04", isNew:true}, {id:11,score:55, title:"MOE lot. résidentiel 18 lots — Cambrai", src:"KLEKOON", type:"MOE", who:"Ville de Cambrai", budget:"90 000 €", dl:"J-28", dlDays:28, date:"22/04", isNew:false}, {id:12,score:82, title:"MOE lot. — ZAC Les Acacias, Arras Nord", src:"Maximil", type:"MOE", who:"Ville d'Arras", budget:"168 000 €", dl:"J-11", dlDays:11, date:"27/04", isNew:true}, {id:13,score:59, title:"Hydraulique — étude bassin rétention, Boulogne-sur-Mer", src:"KLEKOON", type:"Hydraulique", who:"CC Boulonnais", budget:"72 000 €", dl:"J-26", dlDays:26, date:"23/04", isNew:false}, {id:14,score:93, title:"MOE assainissement — Quartier gare, Lens", src:"BOAMP", type:"MOE", who:"Ville de Lens", budget:"195 000 €", dl:"J-7", dlDays:7, date:"30/04", isNew:true}, {id:15,score:47, title:"AMO mobilité douce — liaison cyclable, Dunkerque", src:"TED", type:"AMO", who:"CUD Dunkerque", budget:"65 000 €", dl:"J-33", dlDays:33, date:"21/04", isNew:false}, {id:16,score:76, title:"MOE VRD — Douai nord, lotissement 36 lots", src:"Maximil", type:"MOE", who:"Douaisis Agglo", budget:"155 000 €", dl:"J-17", dlDays:17, date:"25/04", isNew:false}, {id:17,score:38, title:"Études préalables — requalification berges, Boulogne", src:"TED", type:"Études", who:"Ville de Boulogne", budget:"42 000 €", dl:"J-40", dlDays:40, date:"20/04", isNew:false}, {id:18,score:71, title:"MOE voirie — avenue de la République, Cambrai", src:"BOAMP", type:"MOE", who:"Ville de Cambrai", budget:"112 000 €", dl:"J-20", dlDays:20, date:"26/04", isNew:false}, {id:19,score:85, title:"MOE hydraulique + VRD — extension ZAC, Valenciennes", src:"BOAMP", type:"MOE", who:"CA Valenciennes", budget:"240 000 €", dl:"J-10", dlDays:10, date:"28/04", isNew:true}, ]; /* ============================================================ FILTRES FLUX */ let fluxFilters = { src: new Set(), level: new Set(), dl: new Set(), status: new Set() }; let fluxScoreMin = 0; function toggleFilter(btn) { const f = btn.dataset.filter; const v = btn.dataset.val; if (fluxFilters[f].has(v)) { fluxFilters[f].delete(v); btn.classList.remove('active'); } else { fluxFilters[f].add(v); btn.classList.add('active'); } renderFlux(); } function resetFluxFilters() { fluxFilters = { src: new Set(), level: new Set(), dl: new Set(), status: new Set() }; fluxScoreMin = 0; document.getElementById('flux-score-min').value = 0; document.getElementById('flux-score-val').textContent = '0'; document.getElementById('flux-search-input').value = ''; document.querySelectorAll('.filter-btn.active').forEach(b => b.classList.remove('active')); renderFlux(); } function renderFlux() { const list = document.getElementById('flux-ao-list'); const countEl = document.getElementById('flux-ao-count'); if (!list) return; const search = (document.getElementById('flux-search-input')?.value || '').toLowerCase(); const scoreMin = parseInt(document.getElementById('flux-score-min')?.value || 0); let filtered = FLUX_ALL_AO.filter(ao => { // Recherche texte if (search && !ao.title.toLowerCase().includes(search) && !ao.who.toLowerCase().includes(search) && !ao.src.toLowerCase().includes(search)) return false; // Score min slider if (ao.score < scoreMin) return false; // Filtre source if (fluxFilters.src.size > 0 && !fluxFilters.src.has(ao.src)) return false; // Filtre niveau if (fluxFilters.level.size > 0) { const lvl = ao.score >= 80 ? 'hi' : ao.score >= 60 ? 'md' : 'lo'; if (!fluxFilters.level.has(lvl)) return false; } // Filtre délai if (fluxFilters.dl.size > 0) { const isUrgent = ao.dlDays <= 14; if (fluxFilters.dl.has('urgent') && !isUrgent && !fluxFilters.dl.has('normal')) return false; if (fluxFilters.dl.has('normal') && isUrgent && !fluxFilters.dl.has('urgent')) return false; } // Filtre statut if (fluxFilters.status.has('new') && !ao.isNew) return false; if (fluxFilters.status.has('fav') && !favs.has(ao.id)) return false; return true; }); filtered = triSort(filtered); countEl.textContent = filtered.length + ' AO'; list.innerHTML = ''; if (filtered.length === 0) { list.innerHTML = '
🔍
Aucun AO ne correspond aux filtres
'; return; } filtered.forEach(ao => { const scoreClass = ao.score >= 80 ? 'sc-hi' : ao.score >= 60 ? 'sc-md' : 'sc-lo'; const dlClass = ao.dlDays <= 14 ? 'urgent' : 'normal'; const isFav = favs.has(ao.id); const div = document.createElement('div'); div.className = 'flux-ao-item' + (isFav ? ' fav-item' : ''); div.dataset.aoid = ao.id; const st = getAoStatus(ao.id); if (st.status) div.dataset.status = st.status; if (st.appui) div.dataset.appui = '1'; div.innerHTML = `
\${ao.score}
\${ao.title}
\${ao.type} \${ao.src} \${ao.who} 💵 \${ao.budget} 📅 \${ao.date} \${ao.isNew ? 'NEW' : ''}
⚡ APPUI JFH
\${ao.dl}
\${isFav?'★':'☆'}
`; div.onclick = () => openModal(Math.min(ao.id, AO_DATA.length-1)); list.appendChild(div); }); } function toggleFluxFav(id, starEl) { if (favs.has(id)) { favs.delete(id); starEl.classList.remove('starred'); starEl.textContent = '☆'; starEl.closest('.flux-ao-item').classList.remove('fav-item'); } else { favs.add(id); starEl.classList.add('starred'); starEl.textContent = '★'; starEl.closest('.flux-ao-item').classList.add('fav-item'); showToast('★ Ajouté aux favoris'); } updateFavCount(); renderMeao(); } /* ============================================================ STATUTS AO */ const aoStatuses = {}; // {aoId: {status: 'go'|'study'|'nogo'|null, appui: bool}} function getAoStatus(idx) { return aoStatuses[idx] || { status: null, appui: false }; } function setAoStatus(val, btn) { const idx = parseInt(document.getElementById('modal').dataset.idx); if (!aoStatuses[idx]) aoStatuses[idx] = { status: null, appui: false }; // Toggle : cliquer deux fois = désactiver if (aoStatuses[idx].status === val) { aoStatuses[idx].status = null; } else { aoStatuses[idx].status = val; } refreshStatusBar(idx); } function toggleAppui(btn) { const idx = parseInt(document.getElementById('modal').dataset.idx); if (!aoStatuses[idx]) aoStatuses[idx] = { status: null, appui: false }; aoStatuses[idx].appui = !aoStatuses[idx].appui; refreshStatusBar(idx); if (aoStatuses[idx].appui) showToast('⚡ Appui JFH demandé'); } function refreshStatusBar(idx) { const st = getAoStatus(idx); const bar = document.getElementById('m-status-bar'); if (bar) { bar.querySelectorAll('.btn-go, .btn-study, .btn-nogo').forEach(b => b.classList.remove('active')); bar.querySelector('.btn-appui').classList.toggle('active', st.appui); if (st.status === 'go') bar.querySelector('.btn-go').classList.add('active'); if (st.status === 'study') bar.querySelector('.btn-study').classList.add('active'); if (st.status === 'nogo') bar.querySelector('.btn-nogo').classList.add('active'); } // Mettre à jour les lignes AO dans le dashboard et le flux updateAoRowStatus(idx, st); } function updateAoRowStatus(idx, st) { const labels = { go: '✅ GO', study: '🔍 ÉTUDE', nogo: '❌ NO GO' }; // Dashboard const dashRow = document.querySelector(`.ao-item[data-aoid="${idx}"]`); if (dashRow) { dashRow.dataset.status = st.status || ''; dashRow.dataset.appui = st.appui ? '1' : '0'; if (!st.status) delete dashRow.dataset.status; if (!st.appui) delete dashRow.dataset.appui; const sb = dashRow.querySelector('.ao-status-badge'); const ab = dashRow.querySelector('.ao-appui-badge'); if (sb) { sb.textContent = st.status ? labels[st.status] : ''; } if (ab) ab.style.display = st.appui ? 'inline-flex' : 'none'; } // Vue Flux document.querySelectorAll(`.flux-ao-item[data-aoid="${idx}"]`).forEach(row => { if (st.status) row.dataset.status = st.status; else delete row.dataset.status; if (st.appui) row.dataset.appui = '1'; else delete row.dataset.appui; const sb = row.querySelector('.flux-status-badge'); const ab = row.querySelector('.flux-appui-badge'); if (sb) sb.textContent = st.status ? labels[st.status] : ''; if (ab) { ab.style.display = st.appui ? 'inline-flex' : 'none'; if (!st.appui) ab.textContent = '⚡ APPUI JFH'; } }); } /* ============================================================ LU / NON LU */ const readAos = new Set(); // IDs des AO lus let kpiTerminalReady = false; function markAsRead(idx) { if (readAos.has(idx)) return; readAos.add(idx); // Mettre à jour la ligne AO const row = document.querySelector(`.ao-item[data-aoid="${idx}"]`); if (row) row.classList.add('is-read'); document.querySelectorAll(`.flux-ao-item[data-aoid="${idx}"]`).forEach(r => r.classList.add('is-read')); // Mettre à jour le KPI Non Lus updateUnreadCount(); } function updateUnreadCount() { // Compter les AO non lus dans le top 5 (ids 0-4) const totalTop = 5; const unread = totalTop - [...Array(totalTop)].filter((_,i) => readAos.has(i)).length; // KPI val const kpiEl = document.querySelector('#btn-meao')?.closest ? null : null; // Mettre à jour le chiffre dans le KPI Non Lus document.querySelectorAll('.kpi-val').forEach((el, i) => { if (el.textContent.trim() === '5' || (el._isUnread)) { // Trouver le bon KPI via son label } }); // Cibler directement le KPI non lus via son texte parent document.querySelectorAll('.kpi').forEach(kpi => { const lbl = kpi.querySelector('.kpi-lbl'); if (lbl && lbl.textContent.includes('NON LUS')) { const val = kpi.querySelector('.kpi-val'); if (val) { val.textContent = unread; val._isUnread = true; } const sub = kpi.querySelector('.kpi-sub'); if (sub) sub.textContent = unread > 0 ? 'À traiter' : 'Tout lu ✓'; } }); // Synchroniser le badge topbar const tbCount = document.getElementById('tb-nonlus-count'); if (tbCount) { tbCount.textContent = unread; const badge = tbCount.closest('.tb-stat-badge'); if (badge) { badge.style.background = unread > 0 ? 'rgba(63,185,80,.1)' : 'rgba(255,255,255,.04)'; badge.style.borderColor = unread > 0 ? 'rgba(63,185,80,.3)' : 'rgba(255,255,255,.1)'; tbCount.style.color = unread > 0 ? 'rgba(63,185,80,.95)' : 'rgba(255,255,255,.3)'; } } // Mettre à jour le flux terminal "EN ATTENTE" — uniquement si terminal prêt if (!kpiTerminalReady) return; const termNonLus = document.getElementById('term-nonlus'); if (termNonLus) { // Les lignes qui ne sont pas encore lues const unreadAos = [ {score:96, label:'Assainissement — Lens', color:'rgba(63,185,80,.95)'}, {score:88, label:'MOE VRD — Douai', color:'rgba(63,185,80,.88)'}, {score:74, label:'Voirie — Arras', color:'rgba(210,153,34,.95)'}, ].filter((_,i) => !readAos.has(i)); termNonLus.querySelectorAll('.kpi-term-line').forEach(l => l.remove()); const cur = termNonLus.querySelector('.kpi-term-cursor'); unreadAos.forEach(a => { const div = document.createElement('div'); div.className = 'kpi-term-line'; div.innerHTML = `${a.score}${a.label}`; termNonLus.insertBefore(div, cur || null); }); if (unreadAos.length === 0) { const div = document.createElement('div'); div.className = 'kpi-term-line'; div.innerHTML = '✓ Tout lu'; termNonLus.insertBefore(div, cur || null); } } } /* ============================================================ RÉGLAGES JS */ function addCpv() { const code = document.getElementById('cpv-new-code').value.trim(); const label = document.getElementById('cpv-new-label').value.trim(); if (!code) return; const row = document.createElement('div'); row.className = 'cpv-row'; row.innerHTML = '' + code + '' + (label || 'Code CPV') + '×'; document.getElementById('cpv-list').appendChild(row); document.getElementById('cpv-new-code').value = ''; document.getElementById('cpv-new-label').value = ''; } function addBlacklist() { const name = document.getElementById('bl-new-name').value.trim(); const reason = document.getElementById('bl-new-reason').value.trim(); if (!name) return; const row = document.createElement('div'); row.className = 'bl-row'; row.innerHTML = '🚫 ' + name + '' + (reason || '') + '× Retirer'; document.getElementById('blacklist').appendChild(row); document.getElementById('bl-new-name').value = ''; document.getElementById('bl-new-reason').value = ''; showToast('🚫 ' + name + ' ajouté à la black-list'); } /* ============================================================ SOURCES JS */ function toggleSource(sw) { sw.classList.toggle('on'); sw.classList.toggle('off'); const name = sw.closest('.src-card').querySelector('.src-name-big').textContent; const on = sw.classList.contains('on'); showToast((on ? '✓ ' : '⏸ ') + name + (on ? ' — Source activée' : ' — Source désactivée')); } function toggleMapping(id, btn) { const el = document.getElementById(id); if (!el) return; el.classList.toggle('open'); btn.textContent = el.classList.contains('open') ? '▲ Fermer le mapping' : '⚙ Mapping des champs'; } function selectFmt(btn, fmt) { document.querySelectorAll('.fmt-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.fmt-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById('fmt-' + fmt); if (panel) panel.classList.add('active'); } function addCustomSource() { const name = document.getElementById('new-src-name')?.value.trim(); if (!name) { showToast('⚠ Donnez un nom à la source'); return; } showToast('✓ Source "' + name + '" ajoutée — premier fetch dans 5 min'); document.getElementById('new-src-name').value = ''; document.getElementById('new-src-url').value = ''; } function toggleDept(el) { el.classList.toggle('on'); el.classList.toggle('off'); } function toggleType(el) { el.classList.toggle('on'); el.classList.toggle('off'); const ck = el.querySelector('.rgl-chk'); if (ck) ck.textContent = el.classList.contains('on') ? '✓' : ''; } function toggleSrc(el) { el.classList.toggle('on'); el.classList.toggle('off'); } function updatePoids() { const inputs = document.querySelectorAll('#view-reglages input[type=range]'); const vals = [...inputs].slice(2); // skip rayon + penalite const poidsInputs = [...document.querySelectorAll('#view-reglages input[type=range]')].filter(i => i.max <= 60); poidsInputs.forEach((inp, i) => { const el = document.getElementById('p' + i); if (el) el.textContent = inp.value + '%'; }); const total = poidsInputs.reduce((s, i) => s + parseInt(i.value), 0); const tot = document.getElementById('poids-total'); if (tot) { tot.textContent = 'TOTAL : ' + total + '% ' + (total === 100 ? '✓' : '⚠'); tot.style.color = total === 100 ? 'rgba(63,185,80,.9)' : 'rgba(192,94,94,.9)'; } } function saveReglages() { showToast('💾 Réglages enregistrés'); } function resetReglages() { showToast('↩ Réglages réinitialisés'); } /* ============================================================ HISTORIQUE & RÉPONDU */ const historiqueAo = []; // {idx, title, score, src, acheteur, budget, deadline, dateReponse, resultat, comment} let currentReponduIdx = null; let currentReponduRes = null; function marquerRepondu() { const idx = parseInt(document.getElementById('modal').dataset.idx || 0); ouvrirRepondu(idx); } function ouvrirRepondu(idx) { currentReponduIdx = idx; currentReponduRes = null; const ao = AO_DATA[Math.min(idx, AO_DATA.length-1)]; document.getElementById('repondu-ao-title').textContent = ao ? ao.title : 'AO #' + idx; document.getElementById('repondu-date').value = new Date().toISOString().split('T')[0]; document.getElementById('repondu-comment').value = ''; document.querySelectorAll('.res-btn').forEach(b => b.className = 'res-btn'); document.getElementById('repondu-overlay').classList.add('open'); document.getElementById('modal-repondu').classList.add('open'); // Fermer la modal principale si ouverte document.getElementById('modal-overlay').classList.remove('open'); } function fermerRepondu() { document.getElementById('repondu-overlay').classList.remove('open'); document.getElementById('modal-repondu').classList.remove('open'); } function selectRes(btn, res) { document.querySelectorAll('.res-btn').forEach(b => b.className = 'res-btn'); btn.classList.add('sel-' + res); currentReponduRes = res; } function confirmerRepondu() { if (currentReponduIdx === null) return; const ao = AO_DATA[Math.min(currentReponduIdx, AO_DATA.length-1)]; const date = document.getElementById('repondu-date').value; const comment = document.getElementById('repondu-comment').value; const entry = { idx: currentReponduIdx, title: ao ? ao.title : 'AO #' + currentReponduIdx, score: ao ? ao.score : 0, src: ao ? ao.source : '', acheteur: ao ? ao.acheteur : '', budget: ao ? ao.budget : '', deadline: ao ? ao.deadline : '', dateReponse: date, resultat: currentReponduRes || 'attente', comment: comment, scoreClass: ao ? ao.scoreClass : 'sc-lo' }; // Éviter les doublons const exists = historiqueAo.findIndex(h => h.idx === currentReponduIdx); if (exists >= 0) historiqueAo[exists] = entry; else historiqueAo.unshift(entry); // Retirer des favoris favs.delete(currentReponduIdx); updateFavCount(); renderMeao(); fermerRepondu(); renderHistorique(); showToast('📤 AO ajouté à l\'historique — ' + (currentReponduRes ? {gagne:'🏆 Gagné', perdu:'❌ Perdu', attente:'⏳ En attente', annule:'⏸ Annulé'}[currentReponduRes] : 'En attente')); } function renderHistorique() { const list = document.getElementById('histo-list'); const empty = document.getElementById('histo-empty'); if (!list) return; list.innerHTML = ''; if (historiqueAo.length === 0) { empty.style.display = 'flex'; document.getElementById('histo-count').textContent = '0 AO'; document.getElementById('histo-total').textContent = '0'; document.getElementById('histo-gagnes').textContent = '0'; document.getElementById('histo-perdus').textContent = '0'; document.getElementById('histo-attente').textContent = '0'; document.getElementById('histo-taux').textContent = '—'; return; } empty.style.display = 'none'; // Stats const total = historiqueAo.length; const gagnes = historiqueAo.filter(h => h.resultat === 'gagne').length; const perdus = historiqueAo.filter(h => h.resultat === 'perdu').length; const attente = historiqueAo.filter(h => h.resultat === 'attente' || h.resultat === 'annule').length; const taux = (gagnes + perdus) > 0 ? Math.round(gagnes / (gagnes + perdus) * 100) + '%' : '—'; document.getElementById('histo-count').textContent = total + ' AO'; document.getElementById('histo-total').textContent = total; document.getElementById('histo-gagnes').textContent = gagnes; document.getElementById('histo-perdus').textContent = perdus; document.getElementById('histo-attente').textContent = attente; document.getElementById('histo-taux').textContent = taux; const resLabels = { gagne:'🏆 Gagné', perdu:'❌ Perdu', attente:'⏳ En attente', annule:'⏸ Annulé' }; const resClasses = { gagne:'res-gagne', perdu:'res-perdu', attente:'res-attente', annule:'res-annule' }; historiqueAo.forEach(h => { const div = document.createElement('div'); div.className = 'histo-ao-item'; // Bande gauche selon résultat const borderColors = { gagne:'rgba(63,185,80,.9)', perdu:'rgba(192,94,94,.9)', attente:'rgba(210,153,34,.9)', annule:'rgba(255,255,255,.2)' }; div.style.borderLeft = '6px solid ' + (borderColors[h.resultat] || 'rgba(255,255,255,.1)'); div.innerHTML = `
${h.score}
${h.title}
${h.src} ${h.acheteur} 💵 ${h.budget} 📅 Répondu le ${h.dateReponse} ${h.comment ? '"' + h.comment + '"' : ''}
${resLabels[h.resultat]}
`; list.appendChild(div); }); } function changerResultat(idx) { ouvrirRepondu(idx); } /* TRI FLUX */ let curTri = 'score', curDir = 'desc'; function setTri(field, btn) { if (curTri === field) { curDir = curDir === 'desc' ? 'asc' : 'desc'; } else { curTri = field; curDir = field === 'src' ? 'asc' : 'desc'; } const labels = { score:'Score', dl:'Échéance', date:'Date', budget:'Budget', src:'Source' }; // Reset tous les boutons avec leur nom sans flèche document.querySelectorAll('.tri-btn').forEach(b => { const f = b.id.replace('tri-',''); b.textContent = labels[f] || f; b.classList.remove('tri-asc','tri-desc'); }); // Bouton actif : nom + flèche btn.textContent = labels[field] + (curDir === 'desc' ? ' ↓' : ' ↑'); btn.classList.add(curDir === 'desc' ? 'tri-desc' : 'tri-asc'); renderFlux(); } function triSort(arr) { const d = curDir === 'desc' ? -1 : 1; return [...arr].sort((a,b) => { if (curTri==='score') return d*(a.score - b.score); if (curTri==='dl') return d*(a.dlDays - b.dlDays); if (curTri==='date') return d*(a.date.localeCompare(b.date)); if (curTri==='budget') { const pa = parseFloat(a.budget)||0, pb = parseFloat(b.budget)||0; return d*(pa - pb); } if (curTri==='src') return d*a.src.localeCompare(b.src); return d*(a.score - b.score); }); } /* ============================================================ PONDÉRATION SCORING */ const CRITERES = [ { id:'keywords', label:'Mots-clés', color:'rgba(123,111,224,.9)', val:40, on:true }, { id:'geo', label:'Géographie', color:'rgba(78,168,168,.9)', val:25, on:true }, { id:'budget', label:'Budget', color:'rgba(210,153,34,.9)', val:20, on:true }, { id:'type', label:'Type mission', color:'rgba(63,185,80,.9)', val:10, on:true }, { id:'proc', label:'Procédure', color:'rgba(192,94,94,.9)', val:5, on:true }, ]; function buildPoids() { const container = document.getElementById('poids-container'); if (!container) return; container.innerHTML = ''; CRITERES.forEach((cr, i) => { const row = document.createElement('div'); row.style.cssText = 'display:grid;grid-template-columns:130px 1fr 44px;align-items:center;gap:10px;opacity:' + (cr.on ? '1' : '.35'); row.id = 'poids-row-' + cr.id; row.innerHTML = ` ${cr.label} ${cr.val}% `; container.appendChild(row); }); refreshPoidsTotal(); } function updatePoids(idx, val) { CRITERES[idx].val = val; document.getElementById('pval-' + CRITERES[idx].id).textContent = val + '%'; refreshPoidsTotal(); } function refreshPoidsTotal() { const total = CRITERES.reduce((s, cr) => s + (cr.on ? cr.val : 0), 0); const lbl = document.getElementById('poids-total-lbl'); const btn = document.getElementById('poids-save-btn'); if (!lbl) return; if (total === 100) { lbl.textContent = 'TOTAL : 100% ✓'; lbl.style.color = 'rgba(63,185,80,.9)'; if (btn) { btn.style.opacity='1'; btn.style.cursor='pointer'; btn.style.pointerEvents='auto'; } } else { lbl.textContent = 'TOTAL : ' + total + '% ⚠'; lbl.style.color = 'rgba(192,94,94,.9)'; if (btn) { btn.style.opacity='.3'; btn.style.cursor='not-allowed'; btn.style.pointerEvents='none'; } } } function toggleCritere(id, toggleEl) { const cr = CRITERES.find(c => c.id === id); if (!cr) return; cr.on = !cr.on; toggleEl.dataset.on = cr.on ? '1' : '0'; // Griser le panel const panel = toggleEl.closest('.panel'); if (panel) panel.classList.toggle('critere-off', !cr.on); // Rebuild sliders buildPoids(); // Si on désactive un critère, redistribuer son poids aux autres actifs if (!cr.on) { const freed = cr.val; cr.val = 0; const others = CRITERES.filter(c => c.on); if (others.length > 0) { const share = Math.floor(freed / others.length); let rem = freed - share * others.length; others.forEach(o => { o.val += share; }); others[0].val += rem; } buildPoids(); } refreshPoidsTotal(); } function savePoids() { const total = CRITERES.reduce((s,cr) => s + (cr.on ? cr.val : 0), 0); if (total !== 100) return; showToast('💾 Pondération enregistrée'); } /* ============================================================ PONDÉRATEUR */ const POND_CR = [ {id:'keywords', label:'Mots-clés', color:'rgba(123,111,224,.95)', fill:'rgba(123,111,224,.65)', val:35, on:true}, {id:'geo', label:'Géographie', color:'rgba(78,168,168,.95)', fill:'rgba(78,168,168,.65)', val:25, on:true}, {id:'budget', label:'Budget', color:'rgba(210,153,34,.95)', fill:'rgba(210,153,34,.65)', val:20, on:true}, {id:'type', label:'Type mission', color:'rgba(63,185,80,.95)', fill:'rgba(63,185,80,.65)', val:10, on:true}, {id:'proc', label:'Procédure', color:'rgba(192,94,94,.95)', fill:'rgba(192,94,94,.65)', val:5, on:true}, {id:'grp', label:'Groupement', color:'rgba(255,180,50,.95)', fill:'rgba(255,180,50,.65)', val:5, on:true}, ]; // Mapping critère → panel scoring const POND_PANELS = {keywords:'panel-kw', geo:'panel-geo', budget:'panel-bud', type:'panel-type', proc:'panel-proc', grp:'panel-grp'}; function buildPoids() { const wrap = document.getElementById('pond-sliders'); if (!wrap) return; wrap.innerHTML = ''; POND_CR.forEach((cr, i) => { const row = document.createElement('div'); row.className = 'pond-row'; row.id = 'pond-row-'+i; row.style.opacity = cr.on ? '1' : '0.3'; row.innerHTML = '
' + '
'+ cr.label+ '
'+ '
' + '
'+ '
'+ '
'+ '
'+(cr.on ? cr.val+'%' : '—')+'
'; wrap.appendChild(row); }); pondRefreshBar(); pondRefreshTotal(); } function pondSetVal(i, v) { v = Math.max(0, Math.min(60, Math.round(v))); POND_CR[i].val = v; const fill=document.getElementById('pond-fill-'+i), thumb=document.getElementById('pond-thumb-'+i), val=document.getElementById('pond-val-'+i); if(fill) fill.style.width=v+'%'; if(thumb) thumb.style.left=v+'%'; if(val) val.textContent = POND_CR[i].on ? v+'%' : '—'; pondRefreshBar(); pondRefreshTotal(); } function pondClickTrack(e, i) { if (!POND_CR[i].on) return; const rect = document.getElementById('pond-track-'+i).getBoundingClientRect(); pondSetVal(i, Math.round((e.clientX - rect.left) / rect.width * 100)); } let pondDrag = null; function pondStartDrag(e, i) { if (!POND_CR[i].on) return; e.preventDefault(); pondDrag = i; document.addEventListener('mousemove', pondOnDrag); document.addEventListener('mouseup', pondStopDrag); document.addEventListener('touchmove', pondOnDrag, {passive:false}); document.addEventListener('touchend', pondStopDrag); } function pondOnDrag(e) { if (pondDrag===null) return; e.preventDefault(); const rect = document.getElementById('pond-track-'+pondDrag).getBoundingClientRect(); const cx = e.touches ? e.touches[0].clientX : e.clientX; pondSetVal(pondDrag, Math.round((cx - rect.left) / rect.width * 100)); } function pondStopDrag() { pondDrag=null; document.removeEventListener('mousemove',pondOnDrag); document.removeEventListener('mouseup',pondStopDrag); document.removeEventListener('touchmove',pondOnDrag); document.removeEventListener('touchend',pondStopDrag); } function pondToggle(i) { const cr = POND_CR[i]; cr.on = !cr.on; const on = cr.on; const sw=document.getElementById('pond-sw-'+i), row=document.getElementById('pond-row-'+i), thumb=document.getElementById('pond-thumb-'+i), val=document.getElementById('pond-val-'+i); sw.classList.toggle('on', on); row.style.opacity = on ? '1' : '0.3'; if(thumb) thumb.style.display = on ? '' : 'none'; if(val) val.textContent = on ? cr.val+'%' : '—'; // Griser le panel correspondant const panelId = POND_PANELS[cr.id]; if(panelId){ const p=document.getElementById(panelId); if(p) { p.classList.toggle('cr-off',!on); } } // Redistribuer le poids if (!on) { const freed=cr.val; cr.val=0; pondSetVal(i,0); const others = POND_CR.map((c,j)=>({c,j})).filter(({c,j})=>c.on && j!==i); if(others.length){ const share=Math.floor(freed/others.length), rem=freed-share*others.length; others.forEach(({c,j},k)=>{ c.val+=share+(k===0?rem:0); pondSetVal(j,c.val); }); } } pondRefreshBar(); pondRefreshTotal(); } function pondRefreshTotal() { const total = POND_CR.filter(c=>c.on).reduce((s,c)=>s+c.val,0); const el=document.getElementById('pond-total'), btn=document.getElementById('pond-save-btn'); if(total===100){ el.textContent='TOTAL : 100% ✓'; el.style.color='rgba(63,185,80,.9)'; if(btn) btn.disabled=false; } else { el.textContent='TOTAL : '+total+'% ⚠'; el.style.color=total>100?'rgba(192,94,94,.9)':'rgba(210,153,34,.9)'; if(btn) btn.disabled=true; } } function pondRefreshBar() { const bar=document.getElementById('pond-bar'), lbls=document.getElementById('pond-lbls'); if(!bar||!lbls) return; const active=POND_CR.filter(c=>c.on), total=active.reduce((s,c)=>s+c.val,0)||1; bar.innerHTML=''; lbls.innerHTML=''; active.forEach(cr=>{ const pct=Math.round(cr.val/total*100); const seg=document.createElement('div'); seg.className='pond-seg'; seg.style.cssText='width:'+pct+'%;background:'+cr.fill; bar.appendChild(seg); const lbl=document.createElement('div'); lbl.className='pond-bar-lbl'; lbl.style.cssText='width:'+pct+'%;color:'+cr.color; lbl.textContent=pct>9?cr.label:''; lbls.appendChild(lbl); }); } function savePoids() { const total=POND_CR.filter(c=>c.on).reduce((s,c)=>s+c.val,0); if(total!==100) return; showToast('💾 Pondération enregistrée'); const btn=document.getElementById('pond-save-btn'); if(btn){ btn.textContent='✓ Enregistré !'; btn.style.color='rgba(63,185,80,.9)'; btn.style.borderColor='rgba(63,185,80,.5)'; btn.style.background='rgba(63,185,80,.1)'; setTimeout(()=>{ btn.textContent='💾 Enregistrer'; btn.style.color=''; btn.style.borderColor=''; btn.style.background=''; },2000); } } /* FAVORIS ACHETEURS */ function addFavlist() { const name = document.getElementById('fav-new-name')?.value.trim(); const reason = document.getElementById('fav-new-reason')?.value.trim(); if (!name) return; const row = document.createElement('div'); row.className = 'fav-row'; row.innerHTML = '⭐ '+name+''+(reason||'')+'× Retirer'; document.getElementById('favlist').appendChild(row); document.getElementById('fav-new-name').value = ''; document.getElementById('fav-new-reason').value = ''; showToast('⭐ '+name+' ajouté aux acheteurs favoris'); } /* GROUPEMENT */ let currentGroupement = 'solo'; function selectGroupement(val, el) { currentGroupement = val; ['solo','mandataire','cotraitant','indifferent'].forEach(v => { const btn = document.getElementById('grp-'+v); if (!btn) return; btn.classList.remove('on'); btn.classList.add('off'); const chk = btn.querySelector('.rgl-chk'); if(chk) chk.textContent = ''; }); el.classList.remove('off'); el.classList.add('on'); const chk = el.querySelector('.rgl-chk'); if(chk) chk.textContent = '✓'; } /* MOTS-CLÉS */ function removeKw(el) { el.style.transform = 'scale(0)'; el.style.opacity = '0'; setTimeout(() => el.remove(), 150); } function addKw(type) { const input = document.getElementById('kw-'+type+'-input'); if (!input) return; const val = input.value.trim(); if (!val) return; const list = document.getElementById('kw-'+type+'-list'); const wrap = list.querySelector('.kw-add-wrap'); const span = document.createElement('span'); span.className = 'kw-'+type; span.onclick = function(){ removeKw(this); }; span.innerHTML = val + ' ×'; span.style.transform = 'scale(0.8)'; span.style.opacity = '0'; list.insertBefore(span, wrap); requestAnimationFrame(() => { span.style.transition = 'all .15s ease'; span.style.transform = 'scale(1)'; span.style.opacity = '1'; }); input.value = ''; input.focus(); } function addKwOnEnter(e, type) { if (e.key === 'Enter') { e.preventDefault(); addKw(type); } } /* AGENCES & GÉOCODAGE */ let agenceCount = 1; async function geocodeAdresse(adresse) { // Nominatim OpenStreetMap — appel réel const url = 'https://nominatim.openstreetmap.org/search?q=' + encodeURIComponent(adresse + ', France') + '&format=json&limit=1'; try { const r = await fetch(url, {headers:{'Accept-Language':'fr', 'User-Agent':'JFH-VRD-Veille/1.0'}}); const data = await r.json(); if (data && data.length > 0) { return { lat: parseFloat(data[0].lat).toFixed(4), lng: parseFloat(data[0].lon).toFixed(4), ok: true }; } } catch(e) {} return { lat: null, lng: null, ok: false }; } async function addAgence() { const nom = document.getElementById('new-agence-nom')?.value.trim(); const adresse = document.getElementById('new-agence-adresse')?.value.trim(); if (!nom || !adresse) { showToast('⚠ Renseignez le nom et l\'adresse'); return; } const status = document.getElementById('geocode-status'); status.style.display = 'block'; status.textContent = '🔍 Géocodage en cours...'; status.style.color = 'rgba(210,153,34,.8)'; const coords = await geocodeAdresse(adresse); const idx = agenceCount++; const coordsStr = coords.ok ? '📍 ' + coords.lat + ', ' + coords.lng : '⚠ Coordonnées non trouvées'; const coordsColor = coords.ok ? 'rgba(78,168,168,.7)' : 'rgba(192,94,94,.7)'; status.textContent = coords.ok ? '✓ Localisé : ' + coordsStr : '⚠ Adresse non trouvée — vérifiez la saisie'; status.style.color = coords.ok ? 'rgba(63,185,80,.8)' : 'rgba(192,94,94,.8)'; const div = document.createElement('div'); div.className = 'agence-row'; div.id = 'agence-' + idx; div.innerHTML = `
🏢 AGENCE ${nom} ${adresse} ${coordsStr}
Rayon plein score 80 km
Pénalité hors rayon -20 pts
Pénalité hors dept. -30 pts
`; document.getElementById('agences-list').appendChild(div); document.getElementById('new-agence-nom').value = ''; document.getElementById('new-agence-adresse').value = ''; setTimeout(() => { status.style.display = 'none'; }, 3000); } function removeAgence(idx) { const row = document.getElementById('agence-' + idx); if (row) { row.style.opacity = '0'; row.style.transform = 'translateX(-10px)'; setTimeout(() => row.remove(), 200); } } async function relocaliserSiege() { const adresse = '12 rue des Fusillés, 80000 Amiens'; const coordEl = document.getElementById('coords-0'); if (coordEl) coordEl.textContent = '🔍 Géocodage...'; const coords = await geocodeAdresse(adresse); if (coordEl) coordEl.textContent = coords.ok ? '📍 '+coords.lat+', '+coords.lng : '⚠ Non trouvé'; } /* BUDGET DOUBLE SLIDER */ const BUD_MIN_ABS = 0, BUD_MAX_ABS = 5000; let budMin = 50, budMax = 600, budDragging = null; function budUpdate() { const track = document.getElementById('bud-thumb-min')?.closest('[style*="position:relative;padding"]'); if (!track) return; const trackEl = track.querySelector('[style*="height:8px"]'); if (!trackEl) return; const W = trackEl.offsetWidth; const pMin = (budMin - BUD_MIN_ABS) / (BUD_MAX_ABS - BUD_MIN_ABS); const pMax = (budMax - BUD_MIN_ABS) / (BUD_MAX_ABS - BUD_MIN_ABS); // Thumbs const tMin = document.getElementById('bud-thumb-min'); const tMax = document.getElementById('bud-thumb-max'); if (tMin) tMin.style.left = (pMin * 100) + '%'; if (tMax) tMax.style.left = (pMax * 100) + '%'; // Zones colorées const lExcl = document.getElementById('bud-left-excl'); const ideal = document.getElementById('bud-ideal-fill'); const rExcl = document.getElementById('bud-right-excl'); if (lExcl) lExcl.style.width = (pMin * 100) + '%'; if (ideal) { ideal.style.left = (pMin * 100) + '%'; ideal.style.width = ((pMax - pMin) * 100) + '%'; } if (rExcl) rExcl.style.width = ((1 - pMax) * 100) + '%'; // Labels const minL = document.getElementById('bud-min-lbl'); const maxL = document.getElementById('bud-max-lbl'); const idealL = document.getElementById('bud-ideal-lbl'); const rangeL = document.getElementById('bud-range-lbl'); const fmtBud = v => v >= 1000 ? (v/1000).toFixed(v%1000===0?0:1)+'M€' : v+' k€'; if (minL) minL.textContent = fmtBud(budMin); if (maxL) maxL.textContent = fmtBud(budMax); if (idealL) idealL.textContent = fmtBud(budMin) + ' → ' + fmtBud(budMax); if (rangeL) rangeL.textContent = fmtBud(budMin) + ' → ' + fmtBud(budMax); } function budStartDrag(e, which) { e.preventDefault(); budDragging = which; document.addEventListener('mousemove', budOnDrag); document.addEventListener('mouseup', budStopDrag); document.addEventListener('touchmove', budOnDrag, {passive:false}); document.addEventListener('touchend', budStopDrag); } function budOnDrag(e) { if (!budDragging) return; e.preventDefault(); const trackEl = document.getElementById('bud-thumb-min')?.closest('[style*="position:relative;padding"]')?.querySelector('[style*="height:8px"]'); if (!trackEl) return; const rect = trackEl.getBoundingClientRect(); const cx = e.touches ? e.touches[0].clientX : e.clientX; const pct = Math.max(0, Math.min(1, (cx - rect.left) / rect.width)); const rawVal = BUD_MIN_ABS + pct * (BUD_MAX_ABS - BUD_MIN_ABS); const step = rawVal < 200 ? 10 : rawVal < 1000 ? 50 : 100; const val = Math.round(rawVal / step) * step; if (budDragging === 'min') { budMin = Math.min(val, budMax - 10); } else { budMax = Math.max(val, budMin + 10); } budUpdate(); } function budStopDrag() { budDragging = null; document.removeEventListener('mousemove', budOnDrag); document.removeEventListener('mouseup', budStopDrag); document.removeEventListener('touchmove', budOnDrag); document.removeEventListener('touchend', budStopDrag); } /* COMPTEUR MENSUEL AO */ function updateMoisAo() { const mois = ['JANV','FÉVR','MARS','AVR','MAI','JUIN','JUIL','AOÛT','SEPT','OCT','NOV','DÉC']; const m = mois[new Date().getMonth()]; const el = document.getElementById('tb-mois-ao'); if (el) el.innerHTML = m + ' : 247 AO'; } function startClock(){ const el=document.getElementById('tb-clock'); function tick(){const n=new Date(); el.textContent=[n.getHours(),n.getMinutes(),n.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':');} tick(); setInterval(tick,1000); } document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();}); document.getElementById('inp-p').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();}); document.getElementById('inp-u').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('inp-p').focus();});