La saisie astreinte devient une liste d'événements (début, fin, description,
nombre illimité, fin antérieure au début = intervention à cheval sur minuit).
Ces heures ne comptent jamais dans la semaine : elles sont décomptées à part
par pas de 15 minutes, toute tranche entamée comptant en totalité, et rangées
dans des compteurs selon le jour et l'heure (Nuit ×1,5, Journée semaine ×1,
Samedi ×1,5, Dimanche/férié ×2 ; jours fériés français calculés
automatiquement, règles ajustables dans config.json via astreinte_facteurs).
Le détail par événement et les totaux pondérés s'affichent sous le bandeau de
la semaine, et les exports .ods gagnent les colonnes Astreinte brute et
pondérée. Le retour au bureau (fin du dernier événement + 11h de repos) est
désormais dérivé des événements.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
387 lines
13 KiB
HTML
387 lines
13 KiB
HTML
{% extends "base.html" %}
|
||
{% block content %}
|
||
|
||
<div class="week-header">
|
||
<div class="week-nav-arrows">
|
||
<a href="/semaine/{{ prev_year }}/{{ prev_week }}" class="arrow-btn">←</a>
|
||
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">→</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()">←</button>
|
||
<span class="cal-month-label" id="cal-month-label"></span>
|
||
<button class="cal-nav" onclick="calNext()">→</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-save" rowspan="2"></th>
|
||
<th class="g-conge" rowspan="2">Congé</th>
|
||
</tr>
|
||
<tr class="th-sub">
|
||
<th class="s-matin">entrée → sortie</th>
|
||
<th class="s-aprem">entrée → 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) du jour courant
|
||
(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, 60000);
|
||
})();
|
||
</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>
|
||
// Astreinte — ajoute une ligne d'intervention vierge dans le jour ciblé
|
||
window.asAddEvent = function (date) {
|
||
var wrap = document.getElementById('as-events-' + date);
|
||
if (!wrap) 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="">' +
|
||
'<span class="time-sep">→</span>' +
|
||
'<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="">' +
|
||
'<button type="button" class="as-del" title="Supprimer" onclick="this.closest(\'.as-event\').remove()">×</button>';
|
||
wrap.appendChild(ev);
|
||
};
|
||
</script>
|
||
{% endblock %}
|