Astreintes : interventions dans un menu dépliant fermé, cadre élargi

Les heures d'intervention (début, fin, description) passent en trois
colonnes alignées, cachées par défaut derrière un bouton « N
interventions » ; l'heure de retour et le total restent visibles.
L'état déplié survit aux rafraîchissements HTMX. Le conteneur passe
de 1100 à 1400 px pour que les lignes ne dépassent plus du cadre.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 22:40:06 +02:00
parent c208a17eec
commit 83f5512cb6
5 changed files with 147 additions and 64 deletions

View File

@ -182,8 +182,9 @@ demi-journées restantes.
## Astreinte & repos de 11h ## Astreinte & repos de 11h
Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui
suit), une liste d'événements apparaît sous l'après-midi : autant de lignes que suit), un menu dépliant apparaît sous l'après-midi, fermé par défaut (seuls
nécessaire (**+ intervention**), chacune avec un **début**, une **fin** et une l'heure de retour et le total restent visibles). Déplié, il offre autant de lignes
que nécessaire (**+ intervention**) sur trois colonnes : **début**, **fin** et
description libre. Une fin antérieure au début (fin < début) désigne une description libre. Une fin antérieure au début (fin < début) désigne une
intervention à cheval sur minuit. intervention à cheval sur minuit.

View File

@ -452,22 +452,23 @@ def _btns_html(date, jour):
def _as_event_html(date, ev): def _as_event_html(date, ev):
"""Une ligne d'intervention d'astreinte : début → fin, description, suppression.""" """Une ligne d'intervention d'astreinte : début, fin, description (3 colonnes
de la grille), suppression."""
return ( return (
f'<div class="as-event">' f'<div class="as-event">'
f'<input class="time-input" type="time" name="astreinte_debut" data-date="{_e(date)}" value="{_e(ev.get("debut") or "")}">' f'<input class="time-input" type="time" name="astreinte_debut" data-date="{_e(date)}" value="{_e(ev.get("debut") or "")}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="astreinte_fin" data-date="{_e(date)}" value="{_e(ev.get("fin") or "")}">' f'<input class="time-input" type="time" name="astreinte_fin" data-date="{_e(date)}" value="{_e(ev.get("fin") or "")}">'
f'<div class="as-desc-cell">'
f'<input type="text" class="as-desc" name="astreinte_desc" data-date="{_e(date)}" maxlength="200" placeholder="description" value="{_e(ev.get("desc") or "")}">' f'<input type="text" class="as-desc" name="astreinte_desc" data-date="{_e(date)}" maxlength="200" placeholder="description" value="{_e(ev.get("desc") or "")}">'
f'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>' f'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>'
f'</div>' f'</div></div>'
) )
def _as_block_html(date, jour): def _as_block_html(date, jour):
"""Bloc astreinte d'une ligne : événements d'intervention (début/fin, nombre """Bloc astreinte d'une ligne : les heures d'intervention (début, fin,
libre, description), qui déclenchent les 11h de repos et décalent l'heure de description) sont regroupées dans un menu dépliant fermé par défaut ; seuls
retour, plus le décompte du jour (compteur séparé, hors heures de semaine). le bouton, l'heure de retour (11h de repos) et le décompte restent visibles.
Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide ailleurs.""" Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide ailleurs."""
if not jour.get("astreinte_qualifiee"): if not jour.get("astreinte_qualifiee"):
return f'<div id="as-block-{_e(date)}" hx-swap-oob="true"></div>' return f'<div id="as-block-{_e(date)}" hx-swap-oob="true"></div>'
@ -481,12 +482,22 @@ def _as_block_html(date, jour):
if jour.get("astreinte_min"): if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">' total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">'
f'astreinte : {_e(jour["astreinte_h"])}</span>') f'astreinte : {_e(jour["astreinte_h"])}</span>')
nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '')
return ( return (
f'<div id="as-block-{_e(date)}" class="as-block" data-label="Astreinte" hx-swap-oob="true">' f'<div id="as-block-{_e(date)}" class="as-block" data-date="{_e(date)}" data-label="Astreinte" hx-swap-oob="true">'
f'<div class="as-events" id="as-events-{_e(date)}">{rows}</div>' f'<div class="as-head">'
f'<button type="button" class="as-toggle" data-date="{_e(date)}" aria-expanded="false" onclick="asToggle(this)">'
f'<span class="as-caret">▸</span> {label}</button>'
f'{hint}{total}'
f'</div>'
f'<div class="as-body">'
f'<div class="as-grid" id="as-events-{_e(date)}">'
f'<span class="as-th">Début</span><span class="as-th">Fin</span><span class="as-th">Description</span>'
f'{rows}'
f'</div>'
f'<button type="button" class="as-add" onclick="asAddEvent(\'{_e(date)}\')">+ intervention</button>' f'<button type="button" class="as-add" onclick="asAddEvent(\'{_e(date)}\')">+ intervention</button>'
f'{hint}' f'</div>'
f'{total}'
f'</div>' f'</div>'
) )

View File

@ -42,38 +42,50 @@
</div> </div>
</div> </div>
{% if j.astreinte_qualifiee %} {% if j.astreinte_qualifiee %}
<div id="as-block-{{ j.date }}" class="as-block" data-label="Astreinte"> {% set nb_as = (j.astreinte_events or []) | length %}
<div class="as-events" id="as-events-{{ j.date }}"> <div id="as-block-{{ j.date }}" class="as-block" data-date="{{ j.date }}" data-label="Astreinte">
{% for ev in j.astreinte_events or [] %} <div class="as-head">
<div class="as-event"> <button type="button" class="as-toggle" data-date="{{ j.date }}" aria-expanded="false"
<input class="time-input" type="time" name="astreinte_debut" onclick="asToggle(this)">
data-date="{{ j.date }}" value="{{ ev.debut or '' }}"> <span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
<span class="time-sep">→</span> </button>
<input class="time-input" type="time" name="astreinte_fin" {% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité">retour ≥ {{ j.retour }}</span>{% endif %}
data-date="{{ j.date }}" value="{{ ev.fin or '' }}"> {% if j.astreinte_min %}<span class="as-total" title="Compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
<input type="text" class="as-desc" name="astreinte_desc" </div>
data-date="{{ j.date }}" maxlength="200" placeholder="description" <div class="as-body">
value="{{ ev.desc or '' }}"> <div class="as-grid" id="as-events-{{ j.date }}">
<button type="button" class="as-del" title="Supprimer" <span class="as-th">Début</span><span class="as-th">Fin</span><span class="as-th">Description</span>
onclick="this.closest('.as-event').remove()">×</button> {% for ev in j.astreinte_events or [] %}
</div> <div class="as-event">
{% else %} <input class="time-input" type="time" name="astreinte_debut"
<div class="as-event"> data-date="{{ j.date }}" value="{{ ev.debut or '' }}">
<input class="time-input" type="time" name="astreinte_debut" <input class="time-input" type="time" name="astreinte_fin"
data-date="{{ j.date }}" value=""> data-date="{{ j.date }}" value="{{ ev.fin or '' }}">
<span class="time-sep">→</span> <div class="as-desc-cell">
<input class="time-input" type="time" name="astreinte_fin" <input type="text" class="as-desc" name="astreinte_desc"
data-date="{{ j.date }}" value=""> data-date="{{ j.date }}" maxlength="200" placeholder="description"
<input type="text" class="as-desc" name="astreinte_desc" value="{{ ev.desc or '' }}">
data-date="{{ j.date }}" maxlength="200" placeholder="description" value=""> <button type="button" class="as-del" title="Supprimer"
<button type="button" class="as-del" title="Supprimer" onclick="this.closest('.as-event').remove()">×</button>
onclick="this.closest('.as-event').remove()">×</button> </div>
</div> </div>
{% endfor %} {% else %}
<div class="as-event">
<input class="time-input" type="time" name="astreinte_debut"
data-date="{{ j.date }}" value="">
<input class="time-input" type="time" name="astreinte_fin"
data-date="{{ j.date }}" value="">
<div class="as-desc-cell">
<input type="text" class="as-desc" name="astreinte_desc"
data-date="{{ j.date }}" maxlength="200" placeholder="description" value="">
<button type="button" class="as-del" title="Supprimer"
onclick="this.closest('.as-event').remove()">×</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="as-add" onclick="asAddEvent('{{ j.date }}')">+ intervention</button>
</div> </div>
<button type="button" class="as-add" onclick="asAddEvent('{{ j.date }}')">+ intervention</button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div> </div>
{% else %} {% else %}
<div id="as-block-{{ j.date }}"></div> <div id="as-block-{{ j.date }}"></div>

View File

@ -143,7 +143,7 @@
/* ── Layout ── */ /* ── Layout ── */
.container { .container {
max-width: 1100px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
display: flex; display: flex;
@ -431,35 +431,62 @@
.btn-cg.btn-as { border-radius: 4px; } .btn-cg.btn-as { border-radius: 4px; }
.btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; } .btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Astreinte : opération hors horaires + heure de retour après 11h de repos */ /* Astreinte : heures d'intervention (début/fin/description) repliées dans un
menu dépliant fermé par défaut ; retour 11h et total restent visibles. */
.as-block { .as-block {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 5px; gap: .3rem;
flex-wrap: wrap;
padding: .3rem .65rem .45rem; padding: .3rem .65rem .45rem;
border-top: 1px dashed var(--rule); border-top: 1px dashed var(--rule);
} }
.as-label { .as-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.as-toggle {
display: inline-flex;
align-items: center;
gap: .35rem;
border: 1px solid var(--rule);
background: transparent;
padding: .15rem .5rem;
cursor: pointer;
font-family: var(--mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--accent-2);
white-space: nowrap;
transition: border-color .15s;
}
.as-toggle:hover { border-color: var(--accent-2); }
.as-caret { display: inline-block; transition: transform .15s; }
.as-block.open .as-caret { transform: rotate(90deg); }
.as-body { display: none; flex-direction: column; gap: .35rem; }
.as-block.open .as-body { display: flex; }
.as-grid {
display: grid;
grid-template-columns: 84px 84px minmax(110px, 1fr);
gap: 3px 6px;
align-items: center;
}
.as-grid .as-th {
font-family: var(--mono); font-family: var(--mono);
font-size: 9px; font-size: 9px;
letter-spacing: .06em; letter-spacing: .06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--accent-2); color: var(--muted);
white-space: nowrap;
} }
.as-block .time-input { width: 68px; } .as-grid .time-input { width: 100%; }
.as-event { display: contents; }
.as-desc-cell { display: flex; align-items: center; gap: 4px; min-width: 0; }
.as-hint { .as-hint {
font-family: var(--mono); font-family: var(--mono);
font-size: 10px; font-size: 10px;
color: var(--accent); color: var(--accent);
white-space: nowrap; white-space: nowrap;
} }
.as-events { display: flex; flex-direction: column; gap: 3px; width: 100%; }
.as-event { display: flex; align-items: center; gap: 4px; }
.as-desc { .as-desc {
flex: 1; flex: 1;
min-width: 60px; min-width: 0;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 0; border-radius: 0;
padding: .25rem .3rem; padding: .25rem .3rem;
@ -487,6 +514,7 @@
} }
.as-del:hover { border-color: var(--neg); color: var(--neg); } .as-del:hover { border-color: var(--neg); color: var(--neg); }
.as-add { .as-add {
align-self: flex-start;
border: none; border: none;
background: none; background: none;
padding: 0; padding: 0;
@ -600,8 +628,8 @@
.pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; } .pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; }
.pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; } .pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; }
.pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; } .pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; }
.as-block { padding: .4rem .75rem .55rem; } .pointage-table .as-block { padding: .4rem .75rem .55rem; align-items: stretch; }
.as-block .time-input { width: 92px; } .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; }
} }

View File

@ -368,19 +368,50 @@ document.addEventListener('input', function(e) {
</script> </script>
<script> <script>
// Astreinte — ajoute une ligne d'intervention vierge dans le jour ciblé // 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
// (le bloc entier est re-rendu côté serveur à chaque enregistrement).
var asOpen = {};
window.asToggle = function (btn) {
var block = document.getElementById('as-block-' + btn.dataset.date);
if (!block) return;
block.classList.toggle('open');
asSync(block);
};
function asSync(block) {
var open = block.classList.contains('open');
asOpen[block.dataset.date] = open;
var btn = block.querySelector('.as-toggle');
if (btn) btn.setAttribute('aria-expanded', String(open));
var caret = block.querySelector('.as-caret');
if (caret) caret.textContent = open ? '▾' : '▸';
}
function asRestore() {
document.querySelectorAll('.as-block[data-date]').forEach(function (block) {
if (asOpen[block.dataset.date]) block.classList.add('open');
asSync(block);
});
}
asRestore();
document.addEventListener('htmx:afterSwap', asRestore);
// Ajoute une ligne d'intervention vierge dans le jour ciblé
window.asAddEvent = function (date) { window.asAddEvent = function (date) {
var wrap = document.getElementById('as-events-' + date); var grid = document.getElementById('as-events-' + date);
if (!wrap) return; if (!grid) return;
var ev = document.createElement('div'); var ev = document.createElement('div');
ev.className = 'as-event'; ev.className = 'as-event';
ev.innerHTML = ev.innerHTML =
'<input class="time-input" type="time" name="astreinte_debut" data-date="' + date + '" value="">' + '<input class="time-input" type="time" name="astreinte_debut" data-date="' + date + '" value="">' +
'<span class="time-sep">→</span>' +
'<input class="time-input" type="time" name="astreinte_fin" data-date="' + date + '" value="">' + '<input class="time-input" type="time" name="astreinte_fin" data-date="' + date + '" value="">' +
'<input type="text" class="as-desc" name="astreinte_desc" data-date="' + date + '" maxlength="200" placeholder="description" value="">' + '<div class="as-desc-cell">' +
'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>'; '<input type="text" class="as-desc" name="astreinte_desc" data-date="' + date + '" maxlength="200" placeholder="description" value="">' +
wrap.appendChild(ev); '<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>' +
'</div>';
grid.appendChild(ev);
}; };
</script> </script>
{% endblock %} {% endblock %}