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

+506
View File
@@ -0,0 +1,506 @@
{% extends "base.html" %}
{% block title %}Handtekeningen{% endblock %}
{% block page_title %}Handtekeningen{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Bezig met Google...{% endblock %}
{% block extra_css %}
<style>
[hidden] { display: none !important; }
.ht-raster { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 18px; align-items: start; }
@media (max-width: 1000px) { .ht-raster { grid-template-columns: 1fr; } }
.werkbalk { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border: 1px solid var(--border);
border-bottom: 0; border-radius: var(--radius) var(--radius) 0 0; background: var(--surface-muted); }
.werkbalk button, .werkbalk select, .werkbalk label.kleur { width: auto; margin: 0; padding: 4px 9px; font-size: 13px; min-width: 30px;
background: var(--surface, #fff); color: inherit; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; }
.werkbalk button:hover { border-color: var(--primary-color); }
.werkbalk button.aan { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
.werkbalk .scheiding { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
.werkbalk label.kleur { display: inline-flex; align-items: center; gap: 4px; }
.werkbalk input[type=color] { width: 22px; height: 18px; padding: 0; border: 0; background: none; }
.werkbalk .rechts { margin-left: auto; display: flex; gap: 4px; }
#editor, #broncode { min-height: 260px; border: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius);
padding: 14px; background: #fff; color: #111; overflow: auto; }
#editor:focus, #broncode:focus { outline: 2px solid var(--primary-light); border-color: var(--primary-color); }
#broncode { width: 100%; font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; line-height: 1.45; resize: vertical; margin: 0; }
.tekens { font-size: 12px; color: var(--text-muted); text-align: right; margin-top: 4px; }
.tekens.te-lang { color: #b91c1c; font-weight: 700; }
.voorbeeld-kader { border: 1px solid var(--border); border-radius: var(--radius); background: #fff; color: #111; }
.voorbeeld-kop { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface-muted); border-radius: var(--radius) var(--radius) 0 0; }
.voorbeeld-kop select { width: auto; max-width: 260px; margin: 0; }
.mail-nep { padding: 16px; font-family: Arial, Helvetica, sans-serif; font-size: 13px; }
.mail-nep .tekst { color: #6b7280; margin-bottom: 14px; }
.mail-nep .streep { color: #9ca3af; }
.velden { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
.velden button { width: auto; margin: 0; padding: 3px 9px; font-size: 12px; border-radius: 999px; font-weight: 600;
background: var(--primary-light); color: var(--primary-color); border: 1px solid transparent; cursor: pointer; }
.velden button:hover { border-color: var(--primary-color); }
.instel { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-top: 18px; }
.instel .field { margin: 0; }
.logo-rij { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.logo-rij img { max-height: 60px; max-width: 180px; border: 1px solid var(--border); border-radius: 6px; padding: 4px; background: #fff; }
.ou-kader { border: 1px solid var(--border); border-radius: var(--radius); max-height: 230px; overflow: auto; padding: 6px 10px; background: var(--surface, #fff); }
.ou-kader label { display: flex; gap: 8px; align-items: center; margin: 0; padding: 3px 0; font-weight: 400; cursor: pointer; }
.ou-kader input { width: auto; margin: 0; }
.ou-kader label.gekozen { font-weight: 600; color: var(--primary-color); }
.gekozen-ous { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.gekozen-ous span { padding: 2px 10px; border-radius: 999px; background: var(--primary-light); color: var(--primary-color); font-size: 12px; font-weight: 600; }
.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: 14px; }
.nacht.aan { border-color: var(--primary-color); background: var(--primary-light); }
.nacht strong { display: block; }
.balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.balk input[type=search] { flex: 0 1 300px; }
.balk .rechts { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
td.inv input { margin: 0; padding: 5px 8px; font-size: 13px; min-width: 150px; }
td.inv input.gewijzigd { border-color: #d97706; background: #fffbeb; }
td.nowrap { white-space: nowrap; }
td.acties { white-space: nowrap; text-align: right; }
td.acties button { margin: 0 0 0 4px; }
.st { display: inline-grid; place-items: center; min-width: 24px; height: 24px; border-radius: 6px; font-weight: 700; padding: 0 6px; font-size: 12px; }
.st.ok { background: #dcfce7; color: #15803d; } .st.fout { background: #fee2e2; color: #b91c1c; } .st.nooit { color: var(--text-muted); }
.voortgang { height: 8px; border-radius: 999px; background: var(--surface-muted); overflow: hidden; margin: 8px 0; }
.voortgang div { height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
.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; }
</style>
{% endblock %}
{% block content %}
{# ===================== Sjabloon ===================== #}
<form class="card" method="POST" action="{{ url_for('handtekeningen.sjabloon') }}" enctype="multipart/form-data" id="sjabloonForm"
onsubmit="return voorBewaren()">
<div class="page-head" style="margin-bottom:6px">
<h2>✍️ Algemene handtekening</h2>
<div class="actions">
<button type="submit" class="btn-success btn-sm">💾 Bewaren</button>
</div>
</div>
<p class="field-hint" style="margin-top:0">Eén handtekening voor iedereen in de gekozen OU's. Klik op een veld hieronder om het in te
voegen; PIVOT vult het per persoon in. Zet een stuk tussen <code>{{ '{#functie}' }}</code> en <code>{{ '{/functie}' }}</code> (of
<code>telefoon</code>, ...) om het weg te laten als dat veld leeg is.</p>
<div class="ht-raster">
<div>
<div class="werkbalk" id="werkbalk">
<button type="button" data-cmd="bold" title="Vet"><b>B</b></button>
<button type="button" data-cmd="italic" title="Cursief"><i>I</i></button>
<button type="button" data-cmd="underline" title="Onderstrepen"><u>U</u></button>
<span class="scheiding"></span>
<select id="lettergrootte" title="Lettergrootte">
<option value="">Grootte</option><option value="1">Klein</option><option value="2">Normaal</option>
<option value="3">Groter</option><option value="4">Groot</option><option value="5">Zeer groot</option>
</select>
<select id="lettertype" title="Lettertype">
<option value="">Lettertype</option><option>Arial</option><option>Verdana</option><option>Tahoma</option>
<option>Georgia</option><option>Trebuchet MS</option><option>Calibri</option>
</select>
<label class="kleur" title="Tekstkleur">A <input type="color" id="tekstkleur" value="#1d6fa5"></label>
<span class="scheiding"></span>
<button type="button" data-cmd="justifyLeft" title="Links uitlijnen">⇤</button>
<button type="button" data-cmd="justifyCenter" title="Centreren">≡</button>
<button type="button" id="linkKnop" title="Link toevoegen">🔗</button>
<button type="button" data-cmd="unlink" title="Link weghalen">⛓️‍💥</button>
<button type="button" data-cmd="insertHorizontalRule" title="Lijn">―</button>
<button type="button" data-cmd="removeFormat" title="Opmaak wissen">⌫</button>
<span class="rechts">
<button type="button" id="standaardKnop" title="Het standaardsjabloon van PIVOT terugzetten">↺ Standaard</button>
<button type="button" id="htmlKnop" title="HTML-broncode bewerken">&lt;/&gt; HTML</button>
</span>
</div>
<div id="editor" contenteditable="true" spellcheck="false"></div>
<textarea id="broncode" name="sjabloon" hidden spellcheck="false">{{ inst.Sjabloon or standaard_sjabloon }}</textarea>
<div class="velden" id="velden">
{% for k, uitleg in velden %}<button type="button" data-veld="{{ k }}" title="{{ uitleg }}">{{ '{' ~ k ~ '}' }}</button>{% endfor %}
</div>
</div>
<div>
<div class="voorbeeld-kader">
<div class="voorbeeld-kop">
<strong class="small">👁 Voorbeeld</strong>
<select id="voorbeeldVoor" aria-label="Voorbeeld voor">
<option value="">Fictieve persoon</option>
{% for u in gebruikers %}<option value="{{ loop.index0 }}">{{ u.voornaam }} {{ u.achternaam }}</option>{% endfor %}
</select>
</div>
<div class="mail-nep">
<div class="tekst">Beste collega,<br><br>Zo ziet je e-mail eruit met de handtekening.<br><br>Met vriendelijke groeten,</div>
<div class="streep">--</div>
<div id="voorbeeld"></div>
</div>
</div>
<div class="tekens" id="tekens"></div>
</div>
</div>
<div class="instel">
<div class="field">
<label>Logo</label>
<div class="logo-rij">
{% if inst.heeft_logo and inst.Logo_sleutel %}
<img src="{{ url_for('handtekening_logo.logo', code=school.code, sleutel=inst.Logo_sleutel, ext='png' if inst.Logo_type == 'image/png' else ('jpg' if inst.Logo_type == 'image/jpeg' else 'gif')) }}" alt="Logo">
<label style="font-weight:400;display:flex;gap:6px;align-items:center;margin:0"><input type="checkbox" name="logo_wissen" value="1" style="width:auto;margin:0"> wissen</label>
{% endif %}
<input type="file" name="logo" accept="image/png,image/jpeg,image/gif" style="flex:1 1 200px">
</div>
<p class="field-hint">PNG, JPG of GIF, max. 512 kB. Of geef hieronder een eigen publieke link (bv. van de schoolwebsite).</p>
<input type="url" name="logo_url" id="logoUrl" value="{{ inst.Logo_url or '' }}" placeholder="https://... (laat leeg om het geüploade logo te gebruiken)">
<div style="display:flex;gap:8px;align-items:center;margin-top:8px">
<label for="logoBreedte" style="margin:0;font-weight:400">Breedte</label>
<input type="number" name="logo_breedte" id="logoBreedte" min="20" max="600" value="{{ inst.Logo_breedte or 120 }}" style="width:90px;margin:0"> <span class="muted small">pixels</span>
</div>
</div>
<div class="field">
<label for="publiekAdres">Publiek adres van PIVOT</label>
<input type="url" name="publiek_adres" id="publiekAdres" value="{{ inst.Publiek_adres or publiek_voorstel }}">
<p class="field-hint">Gmail haalt een geüpload logo op via <code>{{ (inst.Publiek_adres or publiek_voorstel) }}/handtekening-logo/...</code>.
Dat adres moet van buitenaf (internet) bereikbaar zijn; de IP-blokkering geldt niet voor het logo.
Is PIVOT enkel intern bereikbaar, gebruik dan een eigen link naar het logo.</p>
{% if inst.logo_link %}<p class="field-hint">Huidige logolink: <a href="{{ inst.logo_link }}" target="_blank" rel="noopener">{{ inst.logo_link }}</a></p>{% endif %}
<label style="margin-top:12px">Gegevens van de school</label>
<p class="field-hint" style="margin-top:0"><strong>{{ school_gegevens.schoolnaam }}</strong><br>{{ school_gegevens.schooladres or '— geen adres ingevuld —' }}<br>
Aan te passen via 🏫 Alle scholen beheren › Bewerken › Adres.</p>
</div>
<div class="field">
<label>OU's</label>
{% if fout_ou %}<div class="alert alert-danger" style="margin:0 0 8px">{{ fout_ou }}</div>{% endif %}
<input type="search" id="ouZoek" placeholder="Zoek een OU..." style="margin-bottom:6px">
<div class="ou-kader" id="ouKader">
{% for ou in ous %}
<label class="{{ 'gekozen' if ou in inst.ous }}"><input type="checkbox" name="ou" value="{{ ou }}" {{ 'checked' if ou in inst.ous }}> {{ ou }}</label>
{% endfor %}
</div>
<label style="font-weight:400;display:flex;gap:8px;align-items:center;margin-top:8px">
<input type="checkbox" name="met_sub" value="1" {{ 'checked' if inst.Met_sub_ous }} style="width:auto;margin:0"> Ook de onderliggende OU's
</label>
<div class="gekozen-ous" id="gekozenOus"></div>
</div>
</div>
{% if inst.Bijgewerkt %}<p class="muted small" style="margin:14px 0 0">Laatst bewaard op {{ inst.Bijgewerkt.strftime('%d-%m-%Y %H:%M') }}{% if inst.Door %} door {{ inst.Door }}{% endif %}.</p>{% endif %}
</form>
{# ===================== Gebruikers ===================== #}
<div class="card">
<div class="page-head" style="margin-bottom:10px">
<h2>👥 Gebruikers <span class="muted small">({{ gebruikers|length }})</span></h2>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for('handtekeningen.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<div class="nacht {{ 'aan' if inst.Nachtelijk }}">
<div>
<strong>🌙 Elke nacht opnieuw toepassen</strong>
<span class="muted small">{% if inst.Nachtelijk %}Staat <b>aan</b>: elke nacht (tussen 3 en 6 uur) zet PIVOT de handtekening
opnieuw bij wie ze in Gmail zelf gewijzigd heeft, of bij wie de gegevens veranderd zijn.{% else %}Staat uit: de handtekening
wordt enkel gezet met de knoppen hieronder. Gebruikers kunnen ze in Gmail zelf nog aanpassen.{% endif %}
{% if inst.Nacht_resultaat %}<br>Laatste keer: {{ inst.Nacht_resultaat }}{% endif %}</span>
</div>
<form method="POST" action="{{ url_for('handtekeningen.nachtelijk') }}" style="margin:0">
<input type="hidden" name="aan" value="{{ '0' if inst.Nachtelijk else '1' }}">
<label class="switch" title="{{ 'Uitzetten' if inst.Nachtelijk else 'Aanzetten' }}">
<input type="checkbox" {{ 'checked' if inst.Nachtelijk }} onchange="this.form.submit()"><span class="slider"></span>
</label>
</form>
</div>
{% if not inst.ous %}
<p class="muted">Kies hierboven één of meer OU's en klik op 💾 Bewaren.</p>
{% elif fout_gebruikers %}
<div class="alert alert-danger">{{ fout_gebruikers }}</div>
{% elif not gebruikers %}
<p class="muted">Geen (actieve) gebruikers in de gekozen OU's.</p>
{% else %}
<p class="field-hint" style="margin-top:0">Functie en telefoon komen uit Google. Pas je ze hier aan, dan worden ze ook in Google
bewaard (en staan ze in het adresboek van de organisatie).</p>
<div class="balk">
<input type="search" id="zoek" placeholder="Zoek op naam, e-mail, functie of OU..." aria-label="Zoeken">
<span id="perPagina"></span>
<div class="rechts">
<button type="button" class="btn-sm btn-warning" id="alleBewaren" hidden>💾 Wijzigingen in Google bewaren (<span id="aantalGewijzigd">0</span>)</button>
<button type="button" class="btn-sm btn-light" id="geselecteerdToepassen" disabled>▶ Toepassen op geselecteerden (<span id="aantalGekozen">0</span>)</button>
<button type="button" class="btn-sm btn-success" id="iedereenToepassen">▶ Toepassen op iedereen</button>
</div>
</div>
<div id="voortgangKaart" {{ '' if status.bezig or status.resultaat or status.fout else 'hidden' }} style="margin-bottom:12px">
<div class="voortgang"><div id="balk"></div></div>
<div class="small"><b id="tVoortgang">{{ status.voortgang }}</b> / <b id="tTotaal">{{ status.totaal }}</b> ·
✅ <b id="tGelukt">{{ status.gelukt }}</b> · ⏭️ <b id="tOngewijzigd">{{ status.ongewijzigd }}</b> · ⚠️ <b id="tFout">{{ status.fouten }}</b></div>
<div class="alert alert-success" id="resultaat" style="margin:8px 0 0" {{ '' if status.resultaat and not status.bezig else 'hidden' }}>{{ status.resultaat or '' }}</div>
<div class="alert alert-danger" id="foutmelding" style="margin:8px 0 0" {{ '' if status.fout else 'hidden' }}>{{ status.fout or '' }}</div>
<ul class="muted small" id="foutvoorbeelden"></ul>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:28px"><input type="checkbox" id="allesKiezen" title="Alles (op deze pagina) selecteren" style="width:auto;margin:0"></th>
<th>Naam</th><th>E-mail</th><th>OU</th><th>Functie</th><th>Telefoon</th>
<th title="Laatste keer toegepast">Gmail</th><th></th>
</tr>
</thead>
<tbody id="body">
{% for u in gebruikers %}
{% set l = logboek.get(u.email) %}
<tr data-i="{{ loop.index0 }}" data-email="{{ u.email }}">
<td><input type="checkbox" class="kies" style="width:auto;margin:0"></td>
<td class="nowrap"><strong>{{ u.achternaam }}</strong> {{ u.voornaam }}</td>
<td class="small">{{ u.email }}</td>
<td class="small muted">{{ u.ou }}</td>
<td class="inv"><input type="text" class="functie" value="{{ u.functie }}" data-oud="{{ u.functie }}" placeholder="Functie" maxlength="100"></td>
<td class="inv"><input type="tel" class="telefoon" value="{{ u.telefoon }}" data-oud="{{ u.telefoon }}" placeholder="Telefoon" maxlength="40" style="min-width:130px"></td>
<td class="nowrap status">
{% if not l %}<span class="st nooit" title="Nog niet toegepast">—</span>
{% elif l.Ok %}<span class="st ok" title="Toegepast op {{ l.Toegepast.strftime('%d-%m-%Y %H:%M') }}{{ ' door ' ~ l.Door if l.Door }}">✓</span>
{% else %}<span class="st fout" title="{{ l.Melding }}">✕</span>{% endif %}
</td>
<td class="acties">
<button type="button" class="btn-sm btn-warning bewaar" hidden title="Functie en telefoon in Google bewaren">💾</button>
<button type="button" class="btn-sm btn-light toon" title="Voorbeeld van deze persoon">👁</button>
<button type="button" class="btn-sm btn-light pasToe" title="Handtekening nu instellen in Gmail">▶</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div class="muted small" id="teller"></div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
<p class="field-hint">Eenmalig nodig in de Google Admin-console (Beveiliging › API-beheer › Domeinbrede delegatie, bij het serviceaccount
van PIVOT): <code>https://www.googleapis.com/auth/gmail.settings.basic</code> (handtekening zetten) en
<code>https://www.googleapis.com/auth/admin.directory.user</code> (functie en telefoon).</p>
{% endblock %}
{% block scripts %}
<script>
const GEBRUIKERS = {{ gebruikers|tojson }};
const MAX_TEKENS = {{ max_tekens }};
const STANDAARD = {{ standaard_sjabloon|tojson }};
const URL_VOORBEELD = {{ url_for('handtekeningen.voorbeeld')|tojson }};
const URL_GEBRUIKER = {{ url_for('handtekeningen.gebruiker')|tojson }};
const URL_TOEPASSEN = {{ url_for('handtekeningen.toepassen')|tojson }};
const URL_STATUS = {{ url_for('handtekeningen.status')|tojson }};
const FICTIEF = { voornaam: 'An', achternaam: 'Voorbeeld', email: 'an.voorbeeld@school.be', functie: 'Leerkracht wiskunde', telefoon: '03 123 45 67' };
const post = (url, data) => fetch(url, { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(r => r.json());
// ---------------- Editor (opmaak ↔ HTML) ----------------
const editor = document.getElementById('editor'), bron = document.getElementById('broncode');
const htmlKnop = document.getElementById('htmlKnop');
let htmlModus = false, kiesBereik = null;
editor.innerHTML = bron.value;
function sjabloon() { return htmlModus ? bron.value : editor.innerHTML; }
function zetModus(html) {
if (html === htmlModus) return;
if (html) { bron.value = editor.innerHTML; } else { editor.innerHTML = bron.value; }
htmlModus = html;
bron.hidden = !html; editor.hidden = html;
htmlKnop.classList.toggle('aan', html);
document.querySelectorAll('#werkbalk [data-cmd], #lettergrootte, #lettertype, #tekstkleur, #linkKnop')
.forEach(x => x.disabled = html);
(html ? bron : editor).focus();
}
htmlKnop.onclick = () => zetModus(!htmlModus);
editor.addEventListener('keyup', bewaarSelectie); editor.addEventListener('mouseup', bewaarSelectie);
function bewaarSelectie() { const s = window.getSelection(); if (s.rangeCount && editor.contains(s.anchorNode)) kiesBereik = s.getRangeAt(0); }
function herstelSelectie() { editor.focus(); if (kiesBereik) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(kiesBereik); } }
function opdracht(cmd, waarde) { herstelSelectie(); document.execCommand('styleWithCSS', false, true); document.execCommand(cmd, false, waarde ?? null); bewaarSelectie(); vernieuw(); }
document.querySelectorAll('#werkbalk [data-cmd]').forEach(b => b.addEventListener('mousedown', e => { e.preventDefault(); opdracht(b.dataset.cmd); }));
document.getElementById('lettergrootte').onchange = e => { if (e.target.value) opdracht('fontSize', e.target.value); e.target.value = ''; };
document.getElementById('lettertype').onchange = e => { if (e.target.value) opdracht('fontName', e.target.value); e.target.value = ''; };
document.getElementById('tekstkleur').oninput = e => opdracht('foreColor', e.target.value);
document.getElementById('linkKnop').onclick = () => { const url = prompt('Link (https://... of mailto:...)', 'https://'); if (url) opdracht('createLink', url); };
document.getElementById('standaardKnop').onclick = () => {
if (!confirm('Het standaardsjabloon van PIVOT terugzetten? Je huidige handtekening in de editor gaat verloren (pas na 💾 Bewaren definitief).')) return;
if (htmlModus) bron.value = STANDAARD; else editor.innerHTML = STANDAARD;
vernieuw();
};
// Veld invoegen op de cursor
document.querySelectorAll('#velden [data-veld]').forEach(b => b.addEventListener('mousedown', e => {
e.preventDefault();
const tekst = '{' + b.dataset.veld + '}';
if (htmlModus) {
const a = bron.selectionStart, z = bron.selectionEnd;
bron.value = bron.value.slice(0, a) + tekst + bron.value.slice(z);
bron.selectionStart = bron.selectionEnd = a + tekst.length; bron.focus();
} else { opdracht('insertText', tekst); }
vernieuw();
}));
function voorBewaren() {
bron.value = sjabloon();
if (!bron.value.trim()) { alert('De handtekening is leeg.'); return false; }
showLoader('Bewaren...');
return true;
}
// ---------------- Live voorbeeld ----------------
const voorbeeldVoor = document.getElementById('voorbeeldVoor');
let timer = null;
function persoon(i) {
if (i === '' || i == null) return FICTIEF;
const u = Object.assign({}, GEBRUIKERS[+i]);
const tr = document.querySelector(`#body tr[data-i="${i}"]`);
if (tr) { u.functie = tr.querySelector('.functie').value; u.telefoon = tr.querySelector('.telefoon').value; }
return u;
}
function vernieuw() {
clearTimeout(timer);
timer = setTimeout(() => post(URL_VOORBEELD, {
sjabloon: sjabloon(), gebruiker: persoon(voorbeeldVoor.value),
logo_url: document.getElementById('logoUrl').value, logo_breedte: document.getElementById('logoBreedte').value,
publiek_adres: document.getElementById('publiekAdres').value
}).then(r => {
document.getElementById('voorbeeld').innerHTML = r.html || '';
const t = document.getElementById('tekens');
t.textContent = `${r.tekens} van max. ${MAX_TEKENS} tekens (voor deze persoon)`;
t.classList.toggle('te-lang', r.tekens > MAX_TEKENS);
}).catch(() => {}), 250);
}
editor.addEventListener('input', vernieuw); bron.addEventListener('input', vernieuw);
voorbeeldVoor.addEventListener('change', vernieuw);
['logoUrl', 'logoBreedte', 'publiekAdres'].forEach(id => document.getElementById(id).addEventListener('input', vernieuw));
vernieuw();
// ---------------- OU's ----------------
const ouKader = document.getElementById('ouKader');
function toonGekozen() {
const gekozen = Array.from(ouKader.querySelectorAll('input:checked')).map(x => x.value);
ouKader.querySelectorAll('label').forEach(l => l.classList.toggle('gekozen', l.querySelector('input').checked));
document.getElementById('gekozenOus').innerHTML = gekozen.map(o => `<span>${o.replace(/</g, '&lt;')}</span>`).join('')
|| '<span class="muted small" style="background:none;color:inherit;font-weight:400">Nog geen OU gekozen</span>';
}
ouKader.addEventListener('change', toonGekozen);
document.getElementById('ouZoek').addEventListener('input', e => {
const z = e.target.value.trim().toLowerCase();
ouKader.querySelectorAll('label').forEach(l => l.style.display = !z || l.textContent.toLowerCase().includes(z) ? '' : 'none');
});
toonGekozen();
// ---------------- Gebruikers ----------------
(function () {
const body = document.getElementById('body');
if (!body) return;
const rijen = Array.from(body.rows);
let PER_PAGINA = perPagina('handtekeningen', 25), pagina = 1, zoek = '';
const zichtbaar = () => rijen.filter(tr => !zoek || (tr.textContent + ' ' + tr.querySelector('.functie').value).toLowerCase().includes(zoek));
function toon() {
const lijst = zichtbaar(), grootte = PER_PAGINA || Infinity, paginas = Math.max(1, Math.ceil(lijst.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte;
rijen.forEach(tr => tr.style.display = 'none');
lijst.forEach((tr, i) => tr.style.display = i >= van && i < tot ? '' : 'none');
document.getElementById('teller').textContent = lijst.length ? `${van + 1}–${Math.min(tot, lijst.length)} van ${lijst.length}` : 'Niets gevonden';
const nav = document.getElementById('paginering'); nav.innerHTML = '';
if (paginas > 1) {
const knop = (label, p, actief) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label;
b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light'); b.onclick = () => { pagina = p; toon(); }; nav.appendChild(b); };
if (pagina > 1) { knop('«', 1); knop('‹', 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('›', pagina + 1); knop('»', paginas); }
}
document.getElementById('allesKiezen').checked = false;
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
perPaginaKiezer(document.getElementById('perPagina'), 'handtekeningen', n => { PER_PAGINA = n; pagina = 1; toon(); }, 25);
// Selectie
const gekozen = () => rijen.filter(tr => tr.querySelector('.kies').checked);
function telKeuze() {
const n = gekozen().length;
document.getElementById('aantalGekozen').textContent = n;
document.getElementById('geselecteerdToepassen').disabled = !n;
}
body.addEventListener('change', e => { if (e.target.classList.contains('kies')) telKeuze(); });
document.getElementById('allesKiezen').addEventListener('change', e => {
rijen.filter(tr => tr.style.display !== 'none').forEach(tr => tr.querySelector('.kies').checked = e.target.checked);
telKeuze();
});
// Functie / telefoon wijzigen
const gewijzigd = tr => ['functie', 'telefoon'].some(k => { const i = tr.querySelector('.' + k); return i.value.trim() !== i.dataset.oud; });
function telWijzigingen() {
const n = rijen.filter(gewijzigd).length;
document.getElementById('aantalGewijzigd').textContent = n;
document.getElementById('alleBewaren').hidden = !n;
}
body.addEventListener('input', e => {
const tr = e.target.closest('tr');
if (!tr || !e.target.matches('.functie, .telefoon')) return;
e.target.classList.toggle('gewijzigd', e.target.value.trim() !== e.target.dataset.oud);
tr.querySelector('.bewaar').hidden = !gewijzigd(tr);
telWijzigingen();
if (voorbeeldVoor.value === tr.dataset.i) vernieuw();
});
function bewaarRij(tr) {
const f = tr.querySelector('.functie'), t = tr.querySelector('.telefoon'), knop = tr.querySelector('.bewaar');
knop.disabled = true; knop.textContent = '⏳';
return post(URL_GEBRUIKER, { email: tr.dataset.email, functie: f.value.trim(), telefoon: t.value.trim() }).then(r => {
knop.disabled = false; knop.textContent = '💾';
if (!r.ok) { alert(r.fout || 'Bewaren in Google mislukt.'); return false; }
[f, t].forEach(i => { i.value = i.value.trim(); i.dataset.oud = i.value; i.classList.remove('gewijzigd'); });
GEBRUIKERS[+tr.dataset.i].functie = f.value; GEBRUIKERS[+tr.dataset.i].telefoon = t.value;
knop.hidden = true; telWijzigingen();
return true;
});
}
body.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.matches('.functie, .telefoon')) { e.preventDefault(); const tr = e.target.closest('tr'); if (gewijzigd(tr)) bewaarRij(tr); } });
document.getElementById('alleBewaren').onclick = async () => {
for (const tr of rijen.filter(gewijzigd)) { if (!await bewaarRij(tr)) break; }
};
body.addEventListener('click', e => {
const tr = e.target.closest('tr');
if (!tr) return;
if (e.target.closest('.bewaar')) bewaarRij(tr);
if (e.target.closest('.toon')) { voorbeeldVoor.value = tr.dataset.i; vernieuw(); document.getElementById('sjabloonForm').scrollIntoView({ behavior: 'smooth' }); }
if (e.target.closest('.pasToe')) start([tr.dataset.email], tr);
});
// Toepassen
function start(emails, tr) {
const niet = rijen.filter(gewijzigd);
if (niet.length && !confirm(`${niet.length} gebruiker(s) hebben nog niet-bewaarde wijzigingen aan functie/telefoon. Die worden NIET gebruikt. Toch doorgaan?`)) return;
const wie = emails ? (emails.length === 1 ? emails[0] : `${emails.length} gebruikers`) : `alle ${rijen.length} gebruikers`;
if (!confirm(`De handtekening in Gmail instellen voor ${wie}? Een bestaande handtekening wordt vervangen.`)) return;
post(URL_TOEPASSEN, emails ? { emails } : { alle: true }).then(r => {
if (!r.ok) { alert(r.fout || 'Starten mislukt.'); return; }
document.getElementById('voortgangKaart').hidden = false;
kijk();
});
}
document.getElementById('geselecteerdToepassen').onclick = () => start(gekozen().map(tr => tr.dataset.email));
document.getElementById('iedereenToepassen').onclick = () => start(null);
const zet = (id, w) => document.getElementById(id).textContent = w;
function kijk() {
fetch(URL_STATUS, { credentials: 'same-origin' }).then(r => r.json()).then(s => {
zet('tVoortgang', s.voortgang); zet('tTotaal', s.totaal); zet('tGelukt', s.gelukt); zet('tOngewijzigd', s.ongewijzigd); zet('tFout', s.fouten);
document.getElementById('balk').style.width = (s.totaal ? Math.round(100 * s.voortgang / s.totaal) : (s.bezig ? 3 : 100)) + '%';
const res = document.getElementById('resultaat'), fout = document.getElementById('foutmelding');
res.hidden = !(s.resultaat && !s.bezig); res.textContent = s.resultaat || '';
fout.hidden = !s.fout; fout.textContent = s.fout || '';
document.getElementById('foutvoorbeelden').innerHTML = (s.foutvoorbeelden || []).map(f => `<li>${f.replace(/</g, '&lt;')}</li>`).join('');
['iedereenToepassen', 'geselecteerdToepassen'].forEach(id => document.getElementById(id).disabled = s.bezig || (id === 'geselecteerdToepassen' && !gekozen().length));
if (s.bezig) { setTimeout(kijk, 1500); return; }
const log = s.logboek || {};
rijen.forEach(tr => {
const l = log[tr.dataset.email]; if (!l) return;
tr.querySelector('.status').innerHTML = l.ok ? `<span class="st ok" title="Toegepast op ${l.op}">✓</span>`
: `<span class="st fout" title="${l.melding.replace(/"/g, '&quot;')}">✕</span>`;
});
}).catch(() => setTimeout(kijk, 4000));
}
{% if status.bezig %}kijk();{% endif %}
toon(); telKeuze();
})();
</script>
{% endblock %}