PIVOT 1.1.0
This commit is contained in:
commit
8a53234eb6
161 files changed
+31109
No files matched your search
@@ -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 %}
|
||||
Reference in new issue
Block a user