Last active
May 12, 2026 10:21
-
-
Save ColinMaudry/2ba36a2fe0ae98999e73eb53472313be to your computer and use it in GitHub Desktop.
JS adapté pour utiliser decp.info et non data.economie
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="robots" content="noindex, nofollow"> | |
| <title>Portail Commande Publique — Bêta</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700;1,400&family=Source+Code+Pro:wght@400;500&display=swap'); | |
| :root { | |
| --blue: #003189; | |
| --blue-dark: #001F5A; | |
| --blue-light: #E8EDF8; | |
| --blue-mid: #B3C2E0; | |
| --green: #18753C; | |
| --green-pale: #DFFBE8; | |
| --orange: #B34000; | |
| --orange-pale: #FFF0DC; | |
| --purple: #6A00F4; | |
| --purple-pale: #EDE3FF; | |
| --teal: #0E7490; | |
| --teal-pale: #E0F2FE; | |
| --grey-50: #F8F9FB; | |
| --grey-100: #EFEFEF; | |
| --grey-400: #9E9E9E; | |
| --grey-600: #666666; | |
| --grey-800: #3A3A3A; | |
| --grey-900: #1E1E1E; | |
| --white: #FFFFFF; | |
| --border: #D8D8D8; | |
| --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); | |
| --shadow-md: 0 2px 8px rgba(0,0,0,0.1); | |
| } | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| body { font-family:'Source Sans 3',Arial,sans-serif; background:var(--grey-50); color:var(--grey-900); font-size:15px; line-height:1.5; } | |
| /* ══ HEADER ══ */ | |
| .site-header { background:var(--white); border-bottom:3px solid var(--blue); padding:0 32px; position:sticky; top:0; z-index:100; } | |
| .site-header-inner { max-width:1280px; margin:0 auto; display:flex; align-items:center; height:64px; gap:16px; } | |
| .site-title { font-size:18px; font-weight:700; color:var(--blue); white-space:nowrap; } | |
| .site-sub { font-size:11px; color:var(--grey-400); margin-top:1px; } | |
| .hd-divider { width:1px; height:32px; background:var(--border); flex-shrink:0; } | |
| .beta-badge { background:var(--orange-pale); color:var(--orange); font-size:10px; font-weight:700; letter-spacing:1px; padding:3px 8px; border-radius:3px; text-transform:uppercase; white-space:nowrap; } | |
| .hd-nav { display:flex; gap:8px; margin-left:auto; align-items:center; } | |
| .hd-nav-btn { | |
| padding:8px 20px; height:40px; display:flex; align-items:center; gap:6px; | |
| font-size:14px; font-weight:600; color:var(--grey-600); | |
| background:var(--grey-100); border:2px solid transparent; border-radius:6px; | |
| cursor:pointer; font-family:inherit; transition:all 0.15s; white-space:nowrap; | |
| } | |
| .hd-nav-btn:hover { background:var(--blue-light); color:var(--blue); border-color:var(--blue-mid); } | |
| .hd-nav-btn.active { background:var(--blue); color:white; border-color:var(--blue); font-weight:700; box-shadow:0 2px 6px rgba(0,49,137,0.3); } | |
| /* ══ BANNER ══ */ | |
| .beta-banner { background:#EFF6FF; border-bottom:1px solid var(--blue-mid); padding:7px 32px; font-size:12px; color:var(--blue); display:flex; align-items:center; gap:10px; flex-wrap:wrap; } | |
| .ldot { display:inline-flex; align-items:center; gap:4px; font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 7px; border-radius:3px; letter-spacing:0.5px; } | |
| .ldot.live { background:var(--green-pale); color:var(--green); } | |
| .ldot::before { content:''; width:5px; height:5px; border-radius:50%; } | |
| .ldot.live::before { background:var(--green); animation:pulse 2s infinite; } | |
| @keyframes pulse { 0%,100%{opacity:1;}50%{opacity:0.3;} } | |
| /* ══ VUES ══ */ | |
| .view { display:none; } | |
| .view.active { display:block; } | |
| /* ══ VUE RECHERCHE ══ */ | |
| .hero { background:linear-gradient(135deg,var(--blue) 0%,var(--blue-dark) 100%); padding:36px 32px 44px; } | |
| .hero-inner { max-width:860px; margin:0 auto; } | |
| .hero h1 { font-size:22px; font-weight:700; color:white; margin-bottom:6px; } | |
| .hero p { font-size:14px; color:rgba(255,255,255,0.6); margin-bottom:22px; } | |
| .search-wrap { background:white; border-radius:4px; display:flex; box-shadow:0 4px 16px rgba(0,0,0,0.25); overflow:hidden; } | |
| .search-icon-wrap { display:flex; align-items:center; padding:0 14px; color:var(--grey-400); border-right:1px solid var(--border); flex-shrink:0; } | |
| #qInput { flex:1; border:none; outline:none; font-size:15px; font-family:inherit; color:var(--grey-900); height:52px; padding:0 14px; } | |
| #qInput::placeholder { color:var(--grey-400); } | |
| #searchBtn { background:var(--blue); color:white; border:none; padding:0 28px; font-size:15px; font-weight:600; font-family:inherit; cursor:pointer; transition:background 0.15s; white-space:nowrap; flex-shrink:0; } | |
| #searchBtn:hover { background:var(--blue-dark); } | |
| .filter-bar { margin-top:12px; display:flex; flex-wrap:wrap; gap:8px; } | |
| .fbtn { display:flex; align-items:center; gap:5px; padding:6px 13px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.22); border-radius:4px; color:rgba(255,255,255,0.85); font-size:13px; font-family:inherit; cursor:pointer; transition:all 0.15s; } | |
| .fbtn:hover { background:rgba(255,255,255,0.18); } | |
| .fbtn.on { background:white; color:var(--blue); border-color:white; font-weight:600; } | |
| .fbtn .caret { font-size:9px; opacity:0.6; } | |
| .freset { margin-left:auto; background:transparent; border:1px solid rgba(255,255,255,0.25); color:rgba(255,255,255,0.55); font-size:12px; font-family:inherit; padding:5px 11px; border-radius:4px; cursor:pointer; } | |
| .freset:hover { color:white; border-color:white; } | |
| /* PAGE BODY */ | |
| .page-body { max-width:1280px; margin:0 auto; padding:28px 32px 60px; } | |
| /* ÉTAT INITIAL */ | |
| .initial-state { text-align:center; padding:60px 20px; color:var(--grey-400); } | |
| .initial-state .big-icon { font-size:48px; margin-bottom:16px; } | |
| .initial-state p { font-size:15px; line-height:1.6; } | |
| .initial-state strong { color:var(--grey-600); } | |
| /* FICHE */ | |
| .result-header { background:white; border:1px solid var(--border); border-left:4px solid var(--blue); border-radius:4px; padding:18px 22px; margin-bottom:22px; display:flex; align-items:flex-start; justify-content:space-between; gap:20px; box-shadow:var(--shadow-sm); } | |
| .rh-name { font-size:18px; font-weight:700; color:var(--blue); margin-bottom:3px; } | |
| .kpis { display:flex; gap:10px; flex-shrink:0; } | |
| .kpi { text-align:center; padding:10px 16px; background:var(--grey-50); border:1px solid var(--border); border-radius:4px; min-width:88px; } | |
| .kpi-val { font-size:20px; font-weight:700; color:var(--blue); display:block; } | |
| .kpi-lbl { font-size:10px; color:var(--grey-600); margin-top:2px; line-height:1.3; } | |
| /* ONGLETS */ | |
| .tabs { display:flex; border-bottom:2px solid var(--border); margin-bottom:22px; } | |
| .tab { padding:10px 18px; font-size:14px; font-weight:500; color:var(--grey-600); cursor:pointer; border-bottom:3px solid transparent; margin-bottom:-2px; display:flex; align-items:center; gap:6px; transition:color 0.15s; } | |
| .tab:hover { color:var(--blue); } | |
| .tab.active { color:var(--blue); border-bottom-color:var(--blue); font-weight:700; } | |
| .tab .tbadge { background:var(--grey-100); color:var(--grey-600); padding:1px 7px; border-radius:10px; font-size:11px; } | |
| .tab.active .tbadge { background:var(--blue-light); color:var(--blue); } | |
| .tab-pane { display:none; } | |
| .tab-pane.active { display:block; } | |
| /* LAYOUT 2 COL */ | |
| .two-col { display:grid; grid-template-columns:240px 1fr; gap:18px; align-items:start; } | |
| /* SIDEBAR */ | |
| .sidebar { background:white; border:1px solid var(--border); border-radius:4px; overflow:hidden; box-shadow:var(--shadow-sm); } | |
| .sb-hd { padding:10px 14px; font-size:11px; font-weight:700; color:var(--grey-600); text-transform:uppercase; letter-spacing:0.6px; background:var(--grey-50); border-bottom:1px solid var(--border); } | |
| .fg { padding:12px 14px; border-bottom:1px solid var(--border); } | |
| .fg:last-child { border-bottom:none; } | |
| .fg-lbl { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; color:var(--grey-800); margin-bottom:7px; } | |
| .fo { display:flex; align-items:center; justify-content:space-between; padding:3px 0; cursor:pointer; } | |
| .fo-l { display:flex; align-items:center; gap:7px; } | |
| .fo-t { font-size:13px; color:var(--grey-800); } | |
| .fo:hover .fo-t { color:var(--blue); } | |
| .fo-c { font-size:10px; color:var(--grey-400); background:var(--grey-100); padding:1px 6px; border-radius:10px; } | |
| .fo input[type=checkbox] { width:14px; height:14px; accent-color:var(--blue); cursor:pointer; } | |
| .src-box { padding:9px 11px; background:var(--grey-50); border:1px solid var(--border); border-radius:3px; font-size:11px; color:var(--grey-600); line-height:1.5; margin-top:4px; } | |
| /* RESULT CARDS */ | |
| .result-list { display:flex; flex-direction:column; gap:10px; } | |
| .rcard { background:white; border:1px solid var(--border); border-radius:4px; padding:14px 18px; cursor:pointer; transition:all 0.15s; box-shadow:var(--shadow-sm); position:relative; } | |
| .rcard:hover { border-color:var(--blue); box-shadow:var(--shadow-md); } | |
| .rcard.isnew::after { content:'Nouveau'; position:absolute; top:10px; right:10px; background:var(--green); color:white; font-size:9px; font-weight:700; letter-spacing:1px; padding:2px 7px; border-radius:2px; } | |
| .rc-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:7px; } | |
| .rc-title { font-size:14px; font-weight:600; color:var(--blue); text-decoration:none; line-height:1.4; } | |
| .rc-title:hover { text-decoration:underline; } | |
| .rc-badges { display:flex; gap:5px; flex-shrink:0; } | |
| .badge { display:inline-flex; align-items:center; padding:3px 8px; border-radius:3px; font-size:11px; font-weight:600; white-space:nowrap; } | |
| .badge.ouvert { background:var(--green-pale); color:var(--green); } | |
| .badge.prog { background:var(--orange-pale); color:var(--orange); } | |
| .badge.attr { background:var(--purple-pale); color:var(--purple); } | |
| .rc-meta { display:flex; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--grey-600); } | |
| .rc-mi { display:flex; align-items:center; gap:4px; } | |
| .rc-mi strong { color:var(--grey-900); font-weight:600; } | |
| .rc-ft { display:flex; align-items:center; justify-content:space-between; margin-top:9px; padding-top:9px; border-top:1px solid var(--grey-100); } | |
| .rc-src { display:flex; align-items:center; gap:5px; font-size:11px; color:var(--grey-400); } | |
| .rc-lnk { font-size:12px; color:var(--blue); text-decoration:none; font-weight:600; } | |
| .rc-lnk:hover { text-decoration:underline; } | |
| /* LOADING / EMPTY */ | |
| .loading-state { display:flex; align-items:center; gap:10px; padding:22px 16px; color:var(--grey-600); font-size:13px; background:white; border:1px solid var(--border); border-radius:4px; } | |
| .spinner { width:18px; height:18px; border:2px solid var(--border); border-top-color:var(--blue); border-radius:50%; animation:spin 0.7s linear infinite; flex-shrink:0; } | |
| @keyframes spin { to { transform:rotate(360deg); } } | |
| .empty-state { padding:22px 16px; color:var(--grey-600); font-size:13px; background:white; border:1px solid var(--border); border-radius:4px; line-height:1.6; } | |
| .empty-state a { color:var(--blue); } | |
| /* ══ VUE OBSERVATOIRE ══ */ | |
| .obs-hero { background:linear-gradient(135deg,var(--blue) 0%,var(--blue-dark) 100%); padding:36px 32px 44px; } | |
| .obs-hero-inner { max-width:1280px; margin:0 auto; } | |
| .obs-hero h1 { font-size:22px; font-weight:700; color:white; margin-bottom:6px; } | |
| .obs-hero p { font-size:14px; color:rgba(255,255,255,0.65); margin-bottom:24px; } | |
| /* FILTRES OBSERVATOIRE */ | |
| .obs-filters { display:flex; flex-wrap:wrap; gap:10px; align-items:center; } | |
| .obs-filter-group { display:flex; flex-direction:column; gap:4px; } | |
| .obs-filter-label { font-size:10px; color:rgba(255,255,255,0.5); text-transform:uppercase; letter-spacing:1px; font-weight:600; } | |
| .obs-filter-select { padding:7px 12px; border-radius:4px; border:1px solid rgba(255,255,255,0.25); background:rgba(255,255,255,0.1); color:white; font-size:13px; font-family:inherit; cursor:pointer; } | |
| .obs-filter-select option { background:var(--blue-dark); color:white; } | |
| .obs-search-btn { padding:8px 20px; background:white; color:var(--blue); border:none; border-radius:4px; font-size:14px; font-weight:700; font-family:inherit; cursor:pointer; transition:all 0.15s; align-self:flex-end; } | |
| .obs-search-btn:hover { background:var(--blue-light); } | |
| /* OBSERVATOIRE BODY */ | |
| .obs-body { max-width:1280px; margin:0 auto; padding:28px 32px 60px; } | |
| /* AXES TABS */ | |
| .obs-axes { display:flex; gap:8px; margin-bottom:24px; flex-wrap:wrap; } | |
| .obs-axis-btn { | |
| display:flex; align-items:center; gap:6px; padding:8px 16px; | |
| border-radius:4px; border:2px solid var(--border); | |
| background:white; color:var(--grey-600); | |
| font-size:13px; font-weight:500; font-family:inherit; cursor:pointer; | |
| transition:all 0.15s; | |
| } | |
| .obs-axis-btn:hover { border-color:var(--blue); color:var(--blue); } | |
| .obs-axis-btn.active.acheteur { background:var(--blue-light); color:var(--blue); border-color:var(--blue); font-weight:700; } | |
| .obs-axis-btn.active.territoire { background:var(--green-pale); color:var(--green); border-color:var(--green); font-weight:700; } | |
| .obs-axis-btn.active.secteur { background:var(--orange-pale); color:var(--orange); border-color:var(--orange); font-weight:700; } | |
| .obs-axis-btn.active.titulaire { background:var(--purple-pale); color:var(--purple); border-color:var(--purple); font-weight:700; } | |
| /* CONTEXTE OBSERVATOIRE */ | |
| .obs-context { | |
| background:white; border:1px solid var(--border); border-radius:4px; | |
| padding:14px 18px; margin-bottom:20px; | |
| display:flex; align-items:center; gap:12px; flex-wrap:wrap; | |
| box-shadow:var(--shadow-sm); | |
| } | |
| .obs-ctx-scope { font-size:14px; font-weight:700; color:var(--grey-900); } | |
| .obs-ctx-sub { font-size:12px; color:var(--grey-400); font-style:italic; } | |
| .obs-ctx-right { margin-left:auto; font-size:12px; color:var(--grey-400); } | |
| /* GRILLES ET CHARTS */ | |
| .obs-kpi-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:20px; } | |
| .obs-kpi { background:white; border:1px solid var(--border); border-radius:4px; padding:16px; box-shadow:var(--shadow-sm); } | |
| .obs-kpi-val { font-size:26px; font-weight:700; color:var(--blue); } | |
| .obs-kpi-lbl { font-size:12px; color:var(--grey-600); margin-top:3px; } | |
| .obs-kpi-src { margin-top:6px; } | |
| .obs-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:20px; } | |
| .chart-card { background:white; border:1px solid var(--border); border-radius:4px; overflow:hidden; box-shadow:var(--shadow-sm); } | |
| .chart-hd { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; } | |
| .chart-title { font-size:13px; font-weight:700; color:var(--grey-900); } | |
| .chart-sub { font-size:11px; color:var(--grey-400); } | |
| .chart-body { padding:14px 16px; } | |
| .bar-row { display:grid; grid-template-columns:150px 1fr 75px; align-items:center; gap:8px; margin-bottom:6px; } | |
| .bar-lbl { font-size:11px; color:var(--grey-800); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| .bar-track { background:var(--grey-100); border-radius:2px; height:16px; overflow:hidden; } | |
| .bar-fill { height:100%; border-radius:2px; transition:width 0.8s ease; } | |
| .bar-val { font-size:11px; color:var(--grey-600); font-weight:600; } | |
| .data-table { width:100%; border-collapse:collapse; font-size:12px; } | |
| .data-table th { background:var(--grey-50); padding:7px 10px; text-align:left; font-size:10px; font-weight:700; color:var(--grey-600); text-transform:uppercase; letter-spacing:0.5px; border-bottom:2px solid var(--border); } | |
| .data-table td { padding:7px 10px; border-bottom:1px solid var(--grey-100); color:var(--grey-800); } | |
| .data-table tr:hover td { background:var(--grey-50); } | |
| .data-table .num { font-weight:700; color:var(--grey-400); text-align:center; } | |
| .data-table .amt { font-weight:600; color:var(--blue); text-align:right; } | |
| /* ALERTE DONNÉES */ | |
| .data-alert { background:#FFFBEB; border:1px solid #FCD34D; border-radius:4px; padding:12px 16px; font-size:12px; color:#92400E; line-height:1.6; margin-bottom:20px; } | |
| /* DECP PROMO */ | |
| .decp-promo { background:var(--blue-light); border:1px solid var(--blue-mid); border-radius:4px; padding:14px 18px; display:flex; align-items:center; gap:14px; margin-top:20px; } | |
| .decp-promo-title { font-weight:700; color:var(--blue); font-size:13px; margin-bottom:3px; } | |
| .decp-promo-text { font-size:12px; color:var(--grey-600); line-height:1.5; } | |
| .decp-promo-btn { background:var(--blue); color:white; padding:8px 16px; border-radius:3px; text-decoration:none; font-size:13px; font-weight:600; white-space:nowrap; flex-shrink:0; } | |
| .decp-promo-btn:hover { background:var(--blue-dark); } | |
| /* FOOTER */ | |
| footer { background:var(--grey-900); color:rgba(255,255,255,0.5); padding:20px 32px; font-size:12px; margin-top:48px; text-align:center; } | |
| footer a { color:rgba(255,255,255,0.7); text-decoration:none; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- HEADER --> | |
| <header class="site-header"> | |
| <div class="site-header-inner"> | |
| <div> | |
| <div class="site-title">Portail Commande Publique</div> | |
| <div class="site-sub">Consultations · Programmations · Données d'attribution</div> | |
| </div> | |
| <div class="hd-divider"></div> | |
| <span class="beta-badge">Bêta</span> | |
| <div class="hd-nav"> | |
| <button class="hd-nav-btn active" id="navRecherche" onclick="showView('recherche')">🔍 Recherche</button> | |
| <button class="hd-nav-btn" id="navObservatoire" onclick="showView('observatoire')">📊 Observatoire</button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- BANNER --> | |
| <div class="beta-banner"> | |
| <span>Données en temps réel :</span> | |
| <span class="ldot live">BOAMP</span> | |
| <span class="ldot live">DECP data.gouv.fr</span> | |
| <span class="ldot live">APProch data.gouv.fr</span> | |
| </div> | |
| <!-- ══════════════════════════════════════════ | |
| VUE RECHERCHE | |
| ══════════════════════════════════════════ --> | |
| <div class="view active" id="view-recherche"> | |
| <div class="hero"> | |
| <div class="hero-inner"> | |
| <h1>Et si tous les marchés publics étaient accessibles en un point unique ?</h1> | |
| <p>Consultations en cours · Programmations prévisionnelles · Données d'attribution</p> | |
| <div class="search-wrap"> | |
| <div class="search-icon-wrap"> | |
| <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/> | |
| </svg> | |
| </div> | |
| <input type="text" id="qInput" placeholder="Nom d'acheteur, objet du marché, code CPV, territoire…" | |
| onkeydown="if(event.key==='Enter') lancerRecherche()" /> | |
| <button id="searchBtn" onclick="lancerRecherche()">Rechercher</button> | |
| </div> | |
| <div class="filter-bar"> | |
| <button class="fbtn" onclick="this.classList.toggle('on')">Période <span class="caret">▾</span></button> | |
| <button class="fbtn" onclick="this.classList.toggle('on')">Catégorie d'achat <span class="caret">▾</span></button> | |
| <button class="fbtn" onclick="this.classList.toggle('on')">Montant estimé <span class="caret">▾</span></button> | |
| <button class="fbtn" onclick="this.classList.toggle('on')">Périmètre géographique <span class="caret">▾</span></button> | |
| <button class="fbtn" onclick="this.classList.toggle('on')">Code CPV <span class="caret">▾</span></button> | |
| <button class="freset" onclick="document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'))">Réinitialiser</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="page-body"> | |
| <div class="initial-state" id="initialState"> | |
| <div class="big-icon">🔍</div> | |
| <p> | |
| Saisissez le nom d'un acheteur public, un objet de marché ou un code CPV.<br> | |
| <strong>Exemple : Euroméditerranée · Métropole Aix-Marseille · MOE aménagement · 45</strong> | |
| </p> | |
| </div> | |
| <div id="resultsZone" style="display:none;"> | |
| <!-- FICHE --> | |
| <div class="result-header"> | |
| <div> | |
| <div class="rh-name" id="rhName">—</div> | |
| <div style="font-size:13px;color:var(--grey-400);margin-top:4px;" id="rhSub"></div> | |
| </div> | |
| <div class="kpis"> | |
| <div class="kpi"><span class="kpi-val" id="kpiBoamp">…</span><div class="kpi-lbl">Avis BOAMP</div></div> | |
| <div class="kpi"><span class="kpi-val" id="kpiApproch">…</span><div class="kpi-lbl">Projets APProch</div></div> | |
| <div class="kpi"><span class="kpi-val" id="kpiDecp">…</span><div class="kpi-lbl">Marchés attribués</div></div> | |
| <div class="kpi"><span class="kpi-val" id="kpiVol">…</span><div class="kpi-lbl">Volume (DECP)</div></div> | |
| </div> | |
| </div> | |
| <!-- ONGLETS --> | |
| <div class="tabs"> | |
| <div class="tab active" id="tabBoamp" onclick="switchTab('boamp',this)">📋 Avis BOAMP <span class="tbadge" id="tBoamp">…</span></div> | |
| <div class="tab" id="tabApproch" onclick="switchTab('approch',this)">📅 Programmation <span class="tbadge" id="tApproch">…</span></div> | |
| <div class="tab" id="tabDecp" onclick="switchTab('decp',this)">✅ Marchés attribués <span class="tbadge" id="tDecp">…</span></div> | |
| <div class="tab" id="tabObs" onclick="switchTab('obs',this)">📊 Observatoire <span style="font-size:11px;color:var(--grey-400);">· zoom</span></div> | |
| </div> | |
| <!-- BOAMP --> | |
| <div class="tab-pane active" id="pane-boamp"> | |
| <div class="two-col"> | |
| <div class="sidebar"> | |
| <div class="sb-hd">Affiner</div> | |
| <div class="fg"> | |
| <div class="fg-lbl">Type d'avis</div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Appel à la concurrence</span></label><span class="fo-c" id="fc-aac">…</span></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Avis d'attribution</span></label><span class="fo-c" id="fc-attr">…</span></div> | |
| </div> | |
| <div class="fg"> | |
| <div class="fg-lbl">Période</div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2025 — 2026</span></label><span class="fo-c" id="fc-rec">…</span></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2023 — 2024</span></label><span class="fo-c" id="fc-old">…</span></div> | |
| </div> | |
| <div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> BOAMP · DILA<br><span style="font-size:10px;color:var(--grey-400)">Mis à jour 2×/jour</span></div></div> | |
| </div> | |
| <div id="boampList"><div class="loading-state"><div class="spinner"></div>Chargement des avis BOAMP…</div></div> | |
| </div> | |
| </div> | |
| <!-- APPROCH --> | |
| <div class="tab-pane" id="pane-approch"> | |
| <div class="two-col"> | |
| <div class="sidebar"> | |
| <div class="sb-hd">Affiner</div> | |
| <div class="fg"> | |
| <div class="fg-lbl">Catégorie</div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Travaux</span></label><span class="fo-c" id="fp-t">…</span></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Services</span></label><span class="fo-c" id="fp-s">…</span></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Fournitures</span></label><span class="fo-c" id="fp-f">…</span></div> | |
| </div> | |
| <div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> APProch · data.gouv.fr<br><span style="font-size:10px;color:var(--grey-400)">Publication volontaire</span></div></div> | |
| </div> | |
| <div id="approchList"><div class="loading-state"><div class="spinner"></div>Chargement des données APProch…</div></div> | |
| </div> | |
| </div> | |
| <!-- DECP --> | |
| <div class="tab-pane" id="pane-decp"> | |
| <div class="two-col"> | |
| <div class="sidebar"> | |
| <div class="sb-hd">Affiner</div> | |
| <div class="fg"> | |
| <div class="fg-lbl">Année</div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2025</span></label></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2024</span></label></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2023</span></label></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2022</span></label></div> | |
| </div> | |
| <div class="fg"> | |
| <div class="fg-lbl">Montant</div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">< 100 k€</span></label></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">100 k€ — 1 M€</span></label></div> | |
| <div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">> 1 M€</span></label></div> | |
| </div> | |
| <div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> DECP · data.economie.gouv.fr<br><span style="font-size:10px;color:var(--grey-400)">Rechargé quotidiennement</span></div></div> | |
| </div> | |
| <div id="decpList"><div class="loading-state"><div class="spinner"></div>Chargement des données DECP…</div></div> | |
| </div> | |
| </div> | |
| <!-- OBS ZOOM (résultats recherche) --> | |
| <div class="tab-pane" id="pane-obs"> | |
| <div class="data-alert"> | |
| 📊 Cet Observatoire est filtré sur votre recherche en cours. Pour une vue nationale sans filtre, accédez à l'<a href="#" onclick="showView('observatoire')" style="color:var(--blue);font-weight:600;">Observatoire →</a> | |
| </div> | |
| <div class="obs-kpi-grid"> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="zo-total">…</div><div class="obs-kpi-lbl">Marchés attribués</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="zo-vol">…</div><div class="obs-kpi-lbl">Volume total</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="zo-moy">…</div><div class="obs-kpi-lbl">Montant moyen</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="zo-tit">…</div><div class="obs-kpi-lbl">Titulaires distincts</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| </div> | |
| <div class="obs-charts-grid"> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Volume par année</div><div class="chart-sub">DECP · notification</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="zo-annual"></div></div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Top titulaires</div><div class="chart-sub">Par volume</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"> | |
| <table class="data-table"><thead><tr><th style="width:24px">#</th><th>Titulaire</th><th style="text-align:right">Volume</th><th style="text-align:right">Nb</th></tr></thead> | |
| <tbody id="zo-tit-rows"></tbody></table> | |
| </div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Répartition par type</div><div class="chart-sub">Code CPV</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="zo-type"></div></div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Top secteurs CPV</div><div class="chart-sub">Par volume</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="zo-cpv"></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ══════════════════════════════════════════ | |
| VUE OBSERVATOIRE | |
| ══════════════════════════════════════════ --> | |
| <div class="view" id="view-observatoire"> | |
| <div class="obs-hero"> | |
| <div class="obs-hero-inner"> | |
| <h1>Observatoire de la Commande Publique</h1> | |
| <p>Vue nationale sur les données d'attribution · Filtrez par année, secteur ou territoire</p> | |
| <div class="obs-filters"> | |
| <div class="obs-filter-group"> | |
| <span class="obs-filter-label">Année</span> | |
| <select class="obs-filter-select" id="obsYear"> | |
| <option value="">Toutes années</option> | |
| <option value="2025">2025</option> | |
| <option value="2024" selected>2024</option> | |
| <option value="2023">2023</option> | |
| <option value="2022">2022</option> | |
| </select> | |
| </div> | |
| <div class="obs-filter-group"> | |
| <span class="obs-filter-label">Type d'acheteur</span> | |
| <select class="obs-filter-select" id="obsTypeAcheteur"> | |
| <option value="">Tous types</option> | |
| <option value="commune">Communes</option> | |
| <option value="departement">Départements</option> | |
| <option value="region">Régions</option> | |
| <option value="etat">État / EPA</option> | |
| <option value="hopital">Hôpitaux</option> | |
| </select> | |
| </div> | |
| <div class="obs-filter-group"> | |
| <span class="obs-filter-label">Recherche libre</span> | |
| <input type="text" id="obsQ" class="obs-filter-select" placeholder="Acheteur, secteur, territoire…" style="width:220px;" onkeydown="if(event.key==='Enter') lancerObservatoire()" /> | |
| </div> | |
| <button class="obs-search-btn" onclick="lancerObservatoire()">Actualiser</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="obs-body"> | |
| <!-- AXES --> | |
| <div class="obs-axes"> | |
| <button class="obs-axis-btn active acheteur" data-axis="acheteur" onclick="switchObsAxis('acheteur',this)">🏛️ Par acheteur</button> | |
| <button class="obs-axis-btn territoire" data-axis="territoire" onclick="switchObsAxis('territoire',this)">📍 Par territoire</button> | |
| <button class="obs-axis-btn secteur" data-axis="secteur" onclick="switchObsAxis('secteur',this)">🏷️ Par secteur CPV</button> | |
| <button class="obs-axis-btn titulaire" data-axis="titulaire" onclick="switchObsAxis('titulaire',this)">🏢 Par titulaire</button> | |
| <span style="margin-left:auto;font-size:12px;color:var(--grey-400);align-self:center;" id="obsAxisDesc">Vue par acheteur · Top acheteurs et leurs fournisseurs</span> | |
| </div> | |
| <!-- CONTEXTE --> | |
| <div class="obs-context"> | |
| <div> | |
| <div class="obs-ctx-scope" id="obsCtxScope">Vue nationale · Toutes données DECP</div> | |
| <div class="obs-ctx-sub" id="obsCtxSub">Chargement en cours…</div> | |
| </div> | |
| <div class="obs-ctx-right"><span class="ldot live">LIVE</span> DECP data.economie.gouv.fr</div> | |
| </div> | |
| <!-- ALERTE COUVERTURE --> | |
| <div class="data-alert"> | |
| ⚠️ <strong>Couverture partielle</strong> — Ces données représentent les marchés publiés en open data (DECP ≥ 40 000 €). Les consultations en cours (PLACE) et les programmations prévisionnelles (APProch) ne sont pas encore intégrées à l'échelle nationale — c'est précisément l'objet de ce portail. | |
| </div> | |
| <!-- KPIs --> | |
| <div class="obs-kpi-grid"> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="o-total">…</div><div class="obs-kpi-lbl" id="o-total-lbl">Marchés attribués</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="o-vol">…</div><div class="obs-kpi-lbl">Volume total</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="o-moy">…</div><div class="obs-kpi-lbl">Montant moyen</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| <div class="obs-kpi"><div class="obs-kpi-val" id="o-tit">…</div><div class="obs-kpi-lbl" id="o-tit-lbl">Titulaires distincts</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div> | |
| </div> | |
| <!-- GRAPHIQUES --> | |
| <div class="obs-charts-grid"> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Volume attribué par année</div><div class="chart-sub">DECP · date de notification</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="o-annual"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title" id="o-chart2-title">Top titulaires par volume</div><div class="chart-sub" id="o-chart2-sub">Toutes années</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"> | |
| <table class="data-table"><thead><tr><th style="width:24px">#</th><th id="o-chart2-col">Titulaire</th><th style="text-align:right">Volume</th><th style="text-align:right">Nb</th></tr></thead> | |
| <tbody id="o-chart2-rows"><tr><td colspan="4" style="padding:14px;text-align:center;color:var(--grey-400)">Chargement…</td></tr></tbody></table> | |
| </div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title">Répartition par type de marché</div><div class="chart-sub">DECP · code CPV</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="o-type"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div> | |
| </div> | |
| <div class="chart-card"> | |
| <div class="chart-hd"><div><div class="chart-title" id="o-chart4-title">Top acheteurs par volume</div><div class="chart-sub" id="o-chart4-sub">Pour cette sélection</div></div><span class="ldot live">LIVE</span></div> | |
| <div class="chart-body"><div id="o-chart4"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div> | |
| </div> | |
| </div> | |
| <div class="decp-promo"> | |
| <div style="font-size:24px;flex-shrink:0;">🔗</div> | |
| <div> | |
| <div class="decp-promo-title">Ces données sont propulsées par decp.info</div> | |
| <div class="decp-promo-text">Ce portail s'appuie sur le travail de Colin Maudry (decp.info 2.7.0). Analyses avancées, tendances nationales et données enrichies disponibles sur son Observatoire.</div> | |
| </div> | |
| <a class="decp-promo-btn" href="https://decp.info/observatoire" target="_blank">Voir l'Observatoire →</a> | |
| </div> | |
| </div> | |
| </div> | |
| <footer> | |
| Données : <a href="https://www.boamp.fr">BOAMP · DILA</a> · <a href="https://data.gouv.fr">DECP data.gouv.fr</a> · <a href="https://projets-achats.marches-publics.gouv.fr">APProch</a> · <a href="https://decp.info">decp.info</a> | |
| </footer> | |
| <script> | |
| const PROXY = 'https://magaliparlemarches.fr/.netlify/functions/proxy?url='; | |
| function px(url) { return PROXY + encodeURIComponent(url); } | |
| const DECP_API = 'https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/'; | |
| async function fetchDecp(params) { | |
| const url = new URL(DECP_API); | |
| Object.entries(params).forEach(([k, v]) => { if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, v); }); | |
| if (!url.searchParams.has('page_size')) url.searchParams.set('page_size', '100'); | |
| const r = await fetch(url.toString()); | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| return (await r.json()).data || []; | |
| } | |
| let decpData = []; | |
| let obsData = []; | |
| let currentObsAxis = 'acheteur'; | |
| // ══ NAVIGATION ══ | |
| function showView(v) { | |
| document.querySelectorAll('.view').forEach(el => el.classList.remove('active')); | |
| document.getElementById('view-' + v).classList.add('active'); | |
| document.getElementById('navRecherche').classList.toggle('active', v === 'recherche'); | |
| document.getElementById('navObservatoire').classList.toggle('active', v === 'observatoire'); | |
| if (v === 'observatoire' && !obsData.length) lancerObservatoire(); | |
| } | |
| // ══ RECHERCHE ══ | |
| function lancerRecherche() { | |
| const q = document.getElementById('qInput').value.trim(); | |
| if (!q) return; | |
| decpData = []; | |
| document.getElementById('initialState').style.display = 'none'; | |
| document.getElementById('resultsZone').style.display = 'block'; | |
| document.getElementById('rhName').textContent = q; | |
| document.getElementById('rhSub').textContent = 'Recherche en cours…'; | |
| ['kpiBoamp','kpiApproch','kpiDecp','kpiVol'].forEach(id => document.getElementById(id).textContent = '…'); | |
| ['tBoamp','tApproch','tDecp'].forEach(id => document.getElementById(id).textContent = '…'); | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); | |
| document.getElementById('tabBoamp').classList.add('active'); | |
| document.getElementById('pane-boamp').classList.add('active'); | |
| document.getElementById('boampList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des avis BOAMP…</div>`; | |
| document.getElementById('approchList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des données APProch…</div>`; | |
| document.getElementById('decpList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des données DECP…</div>`; | |
| loadBoamp(q); | |
| loadApproch(q); | |
| loadDecp(q); | |
| } | |
| function switchTab(id, el) { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); | |
| el.classList.add('active'); | |
| document.getElementById('pane-' + id).classList.add('active'); | |
| if (id === 'obs' && decpData.length) renderZoomObs(decpData); | |
| } | |
| // ══ BOAMP ══ | |
| async function loadBoamp(q) { | |
| try { | |
| // API v1 BOAMP via opendatasoft - plus permissive | |
| const apiUrl = new URL('https://boamp-datadila.opendatasoft.com/api/records/1.0/search/'); | |
| apiUrl.searchParams.set('dataset', 'boamp'); | |
| apiUrl.searchParams.set('q', q); | |
| apiUrl.searchParams.set('rows', '25'); | |
| apiUrl.searchParams.set('sort', '-dateparution'); | |
| const r = await fetch(px(apiUrl.toString())); | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| const d = await r.json(); | |
| const records = (d.records || []).map(rec => rec.fields || rec); | |
| renderBoamp(records); | |
| } catch(e) { | |
| document.getElementById('boampList').innerHTML = `<div class="empty-state">Données BOAMP indisponibles (${e.message}). <a href="https://www.boamp.fr/pages/recherche/" target="_blank">Consulter BOAMP →</a></div>`; | |
| document.getElementById('kpiBoamp').textContent = '—'; | |
| document.getElementById('tBoamp').textContent = '—'; | |
| } | |
| } | |
| function renderBoamp(records) { | |
| const count = records.length; | |
| document.getElementById('kpiBoamp').textContent = count || '0'; | |
| document.getElementById('tBoamp').textContent = count; | |
| if (!count) { document.getElementById('boampList').innerHTML = `<div class="empty-state">Aucun avis BOAMP trouvé.<br><a href="https://www.boamp.fr/pages/recherche/" target="_blank">Rechercher sur BOAMP →</a></div>`; return; } | |
| let aac=0,attr=0,rec=0,old=0; | |
| records.forEach(r => { | |
| const t=(r.typeavis||r.famille||'').toLowerCase(); | |
| if(t.includes('attribution')||t.includes('résultat')) attr++; else aac++; | |
| const y=(r.dateparution||'').substring(0,4); | |
| if(y>='2025') rec++; else if(y>='2023') old++; | |
| }); | |
| ['fc-aac','fc-attr','fc-rec','fc-old'].forEach((id,i)=>{ const el=document.getElementById(id); if(el) el.textContent=[aac,attr,rec,old][i]; }); | |
| const list=document.createElement('div'); list.className='result-list'; | |
| records.forEach((r,i)=>{ | |
| const titre=r.objet||r.intitule||'Avis sans intitulé'; | |
| const date=fmtDate(r.dateparution||''); | |
| const type=r.typeavis||r.famille||''; | |
| const acheteur=r.acheteur||r.organisme||''; | |
| const ref=r.idweb||r.reference||''; | |
| const isAttr=type.toLowerCase().includes('attribution')||type.toLowerCase().includes('résultat'); | |
| const c=document.createElement('div'); c.className='rcard'+(((r.dateparution||'').substring(0,4)>='2025'&&i<3)?' isnew':''); | |
| c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge ${isAttr?'attr':'ouvert'}">${isAttr?'✓ Attribution':'● Avis'}</span></div></div> | |
| <div class="rc-meta">${date?`<span class="rc-mi">📅 <strong>${date}</strong></span>`:''}${type?`<span class="rc-mi">📂 <strong>${esc(type.substring(0,40))}</strong></span>`:''}${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,50))}</strong></span>`:''}</div> | |
| <div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> BOAMP · DILA</div><a class="rc-lnk" href="https://www.boamp.fr/avis/detail/${esc(ref)}" target="_blank">Voir sur BOAMP →</a></div>`; | |
| list.appendChild(c); | |
| }); | |
| document.getElementById('boampList').innerHTML=''; document.getElementById('boampList').appendChild(list); | |
| } | |
| // ══ APPROCH ══ | |
| async function loadApproch(q) { | |
| try { | |
| const apiUrl = new URL('https://data.economie.gouv.fr/api/records/1.0/search/'); | |
| apiUrl.searchParams.set('dataset', 'projets-dachats-publics'); | |
| apiUrl.searchParams.set('q', q); | |
| apiUrl.searchParams.set('rows', '20'); | |
| const r = await fetch(px(apiUrl.toString())); | |
| const records = r.ok ? (await r.json()).records?.map(rec=>rec.fields||rec) || [] : []; | |
| renderApproch(records, q); | |
| } catch(e) { renderApproch([], q); } | |
| } | |
| function renderApproch(records, q) { | |
| document.getElementById('kpiApproch').textContent = records.length || '0'; | |
| document.getElementById('tApproch').textContent = records.length || '0'; | |
| if (!records.length) { | |
| document.getElementById('approchList').innerHTML = `<div class="empty-state">Aucun projet APProch trouvé pour "<strong>${esc(q)}</strong>".<br>Publication volontaire — cet acheteur n'a peut-être pas publié sa programmation.<br><a href="https://projets-achats.marches-publics.gouv.fr" target="_blank">Consulter APProch →</a></div>`; return; | |
| } | |
| let t=0,s=0,f=0; | |
| records.forEach(r=>{ const c=(r.categorieAchat||r.typeAchat||'').toLowerCase(); if(c.includes('travaux'))t++; else if(c.includes('fourniture'))f++; else s++; }); | |
| ['fp-t','fp-s','fp-f'].forEach((id,i)=>{ const el=document.getElementById(id); if(el) el.textContent=[t,s,f][i]; }); | |
| const list=document.createElement('div'); list.className='result-list'; | |
| records.forEach(r=>{ | |
| const titre=r.objet||r.intitule||r.libelle||'Projet sans intitulé'; | |
| const dateEch=fmtDate(r.dateEcheancePublication||''); | |
| const montant=r.montantEstime?fmtMontant(r.montantEstime):''; | |
| const cat=r.categorieAchat||r.typeAchat||''; | |
| const acheteur=r.acheteurNom||r.nomAcheteur||''; | |
| const c=document.createElement('div'); c.className='rcard'; | |
| c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge prog">◐ Programmé</span></div></div> | |
| <div class="rc-meta">${dateEch?`<span class="rc-mi">📅 Échéance : <strong>${dateEch}</strong></span>`:''}${montant?`<span class="rc-mi">💶 <strong>${montant}</strong></span>`:''}${cat?`<span class="rc-mi">📂 <strong>${esc(cat)}</strong></span>`:''}${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,45))}</strong></span>`:''}</div> | |
| <div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> APProch · data.gouv.fr</div><a class="rc-lnk" href="https://projets-achats.marches-publics.gouv.fr" target="_blank">APProch →</a></div>`; | |
| list.appendChild(c); | |
| }); | |
| document.getElementById('approchList').innerHTML=''; document.getElementById('approchList').appendChild(list); | |
| } | |
| // ══ RÉSOLUTION SIRET via Annuaire des Entreprises (DINUM) ══ | |
| async function getSiret(q) { | |
| // Ne pas appeler si q ressemble déjà à un SIRET/SIREN | |
| if (/^\d{9,14}$/.test(q.replace(/\s/g,''))) return q.replace(/\s/g,''); | |
| try { | |
| const url = `https://recherche-entreprises.api.gouv.fr/search?q=${encodeURIComponent(q)}&page=1&per_page=5`; | |
| const r = await fetch(url); | |
| if (!r.ok) return null; | |
| const d = await r.json(); | |
| const results = d.results || []; | |
| const qLow = q.toLowerCase(); | |
| const match = results.find(e => { | |
| const nom = (e.nom_raison_sociale || e.nom_complet || '').toLowerCase(); | |
| return nom.includes(qLow.substring(0, 6)); | |
| }) || results[0]; | |
| if (!match) return null; | |
| return match.siege?.siret || match.siren || null; | |
| } catch(e) { return null; } | |
| } | |
| // ══ DECP — requête par SIRET ══ | |
| async function decpBySiret(siret, year) { | |
| const params = { 'acheteur_id__exact': siret, 'dateNotification__sort': 'desc' }; | |
| if (year) { | |
| params['dateNotification__greater'] = `${parseInt(year)-1}-12-31`; | |
| params['dateNotification__less'] = `${parseInt(year)+1}-01-01`; | |
| } | |
| return fetchDecp(params); | |
| } | |
| // ══ DECP RECHERCHE ══ | |
| async function loadDecp(q) { | |
| let records = []; | |
| let siretFound = null; | |
| // Essai 1 : résoudre SIRET → recherche précise | |
| const siret = await getSiret(q); | |
| if (siret) { | |
| siretFound = siret; | |
| records = await decpBySiret(siret, null); | |
| } | |
| // Essai 2 : fulltext si rien | |
| if (!records.length) { | |
| try { records = await fetchDecp({ 'q': q, 'dateNotification__sort': 'desc' }); } catch(e) {} | |
| } | |
| decpData = records; | |
| renderDecpList(records, q); | |
| updateKpis(records); | |
| const siretInfo = siretFound ? ` · SIRET ${siretFound}` : ''; | |
| document.getElementById('rhSub').textContent = `${records.length} marchés DECP trouvés${siretInfo}`; | |
| } | |
| function updateKpis(records) { | |
| const vol = records.reduce((a,r)=>a+parseFloat(r.montant||0),0); | |
| document.getElementById('kpiDecp').textContent = records.length || '0'; | |
| document.getElementById('kpiVol').textContent = vol>0 ? fmtMontant(vol) : '—'; | |
| document.getElementById('tDecp').textContent = records.length || '0'; | |
| } | |
| function renderDecpList(records, q) { | |
| if (!records.length) { document.getElementById('decpList').innerHTML=`<div class="empty-state">Aucun marché DECP trouvé pour "<strong>${esc(q)}</strong>".<br><a href="https://data.gouv.fr" target="_blank">data.gouv.fr →</a></div>`; return; } | |
| const list=document.createElement('div'); list.className='result-list'; | |
| records.slice(0,15).forEach(r=>{ | |
| const titre = r.objet || 'Marché sans intitulé'; | |
| const date = fmtDate(r.dateNotification || ''); | |
| const montant = fmtMontant(r.montant || 0); | |
| const tit = r.titulaire_nom || ''; | |
| const acheteur = r.acheteur_nom || ''; | |
| const cpv = r.codeCPV || ''; | |
| const nature = r.nature || ''; | |
| const c=document.createElement('div'); c.className='rcard'; | |
| c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge attr">✓ Attribué</span></div></div> | |
| <div class="rc-meta"> | |
| ${date?`<span class="rc-mi">📅 Notifié : <strong>${date}</strong></span>`:''} | |
| ${montant?`<span class="rc-mi">💶 <strong>${montant}</strong></span>`:''} | |
| ${tit?`<span class="rc-mi">🏢 <strong>${esc(tit.substring(0,45))}${tit.length>45?'…':''}</strong></span>`:''} | |
| ${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,45))}</strong></span>`:''} | |
| ${cpv?`<span class="rc-mi">🏷️ <strong>${esc(String(cpv).substring(0,40))}</strong></span>`:''} | |
| ${nature?`<span class="rc-mi">📂 <strong>${esc(nature)}</strong></span>`:''} | |
| </div> | |
| <div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> DECP · data.gouv.fr</div><a class="rc-lnk" href="https://data.gouv.fr" target="_blank">data.gouv →</a></div>`; | |
| list.appendChild(c); | |
| }); | |
| document.getElementById('decpList').innerHTML=''; document.getElementById('decpList').appendChild(list); | |
| } | |
| // ══ OBS ZOOM (recherche) ══ | |
| function renderZoomObs(records) { | |
| if (!records.length) return; | |
| const total=records.length, vol=records.reduce((a,r)=>a+parseFloat(r.montant||0),0), moy=vol/total; | |
| const titSet=new Set(records.map(r=>r.titulaire_id||r.titulaire_nom).filter(Boolean)); | |
| document.getElementById('zo-total').textContent=total; | |
| document.getElementById('zo-vol').textContent=vol>0?fmtMontant(vol):'—'; | |
| document.getElementById('zo-moy').textContent=moy>0?fmtMontant(moy):'—'; | |
| document.getElementById('zo-tit').textContent=titSet.size||'—'; | |
| document.getElementById('zo-annual').innerHTML=renderAnnual(records,'var(--blue)'); | |
| document.getElementById('zo-tit-rows').innerHTML=renderTopTable(records,'titulaire'); | |
| document.getElementById('zo-type').innerHTML=renderTypeChart(records); | |
| document.getElementById('zo-cpv').innerHTML=renderCpvChart(records,'var(--teal)'); | |
| } | |
| // ══ OBSERVATOIRE NATIONAL ══ | |
| async function lancerObservatoire() { | |
| const q = document.getElementById('obsQ').value.trim(); | |
| const year = document.getElementById('obsYear').value; | |
| document.getElementById('obsCtxSub').textContent = 'Chargement…'; | |
| ['o-total','o-vol','o-moy','o-tit'].forEach(id=>{ const el=document.getElementById(id); if(el) el.textContent='…'; }); | |
| ['o-annual','o-type','o-chart4'].forEach(id=>{ const el=document.getElementById(id); if(el) el.innerHTML=`<div class="loading-state"><div class="spinner"></div>Chargement…</div>`; }); | |
| document.getElementById('o-chart2-rows').innerHTML=`<tr><td colspan="4" style="padding:14px;text-align:center;color:var(--grey-400)">Chargement…</td></tr>`; | |
| try { | |
| const params = { 'dateNotification__sort': 'desc' }; | |
| if (q) params['q'] = q; | |
| if (year) { | |
| params['dateNotification__greater'] = `${parseInt(year)-1}-12-31`; | |
| params['dateNotification__less'] = `${parseInt(year)+1}-01-01`; | |
| } | |
| const records = await fetchDecp(params); | |
| obsData = records; | |
| const scope = q ? `Recherche : "${q}"` : `Vue nationale${year ? ` · ${year}` : ''}`; | |
| const sub = `${obsData.length} marchés chargés (100 max)`; | |
| document.getElementById('obsCtxScope').textContent = scope; | |
| document.getElementById('obsCtxSub').textContent = sub; | |
| renderObservatoire(obsData, currentObsAxis); | |
| } catch(e) { | |
| document.getElementById('obsCtxSub').textContent = `Erreur : ${e.message}`; | |
| ['o-annual','o-type','o-chart4'].forEach(id=>{ const el=document.getElementById(id); if(el) el.innerHTML=`<div class="empty-state">Données indisponibles.</div>`; }); | |
| } | |
| } | |
| function switchObsAxis(axis, el) { | |
| currentObsAxis = axis; | |
| document.querySelectorAll('.obs-axis-btn').forEach(b => { b.classList.remove('active','acheteur','territoire','secteur','titulaire'); }); | |
| el.classList.add('active', axis); | |
| const descs = { | |
| acheteur:'Vue par acheteur · Top acheteurs et leurs fournisseurs', | |
| territoire:'Vue par territoire · Qui achète où ?', | |
| secteur:'Vue par secteur CPV · Qui achète quoi ?', | |
| titulaire:'Vue par titulaire · Qui remporte quoi ?' | |
| }; | |
| document.getElementById('obsAxisDesc').textContent = descs[axis]; | |
| if (obsData.length) renderObservatoire(obsData, axis); | |
| } | |
| function renderObservatoire(records, axis) { | |
| if (!records.length) return; | |
| const total=records.length, vol=records.reduce((a,r)=>a+parseFloat(r.montant||0),0), moy=vol/total; | |
| // KPIs selon axe | |
| const kpiTitLbl = document.getElementById('o-tit-lbl'); | |
| const kpiTotLbl = document.getElementById('o-total-lbl'); | |
| if (axis==='acheteur') { | |
| const titSet=new Set(records.map(r=>r.titulaire_nom).filter(Boolean)); | |
| document.getElementById('o-tit').textContent=titSet.size||'—'; | |
| if(kpiTitLbl) kpiTitLbl.textContent='Fournisseurs distincts'; | |
| if(kpiTotLbl) kpiTotLbl.textContent='Marchés attribués'; | |
| } else if (axis==='titulaire') { | |
| const achSet=new Set(records.map(r=>r.acheteur_nom).filter(Boolean)); | |
| document.getElementById('o-tit').textContent=achSet.size||'—'; | |
| if(kpiTitLbl) kpiTitLbl.textContent='Acheteurs clients'; | |
| if(kpiTotLbl) kpiTotLbl.textContent='Marchés remportés'; | |
| } else { | |
| const achSet=new Set(records.map(r=>r.acheteur_nom).filter(Boolean)); | |
| document.getElementById('o-tit').textContent=achSet.size||'—'; | |
| if(kpiTitLbl) kpiTitLbl.textContent='Acheteurs distincts'; | |
| if(kpiTotLbl) kpiTotLbl.textContent='Marchés dans cette vue'; | |
| } | |
| document.getElementById('o-total').textContent=total; | |
| document.getElementById('o-vol').textContent=vol>0?fmtMontant(vol):'—'; | |
| document.getElementById('o-moy').textContent=moy>0?fmtMontant(moy):'—'; | |
| // Graphe 1 : volume annuel | |
| document.getElementById('o-annual').innerHTML=renderAnnual(records,'var(--blue)'); | |
| // Graphe 2 : top selon axe | |
| const c2title=document.getElementById('o-chart2-title'); | |
| const c2col=document.getElementById('o-chart2-col'); | |
| if (axis==='titulaire') { | |
| if(c2title) c2title.textContent='Top acheteurs clients'; | |
| if(c2col) c2col.textContent='Acheteur'; | |
| document.getElementById('o-chart2-rows').innerHTML=renderTopTable(records,'acheteur'); | |
| } else { | |
| if(c2title) c2title.textContent='Top fournisseurs par volume'; | |
| if(c2col) c2col.textContent='Fournisseur'; | |
| document.getElementById('o-chart2-rows').innerHTML=renderTopTable(records,'titulaire'); | |
| } | |
| // Graphe 3 : type | |
| document.getElementById('o-type').innerHTML=renderTypeChart(records); | |
| // Graphe 4 : selon axe | |
| const c4=document.getElementById('o-chart4'); | |
| const c4title=document.getElementById('o-chart4-title'); | |
| const c4sub=document.getElementById('o-chart4-sub'); | |
| if (axis==='acheteur'||axis==='territoire') { | |
| if(c4title) c4title.textContent='Top acheteurs par volume'; | |
| if(c4sub) c4sub.textContent='Pour cette sélection'; | |
| if(c4) c4.innerHTML=renderTopBar(records,'acheteur','var(--green)'); | |
| } else if (axis==='secteur') { | |
| if(c4title) c4title.textContent='Top secteurs CPV'; | |
| if(c4sub) c4sub.textContent='Par volume attribué'; | |
| if(c4) c4.innerHTML=renderCpvChart(records,'var(--orange)'); | |
| } else { | |
| if(c4title) c4title.textContent='Secteurs d\'intervention'; | |
| if(c4sub) c4sub.textContent='Par volume · code CPV'; | |
| if(c4) c4.innerHTML=renderCpvChart(records,'var(--purple)'); | |
| } | |
| } | |
| // ══ RENDERERS COMMUNS ══ | |
| function renderAnnual(records, color) { | |
| const annMap={}; | |
| records.forEach(r=>{ | |
| const y=(r.dateNotification||'').substring(0,4); | |
| if(y>='2018') annMap[y]=(annMap[y]||0)+parseFloat(r.montant||0); | |
| }); | |
| const years=Object.keys(annMap).sort(), maxV=Math.max(...Object.values(annMap),1); | |
| return years.length ? years.map(y=>`<div class="bar-row"><div class="bar-lbl">${y}</div><div class="bar-track"><div class="bar-fill" style="width:${(annMap[y]/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(annMap[y])}</div></div>`).join('') : '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>'; | |
| } | |
| function renderTopTable(records, type) { | |
| const map={}; | |
| records.forEach(r=>{ | |
| const nom = type==='titulaire' | |
| ? (r.titulaire_nom||'Inconnu') | |
| : (r.acheteur_nom||'Inconnu'); | |
| if(!map[nom]) map[nom]={nom,vol:0,nb:0}; | |
| map[nom].vol+=parseFloat(r.montant||0); map[nom].nb++; | |
| }); | |
| const top=Object.values(map).sort((a,b)=>b.vol-a.vol).slice(0,7); | |
| return top.length ? top.map((t,i)=>`<tr><td class="num">${i+1}</td><td>${esc(t.nom.substring(0,38))}${t.nom.length>38?'…':''}</td><td class="amt">${t.vol>0?fmtMontant(t.vol):'—'}</td><td style="text-align:right;color:var(--grey-600)">${t.nb}</td></tr>`).join('') : '<tr><td colspan="4" style="padding:12px;text-align:center;color:var(--grey-400)">Pas de données</td></tr>'; | |
| } | |
| function renderTypeChart(records) { | |
| const types={Services:0,Travaux:0,Fournitures:0,Autres:0}; | |
| records.forEach(r=>{ | |
| const cpv=(r.codeCPV||'').substring(0,2); | |
| const n=parseFloat(r.montant||0); | |
| if(['71','72','73','74','75','76','77','78','79','80','85','90','92','98'].includes(cpv)) types.Services+=n; | |
| else if(['44','45'].includes(cpv)) types.Travaux+=n; | |
| else if(parseInt(cpv)>0&&parseInt(cpv)<45) types.Fournitures+=n; | |
| else types.Autres+=n; | |
| }); | |
| const totalT=Object.values(types).reduce((a,b)=>a+b,0); | |
| const colors={Services:'var(--blue)',Travaux:'var(--green)',Fournitures:'var(--orange)',Autres:'var(--grey-400)'}; | |
| return Object.entries(types).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">${k}</div><div class="bar-track"><div class="bar-fill" style="width:${totalT>0?(v/totalT*100).toFixed(0):0}%;background:${colors[k]}"></div></div><div class="bar-val">${totalT>0?(v/totalT*100).toFixed(0):0}%</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données CPV</div>'; | |
| } | |
| function renderTopBar(records, type, color) { | |
| const map={}; | |
| records.forEach(r=>{ | |
| const nom = type==='acheteur' | |
| ? (r.acheteur_nom||'Inconnu') | |
| : (r.titulaire_nom||'Inconnu'); | |
| map[nom]=(map[nom]||0)+parseFloat(r.montant||0); | |
| }); | |
| const top=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,6); | |
| const maxV=Math.max(...top.map(([,v])=>v),1); | |
| return top.map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">${esc(k.substring(0,18))}${k.length>18?'…':''}</div><div class="bar-track"><div class="bar-fill" style="width:${(v/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(v)}</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>'; | |
| } | |
| function renderCpvChart(records, color) { | |
| const cpvMap={}; | |
| records.forEach(r=>{ | |
| const cpv=(r.codeCPV||'NC').substring(0,2); | |
| cpvMap[cpv]=(cpvMap[cpv]||0)+parseFloat(r.montant||0); | |
| }); | |
| const top=Object.entries(cpvMap).sort((a,b)=>b[1]-a[1]).slice(0,6); | |
| const maxV=Math.max(...top.map(([,v])=>v),1); | |
| return top.map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">CPV ${k}</div><div class="bar-track"><div class="bar-fill" style="width:${(v/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(v)}</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>'; | |
| } | |
| // ══ UTILS ══ | |
| function fmtDate(d) { if(!d) return ''; try { return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); } catch { return d.substring(0,10); } } | |
| function fmtMontant(m) { const n=parseFloat(m); if(!n||isNaN(n)) return ''; if(n>=1000000) return (n/1000000).toFixed(2)+' M€'; if(n>=1000) return Math.round(n/1000)+' k€'; return n+' €'; } | |
| function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment