La colonne "Cible" (anciennement "Fin cible") montre désormais une plage
complète entrée → sortie pour chaque demi-journée à rattraper, au lieu
d'une heure de départ seule. Moins ambigu pour l'utilisateur qui sait
aussi quand arriver.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
351 lines
12 KiB
HTML
351 lines
12 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>
|
||
<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();
|
||
};
|
||
|
||
// 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>
|
||
{% endblock %}
|