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

+460
View File
@@ -0,0 +1,460 @@
{% extends "base.html" %}
{% block title %}GO! Pro{% endblock %}
{% block page_title %}GO! Pro{% endblock %}
{% block loader_text %}Personeel ophalen uit Informat...{% endblock %}
{% block extra_css %}
<style>
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; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.filter-bar input[type=search] { flex: 1 1 260px; }
td.mail { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .86em; }
input.adres { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84em; padding: 5px 7px;
min-width: 250px; background: transparent; }
input.adres.aangepast { border-color: var(--primary-color); font-weight: 600; }
/* Kleur per rij volgens Google Workspace */
#personeelBody tr.g-account td { background: #e8f7ee; }
#personeelBody tr.g-alias td { background: #fff1e0; }
#personeelBody tr.g-geen td { background: #fdeaea; }
#personeelBody tr[class*="g-"] input.adres, #personeelBody tr[class*="g-"] select.rol { background: #fff; }
.legende { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; margin: -6px 0 14px; font-size: .88em; color: var(--text-muted); }
.legende button.kleurfilter { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center;
gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid transparent; }
.legende button.kleurfilter:hover { border-color: var(--border); color: var(--text, #1e293b); }
.legende button.kleurfilter:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
.legende button.kleurfilter.actief { border-color: var(--primary-color); color: var(--primary-color); font-weight: 600;
background: var(--surface); }
.legende b { font-weight: 600; font-variant-numeric: tabular-nums; }
.legende b:not(:empty)::before { content: "("; } .legende b:not(:empty)::after { content: ")"; }
.legende i { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.legende i.account { background: #e8f7ee; border: 1px solid #a7dcb9; }
.legende i.alias { background: #fff1e0; border: 1px solid #f3c48d; }
.legende i.geen { background: #fdeaea; border: 1px solid #f0a8a8; }
select.rol { min-width: 150px; max-width: 190px; padding: 5px 6px; }
th.breekt { white-space: normal; min-width: 90px; }
select.rol.anders { border-color: var(--primary-color); font-weight: 600; }
.rol-status { display: inline-block; width: 18px; margin-left: 4px; font-size: .9em; }
td.nowrap { white-space: nowrap; }
button.negeer { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px;
border-radius: 6px; font-size: 13px; opacity: .45; margin-right: 4px; vertical-align: middle; }
button.negeer:hover, button.negeer:focus-visible { opacity: 1; background: #fee2e2; outline: none; }
tr.weg { opacity: .3; transition: opacity .25s; }
button.verplaats { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px;
border-radius: 6px; font-size: 15px; font-weight: 800; color: #16a34a; margin-right: 2px; vertical-align: middle; }
button.verplaats:hover, button.verplaats:focus-visible { background: #dcfce7; outline: none; }
.vp-raster { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.vp-raster .field { margin-bottom: 12px; }
.vp-raster .breed { grid-column: 1 / -1; }
@media (max-width: 560px) { .vp-raster { grid-template-columns: 1fr; } }
#vpFout { display: none; margin: 4px 0 0; }
.nacht { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between;
padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; }
.nacht.aan { border-color: var(--primary-color); background: var(--primary-light); }
.nacht strong { display: block; }
.nacht-acties { display: flex; gap: 12px; align-items: center; }
.schakelaar { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
.schakelaar .spoor { width: 44px; height: 24px; border-radius: 999px; background: #cbd5e1; position: relative; transition: background .15s; flex-shrink: 0; }
.schakelaar .spoor::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.schakelaar.aan .spoor { background: var(--primary-color); }
.schakelaar.aan .spoor::after { transform: translateX(20px); }
.schakelaar button { all: unset; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.schakelaar button:focus-visible .spoor { outline: 2px solid var(--primary-color); outline-offset: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🪪 Personeel {{ school_naam }}</h2>
<div class="actions">
{% if rijen %}
<form method="POST" action="{{ url_for('gopro.verzamel') }}" style="margin:0"
onsubmit="if (!confirm('Alle groene personeelsleden (account bestaat in Google) van deze school naar de verzameltabel zetten? Ze verdwijnen dan uit deze lijst.')) return false; showLoader('Verzamelen...');">
<button type="submit" class="btn-success btn-sm">➕ Groene naar verzameltabel</button>
</form>
<a class="btn btn-light btn-sm" id="exportKnop" href="{{ url_for('gopro.export') }}">⬇️ CSV voor GO! Pro</a>
{% endif %}
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel') }}"><span id="groenTeller">📚 Verzameltabel ({{ verzameld_totaal }})</span></a>
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel', lijst='rood') }}"><span id="roodTeller">🚫 Rode lijst ({{ genegeerd_totaal }})</span></a>
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel', lijst='verzonden') }}" title="Personeelsleden van {{ school_naam }} die al naar GO! Pro verzonden zijn">📨 Verzonden van deze school ({{ verzonden_school }})</a>
<a class="btn btn-secondary btn-sm" href="{{ url_for('gopro.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<div class="nacht {{ 'aan' if nacht.aan }}">
<div>
<strong>🌙 Elke nacht automatisch verzamelen</strong>
<span class="muted small">
{% if nacht.aan %}Staat <b>aan</b>: elke nacht tussen 2 en 6 uur worden van alle scholen de groene personeelsleden
naar de verzameltabel gezet (wie op de rode lijst staat niet){% if nacht.door %} · aangezet door {{ nacht.door }}{% endif %}.
{% else %}Staat uit: enkel met de knop ➕ Groene naar verzameltabel, per school.{% endif %}
{% if nacht.bezig %}<br>⏳ Er loopt nu een verzameling voor alle scholen…
{% elif nacht.resultaat %}<br>Laatste keer ({{ nacht.resultaat_op.strftime('%d-%m-%Y %H:%M') if nacht.resultaat_op else '?' }}): {{ nacht.resultaat }}{% endif %}
</span>
</div>
<div class="nacht-acties">
<form method="POST" action="{{ url_for('gopro.nachtelijk') }}" style="margin:0"
onsubmit="if (!confirm('Nu meteen van alle scholen de groene personeelsleden naar de verzameltabel zetten?')) return false;">
<input type="hidden" name="nu" value="1">
<button type="submit" class="btn-light btn-sm" {{ 'disabled' if nacht.bezig }}>▶ Nu voor alle scholen</button>
</form>
<form method="POST" action="{{ url_for('gopro.nachtelijk') }}" style="margin:0">
<input type="hidden" name="aan" value="{{ '0' if nacht.aan else '1' }}">
<span class="schakelaar {{ 'aan' if nacht.aan }}">
<button type="submit" role="switch" aria-checked="{{ 'true' if nacht.aan else 'false' }}">
<span class="spoor"></span> {{ 'Aan' if nacht.aan else 'Uit' }}
</button>
</span>
</form>
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% else %}
<div class="legende" role="group" aria-label="Legende en filter">
<button type="button" class="kleurfilter actief" data-kleur="" aria-pressed="true">Alle <b data-tel=""></b></button>
<button type="button" class="kleurfilter" data-kleur="account" aria-pressed="false"><i class="account"></i> Account bestaat in Google <b data-tel="account"></b></button>
<button type="button" class="kleurfilter" data-kleur="alias" aria-pressed="false"><i class="alias"></i> Adres is een alias van een ander account <b data-tel="alias"></b></button>
<button type="button" class="kleurfilter" data-kleur="geen" aria-pressed="false"><i class="geen"></i> Geen account gevonden in Google <b data-tel="geen"></b></button>
</div>
{% if not domein %}
<div class="alert alert-warning">Deze school heeft nog geen e-maildomein: vul het in via 🏫 Alle scholen beheren › Bewerken.</div>
{% endif %}
{% if verzameld_hier.groen or verzameld_hier.genegeerd or verzameld_hier.verzonden %}
<div class="alert alert-info">Niet meer getoond:
{% if verzameld_hier.groen %}{{ verzameld_hier.groen }} in de <a href="{{ url_for('gopro.verzameltabel') }}">verzameltabel</a>{% endif %}
{%- if verzameld_hier.genegeerd %}{{ ', ' if verzameld_hier.groen }}{{ verzameld_hier.genegeerd }} op de <a href="{{ url_for('gopro.verzameltabel', lijst='rood') }}">rode lijst</a>{% endif -%}
{%- if verzameld_hier.verzonden %}{{ ', ' if verzameld_hier.groen or verzameld_hier.genegeerd }}{{ verzameld_hier.verzonden }} al <a href="{{ url_for('gopro.verzameltabel', lijst='verzonden') }}">verzonden</a>{% endif -%}
.</div>
{% endif %}
{% if dubbel %}
<div class="alert alert-warning">⚠️ {{ dubbel }} rij(en) met een e-mailadres dat ook bij een ander personeelslid voorkomt (zelfde naam). Die zijn gemarkeerd.</div>
{% endif %}
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek op naam, e-mail, functie of rol..." aria-label="Zoeken">
<span class="muted small">Opgehaald uit Informat op {{ opgehaald }}</span>
</div>
<div class="filter-bar" style="margin-top:-4px">
<span class="muted small" id="googleStatus">Controleren in Google…</span>
<button type="button" class="btn-sm btn-light" id="googleKnop">🔄 Opnieuw controleren</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Voornaam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Achternaam <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">E-mail <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">UPN <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">Functie <span class="pijl"></span></th>
<th class="sorteer" data-kol="5">Rol <span class="pijl"></span></th>
<th class="sorteer breekt" data-kol="6">Startdatum functie <span class="pijl"></span></th>
<th class="sorteer breekt" data-kol="7">Einddatum functie <span class="pijl"></span></th>
<th class="sorteer" data-kol="8">Telefoon <span class="pijl"></span></th>
</tr>
</thead>
<tbody id="personeelBody">
{% for r in rijen %}
<tr>
<td data-sort="{{ r.voornaam }}" class="nowrap"><button type="button" class="verplaats" data-person="{{ r.person_id }}"
data-voornaam="{{ r.voornaam }}" data-achternaam="{{ r.achternaam }}" data-instelling="{{ r.instellingsnummer }}"
data-start="{{ r.start.isoformat() if r.start else '' }}" data-einde="{{ r.einde.isoformat() if r.einde else '' }}"
data-telefoon="{{ r.telefoon }}"
title="{{ r.voornaam }} {{ r.achternaam }} nakijken en naar de verzameltabel"
aria-label="{{ r.voornaam }} {{ r.achternaam }} naar de verzameltabel">+</button><button type="button" class="negeer" data-person="{{ r.person_id }}"
title="{{ r.voornaam }} {{ r.achternaam }} naar de rode lijst (negeren: niet exporteren en niet meer tonen)"
aria-label="{{ r.voornaam }} {{ r.achternaam }} negeren">🚫</button> {{ r.voornaam }}</td>
<td data-sort="{{ r.achternaam }}"><strong>{{ r.achternaam }}</strong></td>
{% for veld in ('email', 'upn') %}
<td class="nowrap" data-sort="{{ r[veld] }}">
<input type="text" class="adres {{ 'aangepast' if r[veld ~ '_aangepast'] }}" value="{{ r[veld] }}"
data-person="{{ r.person_id }}" data-veld="{{ veld }}" data-oud="{{ r[veld] }}"
aria-label="{{ 'E-mail' if veld == 'email' else 'UPN' }} van {{ r.voornaam }} {{ r.achternaam }}"
title="Leeg maken = terug naar het standaardadres"><span class="rol-status" aria-live="polite"></span>
{% if veld == 'email' and r.dubbel %}<span class="badge badge-warning" title="Dit adres komt meer dan één keer voor">dubbel</span>{% endif %}
</td>
{% endfor %}
{% for veld, label in (('functie', 'Functie'), ('rol', 'Rol')) %}
<td data-sort="{{ r[veld] }}" class="nowrap">
<select class="rol {{ 'anders' if r[veld] != 'Leerkracht' }}" aria-label="{{ label }} van {{ r.voornaam }} {{ r.achternaam }}"
data-person="{{ r.person_id }}" data-veld="{{ veld }}">
{% for rol in rollen %}<option {% if rol == r[veld] %}selected{% endif %}>{{ rol }}</option>{% endfor %}
</select><span class="rol-status" aria-live="polite"></span>
</td>
{% endfor %}
<td class="nowrap" data-sort="{{ r.start.isoformat() if r.start else '' }}">{{ r.start.strftime('%d-%m-%Y') if r.start else '' }}</td>
<td class="nowrap" data-sort="{{ r.einde.isoformat() if r.einde else '' }}">{{ r.einde.strftime('%d-%m-%Y') if r.einde else '' }}</td>
<td class="nowrap" data-sort="{{ r.telefoon }}">{{ r.telefoon }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="9">Informat gaf geen actieve personeelsleden terug.</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>
{% endif %}
</div>
<div id="vpVenster" class="modal" role="dialog" aria-modal="true" aria-labelledby="vpTitel">
<div class="modal-content wide">
<span class="close" id="vpSluit" title="Annuleren">&times;</span>
<h3 id="vpTitel">Naar de verzameltabel</h3>
<form id="vpForm" autocomplete="off">
<input type="hidden" name="person_id">
<div class="vp-raster">
<div class="field"><label for="vpVoornaam">Voornaam</label><input id="vpVoornaam" name="voornaam" required maxlength="100"></div>
<div class="field"><label for="vpAchternaam">Achternaam</label><input id="vpAchternaam" name="achternaam" required maxlength="100"></div>
<div class="field breed"><label for="vpEmail">E-mailadres (aanmelden en communicatie)</label><input id="vpEmail" name="email" type="email" maxlength="255"></div>
<div class="field breed"><label for="vpUpn">UPN</label><input id="vpUpn" name="upn" maxlength="255"></div>
<div class="field"><label for="vpFunctie">Functie</label><select id="vpFunctie" name="functie">{% for rol in rollen %}<option>{{ rol }}</option>{% endfor %}</select></div>
<div class="field"><label for="vpRol">Rol</label><select id="vpRol" name="rol">{% for rol in rollen %}<option>{{ rol }}</option>{% endfor %}</select></div>
<div class="field"><label for="vpStart">Startdatum functie</label><input id="vpStart" name="start" type="date" readonly title="In de verzameltabel heeft iedereen het lopende schooljaar"></div>
<div class="field"><label for="vpEinde">Einddatum functie</label><input id="vpEinde" name="einde" type="date" readonly title="In de verzameltabel heeft iedereen het lopende schooljaar"></div>
<div class="field"><label for="vpInstelling">Onderwijsinstelling nr.</label><input id="vpInstelling" name="instellingsnummer" maxlength="20"></div>
<div class="field"><label for="vpTelefoon">Professioneel telefoonnummer</label><input id="vpTelefoon" name="telefoon" maxlength="50"></div>
</div>
<div class="alert alert-danger" id="vpFout"></div>
<div class="modal-footer">
<button type="button" class="btn-light" id="vpAnnuleer">Annuleren</button>
<button type="submit" class="btn-success" id="vpBewaar">💾 Bewaren en naar verzameltabel</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Welke adressen bestaan al in Google? Die rijen worden lichtgroen.
let googleAdressen = null; // {adres: 'primair' | 'alias'} zodra Google geantwoord heeft
let kleurFilter = ''; // '' = alle, of 'account' / 'alias' / 'geen'
let herteken = () => {}; // wordt hieronder de functie die de tabel opnieuw toont
let hertoon = () => {}; // idem, maar op dezelfde pagina blijven
function kleurRij(tr) {
if (!googleAdressen) return;
const soorten = Array.from(tr.querySelectorAll('input.adres')).map(i => googleAdressen[i.value.trim().toLowerCase()]);
const soort = soorten.includes('primair') ? 'account' : soorten.includes('alias') ? 'alias' : 'geen';
tr.classList.remove('g-account', 'g-alias', 'g-geen');
tr.classList.add('g-' + soort);
tr.title = {account: 'Account bestaat in Google', alias: 'Dit adres is een alias van een ander account in Google',
geen: 'Geen account gevonden in Google'}[soort];
}
function controleerGoogle(vernieuw) {
const status = document.getElementById('googleStatus');
if (!status) return;
status.textContent = 'Controleren in Google…';
const adressen = Array.from(document.querySelectorAll('input.adres')).map(i => i.value.trim().toLowerCase()).filter(Boolean);
fetch("{{ url_for('gopro.google_check') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({adressen, vernieuw: !!vernieuw})
}).then(r => r.json()).then(res => {
if (!res.ok) { status.textContent = '⚠️ ' + res.fout; return; }
googleAdressen = Object.assign(googleAdressen || {}, res.adressen);
const rijen = Array.from(document.querySelectorAll('#personeelBody tr')).filter(tr => tr.querySelector('input.adres'));
rijen.forEach(kleurRij);
const tel = k => rijen.filter(tr => tr.classList.contains('g-' + k)).length;
status.textContent = `${tel('account')} met account, ${tel('alias')} via een alias, ${tel('geen')} zonder account in Google.`;
document.querySelectorAll('.legende b[data-tel]').forEach(b => { b.textContent = b.dataset.tel ? tel(b.dataset.tel) : rijen.length; });
herteken();
}).catch(() => { status.textContent = '⚠️ Google kon niet bevraagd worden.'; });
}
// 🚫 naast de naam: personeelslid naar de rode lijst (negeren) en uit de tabel halen
function telLegende() {
const rijen = Array.from(document.querySelectorAll('#personeelBody tr')).filter(tr => tr.querySelector('input.adres'));
const tel = k => rijen.filter(tr => tr.classList.contains('g-' + k)).length;
document.querySelectorAll('.legende b[data-tel]').forEach(b => { b.textContent = b.dataset.tel ? tel(b.dataset.tel) : rijen.length; });
const status = document.getElementById('googleStatus');
if (status && googleAdressen) status.textContent = `${tel('account')} met account, ${tel('alias')} via een alias, ${tel('geen')} zonder account in Google.`;
}
// + naast de naam: venster met alle gegevens; bewaren = met de aanpassingen naar de verzameltabel
(function () {
const venster = document.getElementById('vpVenster'), form = document.getElementById('vpForm');
const fout = document.getElementById('vpFout'), bewaar = document.getElementById('vpBewaar');
let huidigeRij = null;
const sluit = () => { venster.style.display = 'none'; huidigeRij = null; };
document.querySelectorAll('button.verplaats').forEach(knop => knop.addEventListener('click', () => {
const tr = knop.closest('tr'), d = knop.dataset;
const veld = v => tr.querySelector(`[data-veld="${v}"]`);
huidigeRij = tr;
form.person_id.value = d.person;
form.voornaam.value = d.voornaam; form.achternaam.value = d.achternaam;
form.email.value = veld('email').value.trim(); form.upn.value = veld('upn').value.trim();
form.functie.value = veld('functie').value; form.rol.value = veld('rol').value;
form.start.value = {{ periode[0].isoformat()|tojson }}; form.einde.value = {{ periode[1].isoformat()|tojson }};
form.instellingsnummer.value = d.instelling; form.telefoon.value = d.telefoon;
document.getElementById('vpTitel').textContent = `${d.voornaam} ${d.achternaam} naar de verzameltabel`;
fout.style.display = 'none'; bewaar.disabled = false;
venster.style.display = 'block';
form.voornaam.focus();
}));
document.getElementById('vpSluit').addEventListener('click', sluit);
document.getElementById('vpAnnuleer').addEventListener('click', sluit);
venster.addEventListener('click', e => { if (e.target === venster) sluit(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && venster.style.display === 'block') sluit(); });
form.addEventListener('submit', e => {
e.preventDefault();
const gegevens = Object.fromEntries(new FormData(form).entries());
bewaar.disabled = true; fout.style.display = 'none';
fetch("{{ url_for('gopro.verplaats') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(gegevens)
}).then(r => r.json()).then(res => {
if (!res.ok) { fout.textContent = res.fout || 'Mislukt.'; fout.style.display = 'block'; bewaar.disabled = false; return; }
const tr = huidigeRij;
sluit();
if (tr) tr.remove();
const t = document.getElementById('groenTeller'); if (t) t.textContent = `📚 Verzameltabel (${res.totaal})`;
telLegende(); hertoon();
}).catch(() => { fout.textContent = 'Mislukt: geen verbinding met PIVOT.'; fout.style.display = 'block'; bewaar.disabled = false; });
});
})();
document.querySelectorAll('button.negeer').forEach(knop => knop.addEventListener('click', () => {
const tr = knop.closest('tr');
tr.classList.add('weg');
fetch("{{ url_for('gopro.negeer') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: knop.dataset.person})
}).then(r => r.json()).then(res => {
if (!res.ok) { tr.classList.remove('weg'); alert(res.fout || 'Mislukt.'); return; }
tr.remove();
const t = document.getElementById('roodTeller'); if (t) t.textContent = `🚫 Rode lijst (${res.totaal})`;
telLegende(); hertoon();
}).catch(() => { tr.classList.remove('weg'); alert('Mislukt: geen verbinding met PIVOT.'); });
}));
const gk = document.getElementById('googleKnop');
if (gk) gk.addEventListener('click', () => controleerGoogle(true));
// E-mail of UPN wijzigen: bewaren bij het verlaten van het veld (of Enter)
document.querySelectorAll('input.adres').forEach(inp => {
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); inp.blur(); } });
inp.addEventListener('change', () => {
const status = inp.nextElementSibling;
status.textContent = '…';
fetch("{{ url_for('gopro.keuze') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: inp.dataset.person, veld: inp.dataset.veld, waarde: inp.value})
}).then(r => r.json()).then(res => {
if (!res.ok) {
status.textContent = '⚠️'; status.title = res.fout || 'Niet opgeslagen';
alert(res.fout || 'Niet opgeslagen'); inp.value = inp.dataset.oud; return;
}
const leeg = !inp.value.trim();
if (res.waarde !== undefined && res.waarde !== null) inp.value = res.waarde;
inp.dataset.oud = inp.value;
inp.classList.toggle('aangepast', !leeg);
inp.closest('td').dataset.sort = inp.value;
status.textContent = '✓'; status.title = 'Opgeslagen';
setTimeout(() => { status.textContent = ''; }, 2000);
if (googleAdressen) controleerGoogle(false); // ook het nieuwe adres laten nakijken
}).catch(() => { status.textContent = '⚠️'; status.title = 'Niet opgeslagen'; inp.value = inp.dataset.oud; });
});
});
controleerGoogle(false);
// Functie of rol wijzigen: meteen bewaren
document.querySelectorAll('select.rol').forEach(sel => sel.addEventListener('change', () => {
const status = sel.nextElementSibling;
status.textContent = '…';
fetch("{{ url_for('gopro.keuze') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: sel.dataset.person, veld: sel.dataset.veld, waarde: sel.value})
}).then(r => r.json()).then(res => {
status.textContent = res.ok ? '✓' : '⚠️';
status.title = res.ok ? 'Opgeslagen' : (res.fout || 'Niet opgeslagen');
sel.classList.toggle('anders', sel.value !== 'Leerkracht');
sel.closest('td').dataset.sort = sel.value;
if (res.ok) setTimeout(() => { status.textContent = ''; }, 2000);
}).catch(() => { status.textContent = '⚠️'; status.title = 'Niet opgeslagen'; });
}));
// Zoeken, sorteren en pagineren in de browser
(function () {
const body = document.getElementById('personeelBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('gopro');
let pagina = 1, kol = null, op = true, zoek = '';
const tekst = tr => Array.from(tr.cells).map(td => td.dataset.sort || td.textContent).join(' ').toLowerCase();
function zichtbaar() {
return Array.from(body.rows).filter(tr => (!zoek || tekst(tr).includes(zoek))
&& (!kleurFilter || tr.classList.contains('g-' + kleurFilter)));
}
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} rij(en)` : 'Geen resultaten';
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;
op = kol === k ? !op : true; 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'), 'gopro', n => { PER_PAGINA = n; pagina = 1; toon(); });
herteken = () => { pagina = 1; toon(); };
hertoon = () => toon();
document.querySelectorAll('button.kleurfilter').forEach(b => b.addEventListener('click', () => {
kleurFilter = (b.dataset.kleur && kleurFilter !== b.dataset.kleur) ? b.dataset.kleur : '';
const exp = document.getElementById('exportKnop'); // export volgt de kleurkeuze
if (exp) exp.href = "{{ url_for('gopro.export') }}" + (kleurFilter ? '?kleur=' + kleurFilter : '');
document.querySelectorAll('button.kleurfilter').forEach(x => {
const aan = x.dataset.kleur === kleurFilter;
x.classList.toggle('actief', aan); x.setAttribute('aria-pressed', String(aan));
});
herteken();
}));
toon();
})();
</script>
{% endblock %}