Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
{% 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 %}