PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
{% extends "base.html" %}
{% block title %}Co-accounts{% endblock %}
{% block page_title %}Co-accounts{% 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; }
.filterbalk { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
.filterbalk input[type=search] { flex: 1 1 280px; }
.filterbalk label.check-box { margin: 0; }
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; }
.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 %}
<div class="stats">
<div class="card stat">
<div class="val">{{ nooit }}</div>
<div class="lbl">Nog nooit aangemeld</div>
{% if rows %}<div class="sub">van {{ rows|length }} co-accounts</div>{% endif %}
</div>
<div class="card stat">
<div class="val">{{ leerlingen }}</div>
<div class="lbl">Leerlingen met co-accounts</div>
</div>
<div class="card stat">
<div class="val" style="font-size:18px; padding-top:6px">{% if laatst %}{{ laatst.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}</div>
<div class="lbl">Opgehaald uit Smartschool</div>
<div class="sub">Automatisch één keer per dag</div>
</div>
</div>
{% if fout %}<div class="flash-message error"><span>{{ fout }}</span></div>{% endif %}
{% if not ingesteld %}
<div class="alert alert-warning">Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).</div>
{% endif %}
<div class="card">
<div class="sync-bar">
<form method="POST" action="{{ url_for('smartschool.coaccount_ophalen') }}" style="margin:0">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig or not ingesteld %}disabled{% endif %}>🔄 Nu ophalen uit Smartschool</button>
</form>
{% if rows %}<a class="btn btn-light" id="exportBtn" href="{{ url_for('smartschool.coaccount_export', nooit=1) }}">⬇️ CSV-export</a>{% endif %}
<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 met ophalen…{% elif status.fout %}⚠️ {{ status.fout }}{% elif status.resultaat %}{{ status.resultaat }}{% endif %}
</span>
</div>
</div>
<div class="card">
<div class="filterbalk">
<input type="search" id="zoek" placeholder="Zoek op leerling, klas of co-account..." aria-label="Zoeken">
<label class="check-box"><input type="checkbox" id="enkelNooit" checked style="width:auto"> <span>Enkel nog nooit aangemeld</span></label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Klas <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Leerling <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">Co-account <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">Type <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">E-mail <span class="pijl"></span></th>
<th class="sorteer" data-kol="5">Laatste aanmelding <span class="pijl"></span></th>
<th class="actions">Actie</th>
</tr>
</thead>
<tbody id="coBody">
{% for r in rows %}
<tr data-nooit="{{ '1' if not r.Laatste_login else '0' }}"
data-zoek="{{ [r.Klas, r.Leerling_voornaam, r.Leerling_naam, r.Gebruikersnaam, r.Internnummer, r.Voornaam, r.Naam, r.Email]|select|join(' ')|lower }}">
<td class="nowrap" data-sort="{{ r.Klas or '' }}">{{ r.Klas or '–' }}</td>
<td data-sort="{{ (r.Leerling_naam or '') ~ ' ' ~ (r.Leerling_voornaam or '') }}"><strong>{{ r.Leerling_naam }}</strong> {{ r.Leerling_voornaam }}</td>
<td data-sort="{{ (r.Naam or '') ~ ' ' ~ (r.Voornaam or '') }}">{{ r.Voornaam }} {{ r.Naam }}</td>
<td data-sort="{{ r.Type or '' }}">{{ r.Type or '' }}</td>
<td data-sort="{{ r.Email or '' }}">{{ r.Email or '' }}</td>
<td class="nowrap" data-sort="{{ r.Laatste_login.isoformat() if r.Laatste_login else '' }}">
{% if r.Laatste_login %}{{ r.Laatste_login.strftime('%d-%m-%Y %H:%M') }}{% else %}<span class="badge badge-warning">Nooit</span>{% endif %}
</td>
<td class="actions">
<button type="button" class="btn-email btn-sm"
data-voornaam="{{ r.Voornaam or '' }}" data-naam="{{ r.Naam or '' }}" data-email="{{ r.Email or '' }}"
onclick="openEmailModal(this.dataset.voornaam, this.dataset.naam, this.dataset.email)">✉️ Bericht opstellen</button>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="7">{% if status.bezig %}De co-accounts worden opgehaald uit Smartschool…{% else %}Nog geen co-accounts opgehaald.{% 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 modals %}
<div id="emailModal" class="modal">
<div class="modal-content wide">
<span class="close" onclick="closeModal()">&times;</span>
<h3>E-mailherinnering bewerken</h3>
<p>Ontvanger: <strong id="displayEmail"></strong></p>
<div class="field">
<label for="emailSubject">Onderwerp</label>
<input type="text" id="emailSubject">
</div>
<div class="field">
<label for="emailBody">Bericht</label>
<textarea id="emailBody" style="min-height: 220px;"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="closeModal()">Annuleren</button>
<button type="button" class="btn-email" id="confirmSendBtn" onclick="executeSend()">✉️ Nu verzenden</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentTargetData = {};
function openEmailModal(voornaam, naam, email) {
if (!email || email.trim() === "" || email === "None") {
alert("Dit co-account heeft geen e-mailadres ingesteld in Smartschool.");
return;
}
currentTargetData = { voornaam, naam, email };
document.getElementById('displayEmail').innerText = email;
document.getElementById('emailSubject').value = "Belangrijk: Uw Smartschool co-account";
document.getElementById('emailBody').value = `Beste ${voornaam} ${naam},\n\nWe merkten dat u al enige tijd niet meer bent ingelogd op uw Smartschool co-account. \n\nVia dit account kunt u de resultaten, agenda en afwezigheden van uw kind op de voet volgen. Wij raden u aan om regelmatig in te loggen om op de hoogte te blijven.\n\nIndien u problemen heeft met inloggen, kunt u contact opnemen met de ICT-dienst van de school.\n\nMet vriendelijke groeten,\nHet schoolteam`;
document.getElementById('emailModal').style.display = 'block';
}
function closeModal() { document.getElementById('emailModal').style.display = 'none'; }
window.onclick = function (event) {
if (event.target == document.getElementById('emailModal')) closeModal();
};
function executeSend() {
const subject = document.getElementById('emailSubject').value;
const message = document.getElementById('emailBody').value;
if (!subject || !message) {
alert("Onderwerp en bericht mogen niet leeg zijn.");
return;
}
showLoader();
fetch("{{ url_for('smartschool.send_reminder') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: currentTargetData.email,
voornaam: currentTargetData.voornaam,
naam: currentTargetData.naam,
subject: subject,
message: message
})
})
.then(async response => {
const isJson = response.headers.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
if (!response.ok) throw new Error((data && data.message) || response.statusText);
return data;
})
.then(() => {
hideLoader();
closeModal();
alert("E-mail succesvol verzonden!");
})
.catch(error => {
hideLoader();
console.error("Fetch error:", error);
alert("Er is een fout opgetreden: " + error.message);
});
}
// Zoeken, filter "nooit aangemeld", sorteren en pagineren in de browser
(function () {
const body = document.getElementById('coBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('coaccounts');
let pagina = 1, kol = null, op = true, zoek = '';
const nooit = document.getElementById('enkelNooit');
const zichtbaar = () => Array.from(body.rows).filter(tr =>
(!nooit.checked || tr.dataset.nooit === '1') && (!zoek || tr.dataset.zoek.includes(zoek)));
function toon() {
const exp = document.getElementById('exportBtn');
if (exp) exp.href = "{{ url_for('smartschool.coaccount_export') }}?" + new URLSearchParams({nooit: nooit.checked ? '1' : '0', zoek});
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} co-account(s)` : 'Geen co-accounts 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(); });
nooit.addEventListener('change', () => { pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : (k !== 5); // laatste aanmelding: eerst de recentste
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 || '';
if (!x !== !y) return x ? -1 : 1;
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'), 'coaccounts', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
// Voortgang van een lopende ophaling volgen; daarna de pagina vernieuwen
(function () {
const box = document.getElementById('syncProgress'), bar = document.getElementById('syncProgressBar');
const txt = document.getElementById('syncText'), btn = document.getElementById('syncBtn');
let bezig = {{ 'true' if status.bezig else 'false' }};
function tick() {
fetch("{{ url_for('smartschool.coaccount_status') }}").then(r => r.json()).then(s => {
if (s.bezig) {
box.classList.remove('hidden'); btn.disabled = true;
bar.style.width = (s.totaal ? Math.round(s.voortgang / s.totaal * 100) : 0) + '%';
txt.textContent = s.totaal ? `Bezig: ${s.voortgang} van ${s.totaal}…` : 'Bezig met ophalen…';
setTimeout(tick, 2000);
} else {
window.location.reload();
}
}).catch(() => {});
}
if (bezig) tick();
})();
</script>
{% endblock %}