ded5e66ccc
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>
2545 lines
81 KiB
HTML
2545 lines
81 KiB
HTML
<!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>
|