Files
colibre/magali.html
T
Colin Maudry ded5e66ccc fix(backup): nettoyer le fichier temporaire en cas d'erreur dans restore (#89)
Wraps temp file operations in try/except to ensure the temporary database file is always cleaned up, even if an exception occurs during write_snapshot or verify_integrity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:21:28 +02:00

2545 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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"
>&lt; 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"
>&gt; 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</body>
</html>