feat(etapes): fiches cliquables sur les barres et vue mobile

This commit is contained in:
Colin Maudry
2026-06-04 22:15:10 +02:00
parent ab74717679
commit 4d3e8ac344
2 changed files with 133 additions and 14 deletions
+42 -2
View File
@@ -636,6 +636,13 @@ input[type="number"] {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
cursor: pointer;
transition: filter 0.15s, box-shadow 0.15s;
}
.etapes-bar:hover {
filter: brightness(1.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
} }
.etapes-empty { .etapes-empty {
@@ -666,15 +673,36 @@ input[type="number"] {
overflow: hidden; overflow: hidden;
} }
.etapes-m-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #f9fafb;
border-bottom: 1px solid #eaecf0;
}
.etapes-m-stage { .etapes-m-stage {
margin: 0; margin: 0;
padding: 10px 12px; padding: 10px 12px;
background: #f9fafb;
border-bottom: 1px solid #eaecf0;
font-size: 15px; font-size: 15px;
color: #101828; color: #101828;
} }
.etapes-m-link {
background: none;
border: none;
color: #1570ef;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 12px;
white-space: nowrap;
}
.etapes-m-link:hover {
text-decoration: underline;
}
.etapes-m-item { .etapes-m-item {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -711,6 +739,18 @@ input[type="number"] {
font-style: italic; font-style: italic;
} }
.etapes-detail {
margin: 1rem 0;
padding: 1rem 1.25rem;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #f9fafb;
}
.etapes-detail:empty {
display: none;
}
/* --- Bascule desktop / mobile au point de rupture 768 px --- */ /* --- Bascule desktop / mobile au point de rupture 768 px --- */
@media (max-width: 768px) { @media (max-width: 768px) {
+91 -12
View File
@@ -1,4 +1,4 @@
from dash import dcc, html, register_page from dash import Input, Output, State, callback, ctx, dcc, html, register_page
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
@@ -17,6 +17,41 @@ register_page(
image_url=META_CONTENT["image_url"], 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): def _lane(*bars):
"""Une ligne d'étape : fond segmenté en 5 + barres positionnées.""" """Une ligne d'étape : fond segmenté en 5 + barres positionnées."""
@@ -32,10 +67,14 @@ def _lane(*bars):
) )
def _bar(label, color, style): def _bar(label, color, style, bar_id=None):
base = {"backgroundColor": color} base = {"backgroundColor": color}
base.update(style) base.update(style)
return html.Div(label, className="etapes-bar", style=base) 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(): def build_chart():
@@ -75,28 +114,29 @@ def build_chart():
"Approch — sourcing / préinformation (non réglementaire)", "Approch — sourcing / préinformation (non réglementaire)",
"#7c5cff", "#7c5cff",
{"left": "2%", "right": "2%"}, {"left": "2%", "right": "2%"},
bar_id="bar-approch",
), ),
), ),
# Publicité (appel d'offres) # Publicité (appel d'offres)
html.Div( html.Div(["Publicité"], className="etapes-stage"),
["Publicité"],
className="etapes-stage",
),
_lane( _lane(
_bar( _bar(
"JAL", "JAL",
"#f79009", "#f79009",
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"}, {"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
bar_id="bar-jal",
), ),
_bar( _bar(
"BOAMP", "BOAMP",
"#1570ef", "#1570ef",
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"}, {"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
bar_id="bar-boamp",
), ),
_bar( _bar(
"JOUE — avis de marché", "JOUE — avis de marché",
"#0e9384", "#0e9384",
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"}, {"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
bar_id="bar-joue-marche",
), ),
), ),
# Attribution # Attribution
@@ -106,11 +146,13 @@ def build_chart():
"DECP — données essentielles", "DECP — données essentielles",
"#12b76a", "#12b76a",
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"}, {"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
bar_id="bar-decp",
), ),
_bar( _bar(
"JOUE — avis d'attribution", "JOUE — avis d'attribution",
"#0e9384", "#0e9384",
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"}, {"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
bar_id="bar-joue-attribution",
), ),
), ),
# Contrat (vide) # Contrat (vide)
@@ -131,16 +173,18 @@ def build_chart():
# Données par étape, partagées par la vue mobile. # Données par étape, partagées par la vue mobile.
# Chaque item : (libellé, couleur, plage de seuils en texte). # Chaque tuple : (libellé étape, id CSS, [(libellé, couleur, plage seuils)]).
STAGES_MOBILE = [ STAGES_MOBILE = [
( (
"Programmation", "Programmation",
"stage-programmation",
[ [
("Approch", "#7c5cff", "tous montants — publication non réglementaire"), ("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
], ],
), ),
( (
"Publicité (appel d'offres)", "Publicité (appel d'offres)",
"stage-publicite",
[ [
("JAL", "#f79009", "de 90 000 € au seuil formalisé"), ("JAL", "#f79009", "de 90 000 € au seuil formalisé"),
("BOAMP", "#1570ef", "à partir de 90 000 €"), ("BOAMP", "#1570ef", "à partir de 90 000 €"),
@@ -153,19 +197,20 @@ STAGES_MOBILE = [
), ),
( (
"Attribution", "Attribution",
"stage-attribution",
[ [
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"), ("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"), ("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
], ],
), ),
("Contrat", []), ("Contrat", "stage-contrat", []),
("Paiement", []), ("Paiement", "stage-paiement", []),
] ]
def build_mobile(): def build_mobile():
blocks = [] blocks = []
for stage, items in STAGES_MOBILE: for stage, stage_id, items in STAGES_MOBILE:
if items: if items:
children = [ children = [
html.Div( html.Div(
@@ -187,7 +232,21 @@ def build_mobile():
] ]
blocks.append( blocks.append(
html.Div( html.Div(
[html.H4(stage, className="etapes-m-stage"), *children], [
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", className="etapes-m-block",
) )
) )
@@ -207,6 +266,8 @@ layout = html.Div(
), ),
build_chart(), build_chart(),
build_mobile(), build_mobile(),
dcc.Store(id="etapes-selected", data=None),
html.Div(id="etapes-detail", className="etapes-detail"),
dcc.Markdown( dcc.Markdown(
"**À noter :** l'axe horizontal n'est pas linéaire — les seuils " "**À noter :** l'axe horizontal n'est pas linéaire — les seuils "
"sont espacés régulièrement pour rester lisibles. Les étapes " "sont espacés régulièrement pour rester lisibles. Les étapes "
@@ -216,3 +277,21 @@ layout = html.Div(
), ),
], ],
) )
@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("*Fiche en cours de rédaction.*"), triggered
return dcc.Markdown(content), triggered