Files
Pivot/templates/laatkomers.html
2026-10-14 13:34:37 +02:00

232 lines
13 KiB
HTML
Raw Permalink 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 title %}Laatkomers{% endblock %}
{% block page_title %}Laatkomers{% endblock %}
{% block extra_css %}
<style>
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat { padding: 18px 20px; }
.stat + .stat { margin-top: 0; }
.stat .val { font-size: 28px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .lbl { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; margin-top: 4px; }
.stat .sub { font-size: 13px; color: var(--text-muted); }
.sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.progress { flex: 1 1 200px; height: 8px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
.periode { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.periode .field { margin: 0; }
.zoekbalk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.zoekbalk input[type=search] { flex: 1 1 260px; }
th.sorteer { cursor: pointer; user-select: none; white-space: nowrap; }
th.sorteer:hover { color: var(--primary-color); }
th.sorteer .pijl::after { content: "↕"; opacity: .35; margin-left: 2px; }
th.sorteer.op .pijl::after { content: "▲"; opacity: 1; }
th.sorteer.af .pijl::after { content: "▼"; opacity: 1; }
.count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700;
background: var(--warning-bg); color: var(--warning-text); font-variant-numeric: tabular-nums; }
.count-badge.veel { background: var(--danger-bg); color: var(--danger-text); }
details.data summary { cursor: pointer; color: var(--primary-color); font-size: .9em; }
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; max-width: 520px; }
.day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 3px 9px; border-radius: 999px; background: var(--surface);
border: 1px solid var(--border); font-size: 12.5px; white-space: nowrap; }
.day-chip .dd { font-weight: 700; color: var(--warning-text); }
.tabel-voet { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
td.nowrap { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
{# ---------- Kerncijfers ---------- #}
<div class="stats">
<div class="card stat">
<div class="val">{{ rows|length }}</div>
<div class="lbl">Leerlingen te laat</div>
</div>
<div class="card stat">
<div class="val">{{ totaal }}</div>
<div class="lbl">Keer te laat</div>
{% if rows %}<div class="sub">gem. {{ '%.1f'|format(totaal / rows|length) }} per leerling</div>{% endif %}
</div>
<div class="card stat">
<div class="val" style="font-size:18px; padding-top:6px">
{% if laatste.m %}{{ laatste.m.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}
</div>
<div class="lbl">Laatst gesynchroniseerd</div>
<div class="sub">Automatisch elke ochtend</div>
</div>
</div>
{% if fout %}<div class="flash-message error"><span>{{ fout }}</span></div>{% endif %}
{% if not codes %}
<div class="alert alert-warning">Er zijn geen codes voor "te laat" ingesteld. Vul ze in bij 🔌 Koppelingen › Smartschool › <em>Codes voor te laat</em>.</div>
{% endif %}
{# ---------- Synchronisatie en periode ---------- #}
<div class="card">
<div class="sync-bar">
<form method="POST" action="{{ url_for('afwezigheden.sync', van=f.van.isoformat(), tot=f.tot.isoformat()) }}" style="margin:0">
<input type="hidden" name="terug" value="laatkomers">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig %}disabled{% endif %}>🔄 Nu synchroniseren</button>
</form>
<form method="POST" action="{{ url_for('afwezigheden.sync', van=f.van.isoformat(), tot=f.tot.isoformat()) }}" style="margin:0"
onsubmit="return confirm('Alle schooldagen van dit schooljaar opnieuw ophalen uit Smartschool? Dit duurt enkele minuten.')">
<input type="hidden" name="terug" value="laatkomers">
<input type="hidden" name="volledig" value="1">
<button type="submit" class="btn-light" {% if status.bezig %}disabled{% endif %}
title="Nodig de eerste keer: vroeger opgehaalde dagen bevatten de codes voor te laat nog niet">⏮ Heel schooljaar opnieuw ophalen</button>
</form>
<div class="progress {% if not status.bezig %}hidden{% endif %}" id="syncProgress"><span id="syncProgressBar"></span></div>
<span class="muted small" id="syncText">
{% if status.bezig %}Bezig…{% elif status.laatste_resultaat %}{{ status.laatste_resultaat }}{% endif %}
</span>
</div>
<form method="GET" action="{{ url_for('afwezigheden.laatkomers') }}" class="periode" style="margin-top:14px">
<div class="field">
<label for="van">Van</label>
<input type="date" id="van" name="van" value="{{ f.van.isoformat() }}">
</div>
<div class="field">
<label for="tot">Tot en met</label>
<input type="date" id="tot" name="tot" value="{{ f.tot.isoformat() }}">
</div>
<button type="submit">Toon periode</button>
</form>
</div>
{# ---------- Tabel ---------- #}
<div class="card">
<div class="zoekbalk">
<input type="search" id="zoek" placeholder="Zoek een leerling op naam, gebruikersnaam of internnummer..." aria-label="Zoek een leerling">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Leerling <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Internnummer <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">Aantal keer te laat <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">Laatst te laat <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">Eerst te laat <span class="pijl"></span></th>
<th>Data</th>
</tr>
</thead>
<tbody id="laatBody">
{% for r in rows %}
<tr data-zoek="{{ [r.Voornaam, r.Naam, r.Gebruikersnaam, r.Internnummer]|select|join(' ')|lower }}">
<td data-sort="{{ (r.Naam or '') ~ ' ' ~ (r.Voornaam or '') }}">
{% if r.Naam or r.Voornaam %}<strong>{{ r.Naam }}</strong> {{ r.Voornaam }}{% else %}<span class="muted">Onbekende leerling</span>{% endif %}
</td>
<td class="mono" data-sort="{{ r.Internnummer }}">{{ r.Internnummer }}</td>
<td data-sort="{{ '%05d'|format(r.Aantal) }}"><span class="count-badge {{ 'veel' if r.Aantal >= 5 }}">{{ r.Aantal }}</span></td>
<td class="nowrap" data-sort="{{ r.Laatste.isoformat() }}">{{ r.Laatste.strftime('%d-%m-%Y') }}</td>
<td class="nowrap" data-sort="{{ r.Eerste.isoformat() }}">{{ r.Eerste.strftime('%d-%m-%Y') }}</td>
<td>
<details class="data"><summary>Toon {{ r.Data|length }}</summary>
<div class="day-chips">
{% for d, dd in r.Data %}<span class="day-chip">{{ ['ma','di','wo','do','vr','za','zo'][d.weekday()] }} {{ d.strftime('%d-%m') }} <span class="dd">{{ dd }}</span></span>{% endfor %}
</div>
</details>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Geen laatkomers in deze periode{% if not laatste.m %} (er is nog niets opgehaald uit Smartschool){% endif %}.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="teller"></div>
<span id="perPagina"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Zoeken, sorteren (klik op een kolomkop) en pagineren in de browser
(function () {
const body = document.getElementById('laatBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('laatkomers');
let pagina = 1, kol = 2, op = false, zoek = '';
document.querySelector('th.sorteer[data-kol="2"]').classList.add('af'); // standaard: meest te laat bovenaan
const zichtbaar = () => Array.from(body.rows).filter(tr => !zoek || tr.dataset.zoek.includes(zoek));
function toon() {
const rijen = zichtbaar();
const grootte = PER_PAGINA || Infinity;
const paginas = Math.max(1, Math.ceil(rijen.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte;
Array.from(body.rows).forEach(tr => tr.style.display = 'none');
rijen.forEach((tr, i) => tr.style.display = (i >= van && i < tot) ? '' : 'none');
document.getElementById('teller').textContent = rijen.length
? `${van + 1}–${Math.min(tot, rijen.length)} van ${rijen.length} leerling(en)` : 'Geen leerling gevonden';
const nav = document.getElementById('paginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); body.closest('.table-wrap').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); knop('‹ Vorige', pagina - 1); }
for (let p = Math.max(1, pagina - 2); p <= Math.min(paginas, pagina + 2); p++) knop(String(p), p, p === pagina);
if (pagina < paginas) { knop('Volgende ›', pagina + 1); knop('»', paginas, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
// aantal en datums: eerst hoog/nieuw → laag/oud; namen: eerst A → Z
op = kol === k ? !op : (k < 2);
kol = k;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
Array.from(body.rows).sort((a, b) => {
const x = a.cells[k].dataset.sort || '', y = b.cells[k].dataset.sort || '';
const c = x.localeCompare(y, 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(document.getElementById('perPagina'), 'laatkomers', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
// Voortgang van een lopende synchronisatie volgen
(function pollSync() {
const bar = document.getElementById('syncProgressBar');
const box = document.getElementById('syncProgress');
const txt = document.getElementById('syncText');
const btn = document.getElementById('syncBtn');
let wasBusy = {{ 'true' if status.bezig else 'false' }};
function tick() {
fetch("{{ url_for('afwezigheden.status') }}").then(r => r.json()).then(s => {
if (s.bezig) {
wasBusy = true;
box.classList.remove('hidden'); btn.disabled = true;
const pct = s.totaal ? Math.round(s.voortgang / s.totaal * 100) : 0;
bar.style.width = pct + '%';
txt.textContent = `Bezig: dag ${s.voortgang} van ${s.totaal}…`;
setTimeout(tick, 2000);
} else if (wasBusy) {
window.location.reload();
}
}).catch(() => {});
}
if (wasBusy) tick();
})();
</script>
{% endblock %}