Files
pointeuse-optimisator/app/templates/semaine.html
Jacquin Antoine e797098e79 Cibles : reprise ancrée au présent et rafraîchissement en direct
L'heure de reprise de pause n'est qu'indicative : le jour même, une
fois l'heure désirée passée, la cible d'un après-midi non commencé
ancre la reprise à maintenant et la sortie s'ajuste au reste à faire —
suivre les cibles mène au solde 0 exact, et aucune cible ne recommande
de reprendre dans le passé.

La page semaine rafraîchit cibles, panneau Aujourd'hui et compteurs
toutes les 30 s, et immédiatement au retour sur l'onglet (les
minuteurs des navigateurs sont gelés en arrière-plan, l'affichage ne
peit plus rester périmé).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-11 14:29:50 +02:00

546 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block content %}
<div class="week-header">
<div class="week-nav-arrows">
<a href="/semaine/{{ prev_year }}/{{ prev_week }}" class="arrow-btn">&#8592;</a>
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">&#8594;</a>
</div>
<!-- Sélecteur calendrier -->
<div class="cal-trigger-wrap">
<button class="cal-trigger" id="cal-trigger" aria-expanded="false"
onclick="toggleCal()">
<span class="wk">S</span>{{ week }}<span class="slash">/</span>{{ year }}
<span class="cal-caret">▾</span>
</button>
<div class="cal-popup" id="cal-popup" hidden>
<div class="cal-header">
<button class="cal-nav" onclick="calPrev()">&#8592;</button>
<span class="cal-month-label" id="cal-month-label"></span>
<button class="cal-nav" onclick="calNext()">&#8594;</button>
</div>
<table class="cal-grid">
<thead>
<tr>
<th class="cal-wn">S</th>
<th>L</th><th>M</th><th>M</th><th>J</th><th>V</th>
<th class="cal-we">S</th><th class="cal-we">D</th>
</tr>
</thead>
<tbody id="cal-body"></tbody>
</table>
</div>
</div>
<span class="week-header-spacer"></span>
<a href="/export/semaine/{{ year }}/{{ week }}" class="export-btn" title="Exporter cette semaine (.ods)">
⇩ Exporter
</a>
</div>
<!-- Soldes -->
{% include "_soldes.html" %}
<!-- Tableau -->
<div class="table-section">
<table class="pointage-table">
<thead>
<tr class="th-group">
<th class="g-jour" rowspan="2">Jour</th>
<th class="g-matin">Matin</th>
<th class="g-aprem">Après-midi</th>
<th class="g-calc" rowspan="2">Travaillé</th>
<th class="g-calc" rowspan="2">Δ jour</th>
<th class="g-cumul" rowspan="2">Δ sem.</th>
<th class="g-cible" rowspan="2">Cible</th>
<th class="g-conge" rowspan="2">État</th>
</tr>
<tr class="th-sub">
<th class="s-matin">entrée &rarr; sortie</th>
<th class="s-aprem">entrée &rarr; sortie</th>
</tr>
</thead>
<tbody>
{% for j, nom in result.jours | zip(jours_fr) %}
{% include "_ligne.html" %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Historique groupé par année -->
{% if weeks_by_year %}
<div class="history">
<span class="hist-label">Historique</span>
<div class="hist-body">
{% for yr, wks in weeks_by_year.items() %}
<div class="hist-year-group">
<span class="hist-year">{{ yr }}</span>
<div class="hist-chips">
{% for yw in wks %}
<a href="/semaine/{{ yw[0] }}/{{ yw[1] }}"
class="chip{% if yw[0] == year and yw[1] == week %} here{% endif %}">
S{{ '%02d' % yw[1] }}
</a>
{% endfor %}
<a href="/export/annee/{{ yr }}" class="chip chip-export" title="Exporter l'année {{ yr }} (.ods)">
⇩ {{ yr }}
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<script>
(function () {
const CURRENT_YEAR = {{ year }};
const CURRENT_WEEK = {{ week }};
// ISO week number from a Date
function isoWeek(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
// Thursday of this week determines the ISO year
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return {
week: Math.ceil((((date - yearStart) / 86400000) + 1) / 7),
year: date.getUTCFullYear()
};
}
// Monday of an ISO week
function mondayOfISOWeek(year, week) {
const simple = new Date(Date.UTC(year, 0, 1 + (week - 1) * 7));
const dow = simple.getUTCDay() || 7; // Mon=1..Sun=7
if (dow <= 4) simple.setUTCDate(simple.getUTCDate() - dow + 1);
else simple.setUTCDate(simple.getUTCDate() + 8 - dow);
return simple;
}
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin',
'Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
// Start calendar on the month containing the current week's Monday
const initMonday = mondayOfISOWeek(CURRENT_YEAR, CURRENT_WEEK);
let calYear = initMonday.getUTCFullYear();
let calMonth = initMonday.getUTCMonth(); // 0-based
function renderCal() {
document.getElementById('cal-month-label').textContent =
MONTHS_FR[calMonth] + ' ' + calYear;
const body = document.getElementById('cal-body');
body.innerHTML = '';
// First day of month (Mon-based grid)
const first = new Date(Date.UTC(calYear, calMonth, 1));
const startDow = (first.getUTCDay() || 7) - 1; // 0=Mon
const startDate = new Date(first);
startDate.setUTCDate(1 - startDow);
// 6 rows × 7 days
for (let row = 0; row < 6; row++) {
const monday = new Date(startDate);
monday.setUTCDate(startDate.getUTCDate() + row * 7);
// Skip row if entirely outside ±1 month
const firstOfRow = monday;
const lastOfRow = new Date(monday); lastOfRow.setUTCDate(monday.getUTCDate() + 6);
if (firstOfRow.getUTCMonth() > calMonth + 1 ||
lastOfRow.getUTCMonth() < calMonth - 1) continue;
const { week: wn, year: wy } = isoWeek(monday);
const isCurrent = (wy === CURRENT_YEAR && wn === CURRENT_WEEK);
const tr = document.createElement('tr');
tr.className = 'cal-row' + (isCurrent ? ' cal-current' : '');
tr.style.cursor = 'pointer';
tr.title = `Semaine ${wn} / ${wy}`;
tr.onclick = () => { window.location = `/semaine/${wy}/${wn}`; };
// Week number cell
const wnTd = document.createElement('td');
wnTd.className = 'cal-wn';
wnTd.textContent = wn;
tr.appendChild(wnTd);
for (let d = 0; d < 7; d++) {
const day = new Date(monday); day.setUTCDate(monday.getUTCDate() + d);
const td = document.createElement('td');
td.textContent = day.getUTCDate();
const inMonth = day.getUTCMonth() === calMonth;
if (!inMonth) td.className = 'cal-out';
if (d >= 5) td.className = (td.className ? td.className + ' ' : '') + 'cal-we';
tr.appendChild(td);
}
body.appendChild(tr);
}
}
window.calPrev = () => {
calMonth--;
if (calMonth < 0) { calMonth = 11; calYear--; }
renderCal();
};
window.calNext = () => {
calMonth++;
if (calMonth > 11) { calMonth = 0; calYear++; }
renderCal();
};
window.toggleCal = () => {
const popup = document.getElementById('cal-popup');
const btn = document.getElementById('cal-trigger');
const open = popup.hidden;
popup.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
if (open) {
renderCal();
// Sous ~700px, le popup est positionné en fixed pour ne jamais déborder
// du viewport (son ancrage absolu normal suit la position du trigger,
// qui n'est pas centré horizontalement sur mobile).
if (window.innerWidth <= 700) {
const rect = btn.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = (rect.bottom + 8) + 'px';
popup.style.left = '.75rem';
popup.style.right = '.75rem';
popup.style.width = 'auto';
} else {
popup.style.position = '';
popup.style.top = '';
popup.style.left = '';
popup.style.right = '';
popup.style.width = '';
}
}
};
// Close on outside click
document.addEventListener('click', e => {
const wrap = document.querySelector('.cal-trigger-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('cal-popup').hidden = true;
document.getElementById('cal-trigger').setAttribute('aria-expanded', 'false');
}
});
})();
</script>
<script>
// Alerte départ — surveille l'heure cible du jour courant
(function () {
var _alerted = false;
var _lastCible = null;
function todayStr() {
var d = new Date();
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function getCible() {
var wrap = document.getElementById('calc-' + todayStr() + '-cible');
if (!wrap) return null;
var nums = wrap.querySelectorAll('.cible-num');
if (!nums.length) return null;
// Plusieurs cibles possibles (matin + après-midi) : on alerte sur la dernière,
// qui correspond au départ effectif de la journée.
var txt = nums[nums.length - 1].textContent.trim();
var m = txt.match(/(\d{2}:\d{2})$/);
return m ? m[1] : null;
}
function nowMin() {
var d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function toMin(t) {
var p = t.split(':');
return parseInt(p[0], 10) * 60 + parseInt(p[1], 10);
}
function getSolde() {
var el = document.querySelector('#soldes-section .solde-item:last-child .solde-num');
return el ? el.textContent.trim() : null;
}
function showAlert(time) {
if (document.getElementById('alerte-depart')) return;
var solde = getSolde();
var soldeHtml = '';
if (solde && solde.charAt(0) === '+' && solde !== '+0h00') {
soldeHtml = '<div class="alerte-supp">' + solde + ' de supp.</div>';
}
var notifBody = 'Heure de départ : ' + time;
if (solde && solde.charAt(0) === '+') notifBody += ' · ' + solde + ' de supp.';
var el = document.createElement('div');
el.id = 'alerte-depart';
el.innerHTML =
'<div class="alerte-inner">' +
'<span class="alerte-icon">⏰</span>' +
'<div class="alerte-body">' +
'<div class="alerte-label">Il faut partir</div>' +
'<div class="alerte-time">' + time + '</div>' +
soldeHtml +
'</div>' +
'<button class="alerte-close" title="Fermer" ' +
'onclick="document.getElementById(\'alerte-depart\').remove();' +
'window._alerteDismissed=true;">✕</button>' +
'</div>';
document.body.appendChild(el);
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('⏰ Il faut partir !', { body: notifBody, tag: 'depart' });
}
}
function check() {
if (window._alerteDismissed) return;
var cible = getCible();
if (!cible) return;
if (cible !== _lastCible) {
_lastCible = cible;
if (toMin(cible) > nowMin()) _alerted = false;
}
if (!_alerted && nowMin() >= toMin(cible)) {
_alerted = true;
showAlert(cible);
}
}
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
check();
setInterval(check, 30000);
})();
</script>
<script>
// Rafraîchit automatiquement les compteurs (travaillé/delta/soldes) et les
// heures cibles du jour courant : celles-ci suivent l'avancement de la journée
// (reprise ancrée à maintenant, créneaux en cours, sorties cibles) et doivent
// donc être recalculées serveur toutes les 30 s — et immédiatement au retour
// sur l'onglet, les navigateurs gelant les minuteurs en arrière-plan.
(function () {
function todayStr() {
var d = new Date();
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function refresh() {
var date = todayStr();
if (!document.getElementById('row-' + date)) return;
htmx.ajax('GET', '/refresh/' + date, { swap: 'none' });
}
setInterval(refresh, 30000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
window.addEventListener('focus', refresh);
})();
</script>
<script>
// Live warn on time inputs that violate minimum directives
document.addEventListener('input', function(e) {
if (!e.target.matches('.time-input')) return;
var date = e.target.dataset.date;
var me = document.querySelector('[name="matin_entree"][data-date="' + date + '"]');
var ms = document.querySelector('[name="matin_sortie"][data-date="' + date + '"]');
var ae = document.querySelector('[name="aprem_entree"][data-date="' + date + '"]');
var as = document.querySelector('[name="aprem_sortie"][data-date="' + date + '"]');
var maDiv = document.getElementById('cg-matin-' + date);
var apDiv = document.getElementById('cg-aprem-' + date);
if (maDiv && !maDiv.classList.contains('cg')) {
var warn = (me && me.value && me.value > '{{ plages.matin_debut }}') || (ms && ms.value && ms.value < '{{ plages.matin_fin }}');
maDiv.classList.toggle('warn', !!warn);
}
if (apDiv && !apDiv.classList.contains('cg')) {
var warnA = (ae && ae.value && ae.value > '{{ plages.aprem_debut }}') || (as && as.value && as.value < '{{ plages.aprem_fin }}');
apDiv.classList.toggle('warn', !!warnA);
}
});
</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;
// Déclenche la sauvegarde du jour depuis le témoin .autosave (attributs hx-*),
// exposé globalement : utilisé par le debounce ci-dessous et par la
// suppression d'une ligne d'intervention, qui doit être enregistrée
// immédiatement — sinon elle réapparaît au rechargement.
window.asSaveDay = function (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 () { asSaveDay(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>
// 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);
// Le settle HTMX (transition après swap) restaure les classes du snapshot pris
// à l'insertion : une classe ajoutée entre insertion et settle — comme « open »
// par asRestore sur afterSwap — est écrasée. On restaure donc aussi après le
// settle, dernier moment du cycle.
document.addEventListener('htmx:afterSettle', asRestore);
// Supprime une ligne d'intervention et enregistre le jour immédiatement :
// sans sauvegarde, la ligne ne disparaîtrait que visuellement et reviendrait
// au rechargement (l'autosave ne se déclenche que sur change d'un champ).
window.asDelEvent = function (btn) {
var ev = btn.closest('.as-event');
if (!ev) return;
var input = ev.querySelector('[data-date]');
var date = input ? input.dataset.date : null;
ev.remove();
if (date) asSaveDay(date);
};
// Ajoute une ligne d'intervention vierge dans le jour ciblé
window.asAddEvent = function (date) {
var grid = document.getElementById('as-events-' + date);
if (!grid) return;
var ev = document.createElement('div');
ev.className = 'as-event';
ev.innerHTML =
'<input class="time-input" type="time" name="astreinte_debut" data-date="' + date + '" value="">' +
'<input class="time-input" type="time" name="astreinte_fin" data-date="' + date + '" value="">' +
'<div class="as-desc-cell">' +
'<input type="text" class="as-desc" name="astreinte_desc" data-date="' + date + '" maxlength="200" placeholder="description" value="">' +
'<button type="button" class="as-del" title="Supprimer" onclick="asDelEvent(this)">×</button>' +
'</div>';
grid.appendChild(ev);
};
</script>
{% endblock %}