Refonte de l'interface : sauvegarde automatique, menu d'état unique et panneau aujourd'hui

- Chaque heure validée enregistre le jour (plus de bouton ✓), avec un témoin
  « enregistré » et conservation du focus entre les champs
- Les boutons MA/AM/J/F/AS deviennent un bouton d'état unique à pastilles
  ouvrant un menu déroulant (congés, férié, astreinte)
- Panneau Aujourd'hui : sortie cible en grand et progression travaillé/dû
  de la semaine, rafraîchis à chaque saisie
- Polish général : coins arrondis, ombres douces, hiérarchie des soldes,
  états de focus visibles

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 20:34:27 +02:00
parent 791b48b320
commit e9983a28c7
8 changed files with 565 additions and 98 deletions

View File

@ -58,6 +58,14 @@ Si le rythme normal suffit déjà à rentrer dans les clous, aucune préconisati
Les plages qui ne respectent pas les minimums sont signalées en rouge dès la saisie. Les plages qui ne respectent pas les minimums sont signalées en rouge dès la saisie.
**Enregistrement automatique** : chaque heure validée (sortie du champ, Entrée ou
sélecteur) enregistre le jour ; un témoin « ✓ enregistré » s'affiche un instant
dans la colonne Jour. Plus de bouton à cliquer.
Quand la semaine affichée contient le jour courant, un **panneau Aujourd'hui**
s'invite en tête de page : heure de sortie cible en grand et barre de progression
travaillé/dû de la semaine, actualisés à chaque saisie.
Le bandeau au-dessus du tableau ajoute, quand il reste du retard à rattraper, une ligne Le bandeau au-dessus du tableau ajoute, quand il reste du retard à rattraper, une ligne
**Reste à faire** : le total encore dû, le nombre de demi-journées restantes, et la part **Reste à faire** : le total encore dû, le nombre de demi-journées restantes, et la part
supplémentaire moyenne à ajouter sur chacune. supplémentaire moyenne à ajouter sur chacune.
@ -173,8 +181,9 @@ docker compose run --rm pointeuse pytest -v
## Congés & jours fériés ## Congés & jours fériés
Quatre boutons par ligne : **MA** (congé matin), **AM** (congé après-midi), **J** Chaque ligne porte un bouton d'état unique (colonne **État**) qui résume la
(journée entière) et **F** (jour férié). Les trois premiers réduisent les heures situation par des pastilles colorées et ouvre un menu : **MA** (congé matin), **AM**
(congé après-midi), **J** (journée entière) et **F** (jour férié). Les trois premiers réduisent les heures
dues et recalculent les préconisations **Fin cible** pour les demi-journées dues et recalculent les préconisations **Fin cible** pour les demi-journées
restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures
dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans
@ -185,7 +194,7 @@ d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-des
## Astreinte & repos de 11h ## Astreinte & repos de 11h
Le bouton **AS** marque un jour d'astreinte : la période couverte va de **18:00 Le bouton **AS** du menu d'état marque un jour d'astreinte : la période couverte va de **18:00
ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans
trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00** trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00**
(journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain (journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain

View File

@ -314,6 +314,21 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
] ]
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id)) result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id))
result["heures_jour"] = minutes_to_hhmm(h_jour) result["heures_jour"] = minutes_to_hhmm(h_jour)
# Panneau « aujourd'hui » : uniquement si le jour courant appartient à la
# semaine affichée (re-rendu à chaque enregistrement via _soldes.html).
today_str = datetime.today().date().isoformat()
jour_today = next((j for j in result["jours"] if j["date"] == today_str), None)
if jour_today is not None:
trav_total = sum(j["travaille_min"] for j in result["jours"])
du_total = trav_total - result["solde_min"]
pct = min(100, round(trav_total * 100 / du_total)) if du_total > 0 else None
result["today"] = {
"nom": JOURS_FR[datetime.strptime(today_str, "%Y-%m-%d").weekday()],
"jour": jour_today,
"pct": pct,
"trav_total": minutes_to_hhmm(trav_total),
"du_total": minutes_to_hhmm(du_total),
}
return {"result": result} return {"result": result}
@ -445,22 +460,48 @@ def _oob_time_cells(date, jour, plages):
def _btns_html(date, jour): def _btns_html(date, jour):
"""Boutons congé + astreinte + férié d'une ligne (cible du swap HTMX).""" """Bouton d'état d'une ligne (cible du swap HTMX) : pastilles résumant
congés / férié / astreinte, menu déroulant des cinq bascules. Doit rester
synchronisé avec le rendu Jinja de _ligne.html."""
cg = jour["conge"] cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else "" chips = []
btn_am_on = " on" if "aprem" in cg else "" if "jour" in cg:
btn_j_on = " jour-on" if "jour" in cg else "" chips.append('<span class="cg-chip st-jour">J</span>')
btn_as_on = " as-on" if jour.get("astreinte") else "" if "matin" in cg:
btn_f_on = " ferie-on" if jour.get("ferie") else "" chips.append('<span class="cg-chip st-ma">MA</span>')
if "aprem" in cg:
chips.append('<span class="cg-chip st-am">AM</span>')
if jour.get("ferie"):
chips.append('<span class="cg-chip st-ferie">F</span>')
if jour.get("astreinte"):
chips.append('<span class="cg-chip st-as">AS</span>')
state = "".join(chips) or '<span class="cg-none">—</span>'
d = _e(date)
def opt(url, chip_cls, chip_txt, label, on, title=""):
t = f' title="{title}"' if title else ""
return (f'<button hx-post="{url}" hx-swap="none" class="cg-opt{" on" if on else ""}"{t}>'
f'<span class="cg-opt-chip {chip_cls}">{chip_txt}</span> {label}</button>')
return ( return (
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">' f'<div id="cg-btn-{d}" class="cg-cell" hx-swap-oob="true">'
f'<div class="conge-btns">' f'<details class="cg-menu">'
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>' f'<summary class="cg-trigger" title="Congés, jour férié, astreinte">'
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>' f'<span class="cg-state">{state}</span>'
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>' f'<span class="cg-caret">▾</span>'
f'<button hx-post="/ferie/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-ferie{btn_f_on}" title="Jour férié (dû 0, astreinte comptée 0h00 → 0h00)">F</button>' f'</summary>'
f'<button hx-post="/astreinte/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-as{btn_as_on}" title="Jour d&#39;astreinte">AS</button>' f'<div class="cg-pop">'
f'</div></div>' + opt(f"/conge/{d}/matin", "ma", "MA", "Congé matin", "matin" in cg)
+ opt(f"/conge/{d}/aprem", "am", "AM", "Congé après-midi", "aprem" in cg)
+ opt(f"/conge/{d}/jour", "jour", "J", "Journée entière", "jour" in cg)
+ '<div class="cg-opt-sep"></div>'
+ opt(f"/ferie/{d}/toggle", "ferie", "F", "Jour férié", jour.get("ferie"),
"Jour férié : dû 0, astreinte comptée 0h00 → 0h00")
+ opt(f"/astreinte/{d}/toggle", "as", "AS", "Jour d&#39;astreinte", jour.get("astreinte"),
"Jour d&#39;astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)")
+ '</div>'
f'</details>'
f'</div>'
) )

View File

@ -18,6 +18,11 @@
<span class="jour-nom">{{ nom }}</span> <span class="jour-nom">{{ nom }}</span>
<span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span> <span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
{% if j.ferie %}<span class="ferie-badge" title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">férié</span>{% endif %} {% if j.ferie %}<span class="ferie-badge" title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">férié</span>{% endif %}
<!-- Auto-enregistrement : la sauvegarde se déclenche à la validation d'un
champ du jour (voir script de semaine.html), ce témoin en suit l'état. -->
<span class="autosave" id="save-{{ j.date }}" data-date="{{ j.date }}"
hx-post="/pointage/{{ j.date }}" hx-swap="none"
hx-include="[data-date='{{ j.date }}']" hx-trigger="save-day"></span>
</td> </td>
<td class="c-matin"> <td class="c-matin">
@ -84,38 +89,46 @@
</span> </span>
</td> </td>
<td class="col-save">
<button class="btn-valider"
hx-post="/pointage/{{ j.date }}"
hx-swap="none"
hx-include="[data-date='{{ j.date }}']"
title="Enregistrer">✓</button>
</td>
<td class="col-conge" data-label="Congé"> <td class="col-conge" data-label="Congé">
<div id="cg-btn-{{ j.date }}"> <div id="cg-btn-{{ j.date }}" class="cg-cell">
<div class="conge-btns"> <details class="cg-menu">
<button hx-post="/conge/{{ j.date }}/matin" <summary class="cg-trigger" title="Congés, jour férié, astreinte">
hx-swap="none" <span class="cg-state">
class="btn-cg {% if 'matin' in j.conge %}on{% endif %}" {%- if 'jour' in j.conge %}<span class="cg-chip st-jour">J</span>{% endif -%}
title="Congé matin">MA</button> {%- if 'matin' in j.conge %}<span class="cg-chip st-ma">MA</span>{% endif -%}
<button hx-post="/conge/{{ j.date }}/aprem" {%- if 'aprem' in j.conge %}<span class="cg-chip st-am">AM</span>{% endif -%}
hx-swap="none" {%- if j.ferie %}<span class="cg-chip st-ferie">F</span>{% endif -%}
class="btn-cg {% if 'aprem' in j.conge %}on{% endif %}" {%- if j.astreinte %}<span class="cg-chip st-as">AS</span>{% endif -%}
title="Congé après-midi">AM</button> {%- if not (j.conge or j.ferie or j.astreinte) %}<span class="cg-none">—</span>{% endif -%}
<button hx-post="/conge/{{ j.date }}/jour" </span>
hx-swap="none" <span class="cg-caret">▾</span>
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}" </summary>
title="Journée entière">J</button> <div class="cg-pop">
<button hx-post="/ferie/{{ j.date }}/toggle" <button hx-post="/conge/{{ j.date }}/matin" hx-swap="none"
hx-swap="none" class="cg-opt{% if 'matin' in j.conge %} on{% endif %}">
class="btn-cg btn-ferie {% if j.ferie %}ferie-on{% endif %}" <span class="cg-opt-chip ma">MA</span> Congé matin
title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">F</button> </button>
<button hx-post="/astreinte/{{ j.date }}/toggle" <button hx-post="/conge/{{ j.date }}/aprem" hx-swap="none"
hx-swap="none" class="cg-opt{% if 'aprem' in j.conge %} on{% endif %}">
class="btn-cg btn-as {% if j.astreinte %}as-on{% endif %}" <span class="cg-opt-chip am">AM</span> Congé après-midi
title="Jour d'astreinte">AS</button> </button>
</div> <button hx-post="/conge/{{ j.date }}/jour" hx-swap="none"
class="cg-opt{% if 'jour' in j.conge %} on{% endif %}">
<span class="cg-opt-chip jour">J</span> Journée entière
</button>
<div class="cg-opt-sep"></div>
<button hx-post="/ferie/{{ j.date }}/toggle" hx-swap="none"
class="cg-opt{% if j.ferie %} on{% endif %}"
title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">
<span class="cg-opt-chip ferie">F</span> Jour férié
</button>
<button hx-post="/astreinte/{{ j.date }}/toggle" hx-swap="none"
class="cg-opt{% if j.astreinte %} on{% endif %}"
title="Jour d'astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)">
<span class="cg-opt-chip as">AS</span> Jour d'astreinte
</button>
</div>
</details>
</div> </div>
</td> </td>
</tr> </tr>
@ -123,7 +136,7 @@
{% if j.astreinte_qualifiee %} {% if j.astreinte_qualifiee %}
{% set nb_as = (j.astreinte_events or []) | length %} {% set nb_as = (j.astreinte_events or []) | length %}
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}"> <tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}">
<td colspan="9"> <td colspan="8">
<div id="as-block-{{ j.date }}" class="as-block" data-date="{{ j.date }}" data-label="Astreinte"> <div id="as-block-{{ j.date }}" class="as-block" data-date="{{ j.date }}" data-label="Astreinte">
<div class="as-head"> <div class="as-head">
<button type="button" class="as-toggle" data-date="{{ j.date }}" aria-expanded="false" <button type="button" class="as-toggle" data-date="{{ j.date }}" aria-expanded="false"
@ -172,5 +185,5 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}"><td colspan="9"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr> <tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}"><td colspan="8"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr>
{% endif %} {% endif %}

View File

@ -1,4 +1,37 @@
<div id="soldes-section" hx-swap-oob="true"> <div id="soldes-section" hx-swap-oob="true">
{% if result.today %}
{% set jt = result.today.jour %}
<div class="today-panel">
<div class="today-main">
<div class="today-eyebrow">Aujourd'hui · {{ result.today.nom }} {{ jt.date[8:] }}/{{ jt.date[5:7] }}</div>
{% if 'jour' in jt.conge %}
<div class="today-cible today-neutral">En congé aujourd'hui</div>
{% elif jt.cible_aprem %}
<div class="today-cible">{{ jt.cible_aprem }}</div>
<div class="today-sub">sortie cible pour tenir le rythme · entrée {{ jt.cible_aprem_entree }}</div>
{% elif jt.cible_matin %}
<div class="today-cible">{{ jt.cible_matin }}</div>
<div class="today-sub">sortie cible du matin · entrée {{ jt.cible_matin_entree }}</div>
{% elif jt.du_min == 0 %}
<div class="today-cible today-neutral">Aucune heure due aujourd'hui</div>
{% else %}
<div class="today-cible today-neutral">Saisissez vos heures du jour</div>
{% endif %}
</div>
{% if result.today.pct is not none %}
<div class="today-prog">
<div class="today-prog-head">
<span>Semaine · <b>{{ result.today.trav_total }}</b> / {{ result.today.du_total }}</span>
<span>Solde <b class="{% if result.solde_min > 0 %}stat-pos{% elif result.solde_min < 0 %}stat-neg{% endif %}">{% if result.solde_min > 0 %}+{% endif %}{{ result.solde }}</b></span>
</div>
<div class="today-bar">
<div class="today-bar-fill{% if result.solde_min < 0 %} short{% elif result.reste_min == 0 %} full-pos{% endif %}" style="width: {{ result.today.pct }}%"></div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="soldes-wrap">
<div class="soldes"> <div class="soldes">
<div class="solde-item"> <div class="solde-item">
<div class="solde-eyebrow">Travaillé</div> <div class="solde-eyebrow">Travaillé</div>
@ -43,6 +76,7 @@
complétez ou corrigez les saisies, ou rattrapez sur une autre semaine. complétez ou corrigez les saisies, ou rattrapez sur une autre semaine.
</div> </div>
{% endif %} {% endif %}
</div>
{% if result.astreinte and result.astreinte.actif %} {% if result.astreinte and result.astreinte.actif %}
<div class="as-decompte"> <div class="as-decompte">
<div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div> <div class="as-decompte-title">Astreinte — compteur séparé, hors heures de semaine</div>

View File

@ -27,6 +27,10 @@
--conge-bd: #E8C97A; --conge-bd: #E8C97A;
--conge-txt: #7A5300; --conge-txt: #7A5300;
--nav-bg: #1C1712; --nav-bg: #1C1712;
--r-s: 5px;
--r-m: 8px;
--shadow-s: 0 1px 2px rgba(28,23,18,.05), 0 6px 20px -12px rgba(28,23,18,.12);
--shadow-m: 0 2px 6px rgba(28,23,18,.07), 0 16px 40px -18px rgba(28,23,18,.20);
--mono: 'DM Mono', 'Cascadia Code', ui-monospace, monospace; --mono: 'DM Mono', 'Cascadia Code', ui-monospace, monospace;
--sans: 'DM Sans', system-ui, -apple-system, sans-serif; --sans: 'DM Sans', system-ui, -apple-system, sans-serif;
} }
@ -40,6 +44,8 @@
min-height: 100vh; min-height: 100vh;
} }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ── Nav ── */ /* ── Nav ── */
nav { nav {
position: relative; position: relative;
@ -51,6 +57,7 @@
align-items: center; align-items: center;
gap: 2rem; gap: 2rem;
border-bottom: 3px solid var(--accent-2); border-bottom: 3px solid var(--accent-2);
box-shadow: 0 2px 10px rgba(28,23,18,.18);
} }
.nav-brand { .nav-brand {
font-family: var(--mono); font-family: var(--mono);
@ -177,7 +184,8 @@
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
transition: border-color .15s, color .15s; border-radius: var(--r-s);
transition: border-color .15s, color .15s, background .15s;
} }
.arrow-btn:hover { border-color: var(--text); color: var(--text); } .arrow-btn:hover { border-color: var(--text); color: var(--text); }
.week-id { .week-id {
@ -197,6 +205,7 @@
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-s);
padding: .4rem .75rem; padding: .4rem .75rem;
transition: border-color .15s, color .15s; transition: border-color .15s, color .15s;
white-space: nowrap; white-space: nowrap;
@ -204,11 +213,16 @@
.export-btn:hover { border-color: var(--accent); color: var(--accent); } .export-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Soldes ── */ /* ── Soldes ── */
.soldes-wrap {
border: 1.5px solid var(--rule);
border-radius: var(--r-m);
background: var(--surface);
box-shadow: var(--shadow-s);
overflow: hidden;
}
.soldes { .soldes {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
border: 1.5px solid var(--rule);
background: var(--surface);
} }
@media (max-width: 600px) { .soldes { grid-template-columns: 1fr 1fr; } } @media (max-width: 600px) { .soldes { grid-template-columns: 1fr 1fr; } }
.solde-item { .solde-item {
@ -216,6 +230,10 @@
border-right: 1px solid var(--rule); border-right: 1px solid var(--rule);
} }
.solde-item:last-child { border-right: none; } .solde-item:last-child { border-right: none; }
@media (max-width: 600px) {
.solde-item:nth-child(2n) { border-right: none; }
.solde-item:nth-child(-n+2) { border-bottom: 1px solid var(--rule); }
}
.solde-item.item-pos { background: var(--pos-bg); } .solde-item.item-pos { background: var(--pos-bg); }
.solde-item.item-neg { background: var(--neg-bg); } .solde-item.item-neg { background: var(--neg-bg); }
.solde-eyebrow { .solde-eyebrow {
@ -233,6 +251,7 @@
letter-spacing: -.03em; letter-spacing: -.03em;
line-height: 1; line-height: 1;
} }
.solde-item:last-child .solde-num { font-size: 1.9rem; }
.solde-num.pos { color: var(--pos); } .solde-num.pos { color: var(--pos); }
.solde-num.neg { color: var(--neg); } .solde-num.neg { color: var(--neg); }
.solde-num.neutral { color: var(--text); } .solde-num.neutral { color: var(--text); }
@ -247,12 +266,92 @@
.reste-info strong { color: var(--text); font-family: var(--mono); font-weight: 500; } .reste-info strong { color: var(--text); font-family: var(--mono); font-weight: 500; }
.reste-info strong.reste-extra { color: var(--neg); } .reste-info strong.reste-extra { color: var(--neg); }
/* ── Panneau aujourd'hui ── */
.today-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .9rem 2.25rem;
border: 1.5px solid var(--rule);
border-left: 4px solid var(--accent);
border-radius: var(--r-m);
background: var(--surface);
box-shadow: var(--shadow-s);
padding: .9rem 1.25rem;
}
.today-eyebrow {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .3rem;
}
.today-cible {
font-family: var(--mono);
font-size: 1.9rem;
font-weight: 500;
letter-spacing: -.03em;
line-height: 1;
color: var(--accent);
}
.today-cible.today-neutral { color: var(--text); font-size: 1.1rem; }
.today-sub { font-size: 11px; color: var(--muted); margin-top: .3rem; }
.today-prog { flex: 1; min-width: 220px; }
.today-prog-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: .75rem;
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
margin-bottom: .35rem;
}
.today-prog-head b { color: var(--text); font-weight: 500; font-size: 12px; }
.today-bar {
height: 8px;
border-radius: 999px;
background: var(--ground);
border: 1px solid var(--rule-l);
overflow: hidden;
}
.today-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width .4s ease;
}
.today-bar-fill.full-pos { background: var(--pos); }
.today-bar-fill.over { background: var(--accent-2); }
.today-bar-fill.short { background: var(--neg); }
@media (max-width: 700px) {
.today-panel { gap: .6rem; }
.today-prog { flex-basis: 100%; min-width: 0; }
.today-cible { font-size: 1.6rem; }
}
/* ── Table ── */ /* ── Table ── */
.table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-section {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Rayons sur la table desktop : border-collapse separate, sinon le radius
est ignoré. Le menu d'état s'ouvre en position fixe (voir semaine.html)
pour ne pas être rogné par le scroll horizontal du conteneur. */
@media (min-width: 701px) {
.pointage-table {
border: 1.5px solid var(--rule);
border-radius: var(--r-m);
border-collapse: separate;
border-spacing: 0;
box-shadow: var(--shadow-s);
}
.pointage-table tbody tr:last-child td { border-bottom: none; }
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
border: 1.5px solid var(--rule);
background: var(--surface); background: var(--surface);
white-space: nowrap; white-space: nowrap;
} }
@ -359,16 +458,16 @@
width: 78px; width: 78px;
padding: .28rem .3rem; padding: .28rem .3rem;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 0; border-radius: var(--r-s);
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
background: transparent; background: transparent;
text-align: left; text-align: left;
transition: border-color .15s; transition: border-color .15s, background .15s, box-shadow .15s;
} }
.time-input:hover { border-color: var(--muted); } .time-input:hover { border-color: var(--muted); }
.time-input:focus { outline: none; border-color: var(--accent); background: #fff; } .time-input:focus { outline: none; border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px rgba(47,95,160,.12); }
/* L'icône d'horloge native a une largeur fixe qui, avec un champ centré trop /* L'icône d'horloge native a une largeur fixe qui, avec un champ centré trop
étroit, empiète sur le dernier chiffre : on aligne le texte à gauche et on étroit, empiète sur le dernier chiffre : on aligne le texte à gauche et on
resserre le padding de l'icône plutôt que de lui laisser sa marge par défaut. */ resserre le padding de l'icône plutôt que de lui laisser sa marge par défaut. */
@ -378,6 +477,26 @@
} }
tr.htmx-request .time-input { opacity: .4; } tr.htmx-request .time-input { opacity: .4; }
/* Auto-enregistrement : déclenché au changement d'un champ du jour, la
sauvegarde part dès que le champ est validé (blur/picker). Le petit
témoin dans la colonne Jour suit l'état de la requête. */
.autosave {
display: inline-flex;
align-items: center;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .06em;
color: var(--muted);
margin-left: .45rem;
vertical-align: middle;
opacity: 0;
transition: opacity .2s;
pointer-events: none;
}
.autosave.busy, .autosave.ok { opacity: 1; }
.autosave.busy::after { content: 'enregistrement…'; }
.autosave.ok::after { content: '✓ enregistré'; color: var(--pos); }
.col-calc { text-align: right; padding-left: .5rem; } .col-calc { text-align: right; padding-left: .5rem; }
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); } .calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); }
.delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); } .delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); }
@ -397,47 +516,97 @@
.g-cible { text-align: right; } .g-cible { text-align: right; }
.cible-num { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 500; display: inline-block; } .cible-num { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 500; display: inline-block; }
.col-save { text-align: center; padding: .4rem .3rem; } /* ── Menu congé / férié / astreinte ──
.g-save { background: var(--ground); } Un seul bouton par ligne résume l'état (pastilles colorées) et ouvre
.btn-valider { le menu des cinq bascules : congé matin / après-midi / journée, férié,
display: inline-flex; jour d'astreinte. */
align-items: center;
justify-content: center;
width: 26px; height: 26px;
border: 1.5px solid var(--accent);
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--accent);
transition: background .15s, color .15s;
}
.btn-valider:hover { background: var(--accent); color: #fff; }
.btn-valider.htmx-request { opacity: .4; pointer-events: none; }
.col-conge { text-align: center; } .col-conge { text-align: center; }
.conge-btns { display: flex; gap: 3px; justify-content: center; } .cg-cell { position: relative; display: inline-block; }
.btn-cg { .cg-menu { position: relative; }
.cg-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; gap: .3rem;
width: 24px; height: 24px;
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-s);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
padding: .22rem .45rem;
min-height: 26px;
transition: border-color .15s, background .15s;
list-style: none;
}
.cg-trigger::-webkit-details-marker { display: none; }
.cg-trigger:hover { border-color: var(--muted); background: var(--ground); }
.cg-state { display: inline-flex; gap: 3px; align-items: center; justify-content: center; min-width: 20px; }
.cg-chip {
font-family: var(--mono); font-family: var(--mono);
font-size: 9px; font-size: 8.5px;
font-weight: 500; font-weight: 500;
letter-spacing: .04em; letter-spacing: .04em;
color: var(--muted); border-radius: 3px;
transition: all .15s; padding: .12rem .24rem;
line-height: 1;
} }
.btn-cg:hover { border-color: var(--conge-txt); color: var(--conge-txt); background: var(--conge-bg); } .cg-chip.st-ma, .cg-chip.st-am { background: var(--conge-bg); border: 1px solid var(--conge-bd); color: var(--conge-txt); }
.btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); } .cg-chip.st-jour { background: var(--accent-2); color: #fff; }
.btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; } .cg-chip.st-ferie { background: #7B5EA7; color: #fff; }
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; } .cg-chip.st-as { background: var(--accent); color: #fff; }
.btn-cg.btn-as { border-radius: 4px; } .cg-none { color: var(--rule); font-family: var(--mono); font-size: 10px; }
.btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; } .cg-caret { color: var(--muted); font-size: 9px; transition: transform .15s; }
.btn-cg.btn-ferie { border-radius: 4px; } .cg-menu[open] > .cg-trigger { border-color: var(--accent); }
.btn-cg.ferie-on { background: #7B5EA7; border-color: #7B5EA7; color: #fff; } .cg-menu[open] .cg-caret { transform: rotate(180deg); }
.cg-pop {
position: absolute;
right: 0;
top: calc(100% + .35rem);
z-index: 150;
background: var(--surface);
border: 1.5px solid var(--rule);
border-radius: var(--r-m);
box-shadow: var(--shadow-m);
padding: .35rem;
min-width: 178px;
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
}
.cg-opt {
display: flex;
align-items: center;
gap: .5rem;
border: none;
background: transparent;
border-radius: var(--r-s);
padding: .38rem .5rem;
font-family: var(--sans);
font-size: 12px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
transition: background .12s;
}
.cg-opt:hover { background: var(--ground); }
.cg-opt-chip {
font-family: var(--mono);
font-size: 8.5px;
font-weight: 500;
letter-spacing: .04em;
border-radius: 3px;
padding: .12rem .26rem;
border: 1px solid var(--rule);
color: var(--muted);
min-width: 22px;
text-align: center;
flex-shrink: 0;
line-height: 1;
}
.cg-opt.on { background: var(--conge-bg); }
.cg-opt.on .cg-opt-chip.ma, .cg-opt.on .cg-opt-chip.am { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); }
.cg-opt.on .cg-opt-chip.jour { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
.cg-opt.on .cg-opt-chip.ferie { background: #7B5EA7; border-color: #7B5EA7; color: #fff; }
.cg-opt.on .cg-opt-chip.as { background: var(--accent); border-color: var(--accent); color: #fff; }
.ferie-badge { .ferie-badge {
display: inline-block; display: inline-block;
font-family: var(--mono); font-family: var(--mono);
@ -469,6 +638,7 @@
align-items: center; align-items: center;
gap: .35rem; gap: .35rem;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: var(--r-s);
background: transparent; background: transparent;
padding: .15rem .5rem; padding: .15rem .5rem;
cursor: pointer; cursor: pointer;
@ -511,7 +681,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 0; border-radius: var(--r-s);
padding: .25rem .3rem; padding: .25rem .3rem;
font-size: 11px; font-size: 11px;
font-family: var(--sans); font-family: var(--sans);
@ -527,6 +697,7 @@
justify-content: center; justify-content: center;
width: 18px; height: 18px; width: 18px; height: 18px;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-family: var(--mono); font-family: var(--mono);
@ -560,7 +731,9 @@
/* ── Décompte astreinte (section soldes) ── */ /* ── Décompte astreinte (section soldes) ── */
.as-decompte { .as-decompte {
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: var(--r-m);
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow-s);
margin-top: .6rem; margin-top: .6rem;
padding: .55rem .75rem; padding: .55rem .75rem;
font-size: 12px; font-size: 12px;
@ -679,10 +852,9 @@
min-width: 0; min-width: 0;
} }
.pointage-table td.col-save { text-align: right; } .pointage-table td.col-conge { text-align: right; padding-right: .75rem; }
.pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; } .pointage-table .cg-trigger { min-height: 36px; padding: .3rem .6rem; }
.pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; } .pointage-table .cg-chip { font-size: 9.5px; padding: .18rem .3rem; }
.pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; }
.pointage-table .as-block { padding: .4rem .75rem .55rem; align-items: stretch; } .pointage-table .as-block { padding: .4rem .75rem .55rem; align-items: stretch; }
.as-grid { grid-template-columns: 82px 82px minmax(90px, 1fr); } .as-grid { grid-template-columns: 82px 82px minmax(90px, 1fr); }
.as-desc { font-size: 13px; padding: .4rem .4rem; } .as-desc { font-size: 13px; padding: .4rem .4rem; }
@ -719,6 +891,7 @@
font-size: 11px; font-size: 11px;
padding: .18rem .5rem; padding: .18rem .5rem;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 4px;
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
letter-spacing: .02em; letter-spacing: .02em;
@ -765,7 +938,8 @@
z-index: 100; z-index: 100;
background: var(--surface); background: var(--surface);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
box-shadow: 0 4px 20px rgba(28,23,18,.12); border-radius: var(--r-m);
box-shadow: var(--shadow-m);
padding: .75rem; padding: .75rem;
min-width: 260px; min-width: 260px;
max-width: calc(100vw - 2rem); max-width: calc(100vw - 2rem);
@ -789,6 +963,7 @@
.cal-nav { .cal-nav {
background: none; background: none;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: var(--r-s);
width: 24px; height: 24px; width: 24px; height: 24px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
@ -877,7 +1052,9 @@
text-align: center; text-align: center;
padding: .75rem 1.1rem; padding: .75rem 1.1rem;
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow-s);
flex-shrink: 0; flex-shrink: 0;
} }
.stats-overall-num { .stats-overall-num {
@ -907,6 +1084,9 @@
gap: 1px; gap: 1px;
background: var(--rule); background: var(--rule);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
overflow: hidden;
box-shadow: var(--shadow-s);
} }
@media (max-width: 520px) { .comp-periods { grid-template-columns: 1fr; } } @media (max-width: 520px) { .comp-periods { grid-template-columns: 1fr; } }
@ -966,6 +1146,8 @@
gap: 1px; gap: 1px;
background: var(--rule); background: var(--rule);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-s);
overflow: hidden;
} }
.ptab { .ptab {
padding: .38rem .9rem; padding: .38rem .9rem;
@ -990,6 +1172,9 @@
gap: 1px; gap: 1px;
background: var(--rule); background: var(--rule);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
overflow: hidden;
box-shadow: var(--shadow-s);
} }
@media (min-width: 800px) { .ts-grid { grid-template-columns: repeat(4, 1fr); } } @media (min-width: 800px) { .ts-grid { grid-template-columns: repeat(4, 1fr); } }
@ -1094,6 +1279,8 @@
.wkly-section { .wkly-section {
background: var(--surface); background: var(--surface);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
box-shadow: var(--shadow-s);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.wkly-eyebrow { .wkly-eyebrow {
@ -1139,6 +1326,7 @@
background: var(--neg); background: var(--neg);
color: #fff; color: #fff;
border-left: 4px solid #ff6b5b; border-left: 4px solid #ff6b5b;
border-radius: var(--r-m);
box-shadow: 0 6px 28px rgba(0,0,0,.3); box-shadow: 0 6px 28px rgba(0,0,0,.3);
min-width: 240px; min-width: 240px;
animation: alerte-slide .35s cubic-bezier(.2,.8,.3,1) both; animation: alerte-slide .35s cubic-bezier(.2,.8,.3,1) both;
@ -1205,6 +1393,8 @@
.login-card { .login-card {
background: var(--surface); background: var(--surface);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
box-shadow: var(--shadow-m);
padding: 2.5rem 3rem; padding: 2.5rem 3rem;
width: 380px; width: 380px;
max-width: 100%; max-width: 100%;
@ -1248,6 +1438,7 @@
font-size: 15px; font-size: 15px;
padding: .6rem .75rem; padding: .6rem .75rem;
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-s);
background: var(--ground); background: var(--ground);
color: var(--text); color: var(--text);
outline: none; outline: none;
@ -1266,6 +1457,7 @@
background: var(--text); background: var(--text);
color: var(--surface); color: var(--surface);
border: none; border: none;
border-radius: var(--r-s);
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
} }
@ -1292,6 +1484,7 @@
position: relative; position: relative;
background: var(--surface); background: var(--surface);
border: 1.5px solid var(--rule); border: 1.5px solid var(--rule);
border-radius: var(--r-m);
max-width: 92vw; max-width: 92vw;
max-height: 90vh; max-height: 90vh;
overflow: auto; overflow: auto;

View File

@ -54,8 +54,7 @@
<th class="g-calc" rowspan="2">Δ jour</th> <th class="g-calc" rowspan="2">Δ jour</th>
<th class="g-cumul" rowspan="2">Δ sem.</th> <th class="g-cumul" rowspan="2">Δ sem.</th>
<th class="g-cible" rowspan="2">Cible</th> <th class="g-cible" rowspan="2">Cible</th>
<th class="g-save" rowspan="2"></th> <th class="g-conge" rowspan="2">État</th>
<th class="g-conge" rowspan="2">Congé</th>
</tr> </tr>
<tr class="th-sub"> <tr class="th-sub">
<th class="s-matin">entrée &rarr; sortie</th> <th class="s-matin">entrée &rarr; sortie</th>
@ -367,6 +366,106 @@ document.addEventListener('input', function(e) {
}); });
</script> </script>
<script>
// Auto-enregistrement — chaque champ validé (change = blur, Entrée ou picker)
// programme la sauvegarde du jour ; un léger délai regroupe les saisies d'affilée.
// La requête part du témoin .autosave de la ligne (attributs hx-*), qui porte
// aussi les états « enregistrement… / ✓ enregistré ».
(function () {
var timer = null;
var pendingDate = null;
function triggerSave(date) {
var trig = document.getElementById('save-' + date);
if (trig) htmx.trigger(trig, 'save-day');
}
// Le swap OOB remplace les champs du jour : si le focus était dedans
// (ex. tabulation entrée → sortie), on le restaure sur le champ équivalent.
var focusKey = null;
document.addEventListener('change', function (e) {
var t = e.target;
if (!t.matches('.time-input, .as-desc')) return;
var date = t.dataset.date;
if (!date) return;
var a = document.activeElement;
if (a && a.dataset && a.dataset.date === date && a.name) {
focusKey = a.name + '|' + a.dataset.date;
}
pendingDate = date;
clearTimeout(timer);
timer = setTimeout(function () { triggerSave(pendingDate); }, 350);
});
document.body.addEventListener('htmx:afterSwap', function () {
if (!focusKey) return;
var parts = focusKey.split('|');
focusKey = null;
if (document.activeElement && document.activeElement !== document.body) return;
var el = document.querySelector('[name="' + parts[0] + '"][data-date="' + parts[1] + '"]');
if (el) el.focus();
});
document.body.addEventListener('htmx:beforeRequest', function (e) {
var el = e.target;
if (el.classList && el.classList.contains('autosave')) el.classList.add('busy');
});
document.body.addEventListener('htmx:afterOnLoad', function (e) {
var el = e.target;
if (!el.classList || !el.classList.contains('autosave')) return;
el.classList.remove('busy');
el.classList.add('ok');
clearTimeout(el._okTimer);
el._okTimer = setTimeout(function () { el.classList.remove('ok'); }, 2000);
});
document.body.addEventListener('htmx:responseError', function (e) {
var el = e.target;
if (el.classList && el.classList.contains('autosave')) el.classList.remove('busy');
});
})();
</script>
<script>
// Menu congé/férié/astreinte — un seul ouvert à la fois, fermeture au clic
// extérieur ; après une bascule, le swap OOB remplace le bouton (menu refermé).
// Le panneau s'ouvre en position fixe sous le bouton : ancré en absolute il
// serait rogné par le scroll horizontal du conteneur de la table.
(function () {
function placePop(d) {
var pop = d.querySelector('.cg-pop');
if (!pop) return;
if (d.open) {
var r = d.getBoundingClientRect();
pop.style.position = 'fixed';
pop.style.top = (r.bottom + 6) + 'px';
var w = Math.max(178, r.width);
pop.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w)) + 'px';
} else {
pop.style.position = '';
pop.style.top = '';
pop.style.left = '';
}
}
document.addEventListener('click', function (e) {
document.querySelectorAll('details.cg-menu[open]').forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
document.addEventListener('toggle', function (e) {
var d = e.target;
if (!(d instanceof HTMLDetailsElement) || !d.classList.contains('cg-menu')) return;
if (d.open) {
document.querySelectorAll('details.cg-menu[open]').forEach(function (o) {
if (o !== d) o.removeAttribute('open');
});
}
placePop(d);
}, true);
})();
</script>
<script> <script>
// Astreinte — heures d'intervention dans un menu dépliant fermé par défaut. // Astreinte — heures d'intervention dans un menu dépliant fermé par défaut.
// L'état ouvert est mémorisé par date et restauré après les swaps HTMX // L'état ouvert est mémorisé par date et restauré après les swaps HTMX

View File

@ -95,7 +95,7 @@ def test_route_toggle_ferie():
assert models.load_feries("u-ferie-route") == ["2026-07-15"] assert models.load_feries("u-ferie-route") == ["2026-07-15"]
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
assert r.status_code == 200 assert r.status_code == 200
assert "ferie-on" in r.text # bouton F actif assert 'cg-chip st-ferie">F<' in r.text # pastille F active dans le bouton d'état
assert "ferie-badge" in r.text # badge « férié » sur la ligne du jour assert "ferie-badge" in r.text # badge « férié » sur la ligne du jour
client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False) client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False)
assert models.load_feries("u-ferie-route") == [] assert models.load_feries("u-ferie-route") == []

View File

@ -0,0 +1,78 @@
"""Refonte de l'interface : auto-enregistrement (plus de bouton ✓), menu
d'état unique (congé / férié / astreinte) et panneau aujourd'hui.
Vérifie le rendu de la page semaine et les réponses HTMX (swaps OOB) qui
régénèrent ces composants.
"""
import datetime as dt_module
import sys
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
import models # noqa: F401
sys.path.insert(0, ".")
import main
from fastapi.testclient import TestClient
client = TestClient(main.app)
TODAY = "2026-07-17" # vendredi de la semaine 29 (cf. datetime gelé)
class _FrozenDatetime(dt_module.datetime):
@classmethod
def today(cls):
return cls(2026, 7, 17, 10, 30)
def _login(uid: str) -> dict:
import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password",
data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False)
assert r.status_code == 302, r.text
return {"user_id": r.cookies.get("user_id")}
def test_page_semaine_rend_les_nouveaux_composants(monkeypatch):
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
cookies = _login("u-ui")
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
assert r.status_code == 200
# Auto-enregistrement : un témoin par jour, plus de bouton ✓ ni sa colonne
assert "btn-valider" not in r.text
assert "col-save" not in r.text
assert r.text.count('class="autosave"') == 7
assert 'hx-trigger="save-day"' in r.text
# Menu d'état : un par jour, options des cinq bascules
assert r.text.count('class="cg-menu"') == 7
assert "Congé matin" in r.text
assert "Jour férié" in r.text
assert 'cg-opt-chip as">AS</span>' in r.text # option jour d'astreinte
# Panneau aujourd'hui (le jour gelé appartient à la semaine affichée)
assert "today-panel" in r.text
assert "Aujourd'hui ·" in r.text # Jinja laisse l'apostrophe telle quelle
def test_pointage_htmx_rafrachit_panneau_et_compteurs(monkeypatch):
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
cookies = _login("u-ui-pointage")
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
data={"matin_entree": "08:00", "matin_sortie": "12:00"},
headers={"HX-Request": "true"}, follow_redirects=False)
assert r.status_code == 200
assert f'id="calc-{TODAY}-trav"' in r.text # compteurs OOB
assert "today-panel" in r.text # soldes + panneau re-rendus
def test_bascule_congen_regenere_le_menu_d_etat(monkeypatch):
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
cookies = _login("u-ui-conge")
r = client.post(f"/conge/{TODAY}/matin", cookies=cookies,
headers={"HX-Request": "true"}, follow_redirects=False)
assert r.status_code == 200
# Le swap OOB régénère le bouton avec la pastille MA active
assert 'cg-chip st-ma">MA<' in r.text
assert 'id="cg-btn-' + TODAY + '"' in r.text