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

+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}Afwezigheden{% endblock %}
{% block page_title %}Afwezigheden ({{ codes|join('/') }}-codes){% 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); }
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; align-items: end; }
.filter-grid .wide { grid-column: span 2; }
.filter-grid label { display: block; margin-bottom: 6px; }
.filter-actions { display: flex; gap: 8px; }
.filter-actions > * { flex: 1; }
tr.lln { cursor: pointer; }
tr.lln td:first-child::before { content: "▸"; display: inline-block; width: 16px; color: var(--text-muted); transition: transform .15s; }
tr.lln.open td:first-child::before { transform: rotate(90deg); }
tr.detail td { background: var(--surface-muted); padding: 10px 14px 14px 44px; }
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; border-radius: 999px; background: #fff;
border: 1px solid var(--border); font-size: 13px; }
.day-chip .dd { font-weight: 700; color: var(--danger-text); }
.count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700;
background: var(--danger-bg); color: var(--danger-text); }
.count-badge.lvl1 { background: var(--warning-bg); color: var(--warning-text); }
.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; }
</style>
{% endblock %}
{% block content %}
{# ---------- Kerncijfers ---------- #}
<div class="stats">
<div class="card stat">
<div class="val">{{ rows|length }}</div>
<div class="lbl">Leerlingen met {{ f.code or codes|join('/') }}</div>
</div>
<div class="card stat">
<div class="val">{{ totaal_halve_dagen }}</div>
<div class="lbl">Halve dagen</div>
{% if rows %}<div class="sub">gem. {{ '%.1f'|format(totaal_halve_dagen / 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 laatste.m and not status.bezig %}
<div class="alert alert-warning">
Er zijn nog geen gegevens opgehaald uit Smartschool. Klik op <strong>Nu synchroniseren</strong> om het huidige schooljaar op te halen
(de eerste keer duurt dit enkele minuten).
</div>
{% endif %}
{# ---------- Synchronisatie ---------- #}
<div class="card" id="syncCard">
<div class="sync-bar">
<form method="POST" action="{{ url_for('afwezigheden.sync') }}" style="margin:0">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig %}disabled{% endif %}>🔄 Nu synchroniseren</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>
</div>
{# ---------- Filters ---------- #}
<div class="card">
<form method="GET" action="{{ url_for('afwezigheden.overzicht') }}">
<div class="filter-grid">
<div class="wide">
<label for="zoek">Leerling</label>
<input type="search" id="zoek" name="zoek" value="{{ f.zoek }}" placeholder="Naam, gebruikersnaam of internnummer">
</div>
<div>
<label for="van">Van</label>
<input type="date" id="van" name="van" value="{{ f.van.isoformat() }}">
</div>
<div>
<label for="tot">Tot en met</label>
<input type="date" id="tot" name="tot" value="{{ f.tot.isoformat() }}">
</div>
<div>
<label for="min">Minimum halve dagen</label>
<input type="number" id="min" name="min" min="1" value="{{ f.minimum }}">
</div>
{% if codes|length > 1 %}
<div>
<label for="code">Code</label>
<select id="code" name="code">
<option value="">Alle ({{ codes|join(', ') }})</option>
{% for c in codes %}<option value="{{ c }}" {% if f.code == c %}selected{% endif %}>{{ c }}</option>{% endfor %}
</select>
</div>
{% endif %}
<div class="filter-actions">
<button type="submit">🔍 Filteren</button>
<a class="btn btn-light" href="{{ url_for('afwezigheden.overzicht') }}">Wissen</a>
</div>
</div>
</form>
</div>
{# ---------- Ranglijst ---------- #}
<div class="card">
<div class="page-head">
<h2>📋 Leerlingen</h2>
<div class="actions">
{% if rows %}<a class="btn btn-light" href="{{ url_for('afwezigheden.export_csv', **request.args) }}">⬇️ Export CSV</a>{% endif %}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Leerling</th>
<th>Internnummer</th>
<th class="center">Halve dagen</th>
<th class="center">Dagen</th>
<th>Eerste</th>
<th>Laatste</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="lln" data-intern="{{ r.Internnummer }}" onclick="toggleDetail(this)">
<td><strong>{% if r.Naam or r.Voornaam %}{{ r.Naam }} {{ r.Voornaam }}{% else %}<span class="muted">Onbekend in PIVOT</span>{% endif %}</strong>
{% if r.Gebruikersnaam %}<span class="muted small"> · {{ r.Gebruikersnaam }}</span>{% endif %}</td>
<td>{{ r.Internnummer }}</td>
<td class="center"><span class="count-badge {{ 'lvl1' if r.Halve_dagen < 5 else '' }}">{{ r.Halve_dagen }}</span></td>
<td class="center">{{ r.Dagen }}</td>
<td class="nowrap">{{ r.Eerste.strftime('%d-%m-%Y') }}</td>
<td class="nowrap">{{ r.Laatste.strftime('%d-%m-%Y') }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">
{% if laatste.m %}Geen leerlingen met {{ f.code or codes|join('/') }}-codes in deze periode.{% else %}Nog geen gegevens.{% endif %}
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint">Klik op een leerling om de datums te zien. Vanaf 5 halve dagen kleurt het aantal rood.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
const filterQuery = {{ request.query_string.decode()|tojson }};
function toggleDetail(tr) {
const next = tr.nextElementSibling;
if (next && next.classList.contains('detail')) { next.remove(); tr.classList.remove('open'); return; }
tr.classList.add('open');
const row = document.createElement('tr');
row.className = 'detail';
row.innerHTML = '<td colspan="6" class="muted">Laden…</td>';
tr.after(row);
fetch(`{{ url_for('afwezigheden.overzicht') }}leerling/${encodeURIComponent(tr.dataset.intern)}.json?${filterQuery}`)
.then(r => r.json())
.then(dagen => {
const chips = dagen.map(d => {
const deel = d.vm && d.nm ? 'hele dag' : (d.vm ? 'VM' : 'NM');
return `<span class="day-chip">${d.weekdag} ${d.datum} <span class="dd">${deel}</span></span>`;
}).join('');
row.firstElementChild.className = '';
row.firstElementChild.innerHTML = `<div class="day-chips">${chips || 'Geen datums.'}</div>`;
})
.catch(() => { row.firstElementChild.textContent = 'Kon de datums niet laden.'; });
}
// 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(); // klaar → nieuwe gegevens tonen
}
}).catch(() => {});
}
if (wasBusy) tick();
})();
</script>
{% endblock %}