from dash import Input, Output, State, callback, ctx, dcc, html, register_page from src.utils.seo import META_CONTENT NAME = "Quelles données pour quelles étapes et quels seuils dans les marchés publics ?" register_page( __name__, path="/etapes", title=f"{NAME} | decp.info", name="Étapes et données", description=( "À chaque étape d'un marché public (programmation, publicité, " "attribution), quelles données sont publiées et à partir de quel " "seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch." ), image_url=META_CONTENT["image_url"], ) # Contenu des fiches — à rédiger en Markdown. # Clés barres : "bar-approch", "bar-jal", "bar-boamp", "bar-joue-marche", # "bar-decp", "bar-joue-attribution" # Clés étapes (mobile) : "stage-programmation", "stage-publicite", # "stage-attribution", "stage-contrat", "stage-paiement" ALL_CONTENT: dict[str, str | None] = { "bar-approch": None, "bar-jal": None, "bar-boamp": None, "bar-joue-marche": None, "bar-decp": None, "bar-joue-attribution": None, "stage-programmation": None, "stage-publicite": None, "stage-attribution": None, "stage-contrat": None, "stage-paiement": None, } _BAR_IDS = [ "bar-approch", "bar-jal", "bar-boamp", "bar-joue-marche", "bar-decp", "bar-joue-attribution", ] _STAGE_IDS = [ "stage-programmation", "stage-publicite", "stage-attribution", "stage-contrat", "stage-paiement", ] def _lane(*bars): """Une ligne d'étape : fond segmenté en 5 + barres positionnées.""" return html.Div( className="etapes-lane", children=[ html.Div( className="etapes-segs", children=[html.Div() for _ in range(5)], ), *bars, ], ) def _bar(label, color, style, bar_id=None): base = {"backgroundColor": color} base.update(style) props = {"className": "etapes-bar", "style": base} if bar_id is not None: props["id"] = bar_id props["n_clicks"] = 0 return html.Div(label, **props) def build_chart(): return html.Div( className="etapes-chart-scroll", children=html.Div( className="etapes-chart", children=[ # En-tête : coin vide + 5 marqueurs de seuils html.Div(className="etapes-corner"), html.Div( className="etapes-xhead", children=[ html.Div("0 €", className="etapes-xcell"), html.Div( [html.Strong("40 000 €"), "seuil DECP"], className="etapes-xcell", ), html.Div( [html.Strong("90 000 €"), "publicité"], className="etapes-xcell", ), html.Div( [html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"], className="etapes-xcell", ), html.Div( [html.Strong("5,404 M€"), "travaux (UE)"], className="etapes-xcell", ), ], ), # Programmation html.Div("Programmation", className="etapes-stage"), _lane( _bar( "Approch — sourcing / préinformation (non réglementaire)", "#7c5cff", {"left": "2%", "right": "2%"}, bar_id="bar-approch", ), ), # Publicité (appel d'offres) html.Div(["Publicité"], className="etapes-stage"), _lane( _bar( "JAL", "#f79009", {"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, bar_id="bar-jal", ), _bar( "BOAMP", "#1570ef", {"left": "40%", "right": "2%", "top": "28px", "height": "20px"}, bar_id="bar-boamp", ), _bar( "JOUE — avis de marché", "#0e9384", {"left": "60%", "right": "2%", "top": "6px", "height": "20px"}, bar_id="bar-joue-marche", ), ), # Attribution html.Div("Attribution", className="etapes-stage"), _lane( _bar( "DECP — données essentielles", "#12b76a", {"left": "20%", "right": "2%", "top": "6px", "height": "20px"}, bar_id="bar-decp", ), _bar( "JOUE — avis d'attribution", "#0e9384", {"left": "60%", "right": "2%", "top": "28px", "height": "20px"}, bar_id="bar-joue-attribution", ), ), # Contrat (vide) html.Div("Contrat", className="etapes-stage"), html.Div( "— aucune donnée publiée aujourd'hui —", className="etapes-lane etapes-empty", ), # Paiement (vide) html.Div("Paiement", className="etapes-stage"), html.Div( "— aucune donnée publiée aujourd'hui —", className="etapes-lane etapes-empty", ), ], ), ) # Données par étape, partagées par la vue mobile. # Chaque tuple : (libellé étape, id CSS, [(libellé, couleur, plage seuils)]). STAGES_MOBILE = [ ( "Programmation", "stage-programmation", [ ("Approch", "#7c5cff", "tous montants — publication non réglementaire"), ], ), ( "Publicité (appel d'offres)", "stage-publicite", [ ("JAL", "#f79009", "de 90 000 € au seuil formalisé"), ("BOAMP", "#1570ef", "à partir de 90 000 €"), ( "JOUE — avis de marché", "#0e9384", "à partir des seuils formalisés (140 k€ / 216 k€)", ), ], ), ( "Attribution", "stage-attribution", [ ("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"), ("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"), ], ), ("Contrat", "stage-contrat", []), ("Paiement", "stage-paiement", []), ] def build_mobile(): blocks = [] for stage, stage_id, items in STAGES_MOBILE: if items: children = [ html.Div( [ html.I(style={"backgroundColor": color}), html.Span(label, className="etapes-m-label"), html.Span(seuil, className="etapes-m-seuil"), ], className="etapes-m-item", ) for label, color, seuil in items ] else: children = [ html.Div( "aucune donnée publiée aujourd'hui", className="etapes-m-item etapes-m-empty", ) ] blocks.append( html.Div( [ html.Div( [ html.H4(stage, className="etapes-m-stage"), html.Button( "Voir fiche →", id=stage_id, n_clicks=0, className="etapes-m-link", ), ], className="etapes-m-header", ), *children, ], className="etapes-m-block", ) ) return html.Div(blocks, className="etapes-mobile") layout = html.Div( className="container", children=[ html.H2(NAME), dcc.Markdown( "Un marché public passe par plusieurs étapes. À chacune, des " "données peuvent être publiées — selon le montant du marché et " "des obligations réglementaires. Ce graphique situe les " "principales publications de données par **étape** (de haut en " "bas) et par **seuil** (de gauche à droite, en euros hors taxes)." ), build_chart(), build_mobile(), dcc.Store(id="etapes-selected", data=None), html.Div(id="etapes-detail", className="etapes-detail"), dcc.Markdown( "**À noter :** l'axe horizontal n'est pas linéaire — les seuils " "sont espacés régulièrement pour rester lisibles. Les étapes " "*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée " "en open data.", className="etapes-note", ), ], ) @callback( Output("etapes-detail", "children"), Output("etapes-selected", "data"), [Input(id_, "n_clicks") for id_ in _BAR_IDS + _STAGE_IDS], State("etapes-selected", "data"), prevent_initial_call=True, ) def _show_detail(*args): current = args[-1] triggered = ctx.triggered_id if triggered == current: return None, None content = ALL_CONTENT.get(triggered) if content is None: return dcc.Markdown(f"*Fiche en cours de rédaction.* {triggered}"), triggered return dcc.Markdown(content), triggered