209 lines
10 KiB
HTML
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: #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 %}
|