Files
Pivot/templates/afwezigheden.html
marinostrusandClaude Opus 5.5 8b7fa3f716 Donker thema toevoegen via gebruikersmenu rechtsboven
Klik op je naam rechtsboven om een menu te openen met een schakelaar
voor het donkere thema. De keuze wordt per browser onthouden.

- global.css: donkere kleurvariabelen, alleen op scherm (afdrukken blijft licht)
- base.html: gebruikersmenu met themaschakelaar
- vaste witte achtergronden en pastelkleuren op enkele pagina's
  volgen nu het thema

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:08:30 +02:00

209 lines
10 KiB
HTML

{% 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: var(--surface);
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 %}