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

+352
View File
@@ -0,0 +1,352 @@
{% extends "base.html" %}
{% block title %}Contacten{% endblock %}
{% block page_title %}Contacten{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Bezig met Google...{% endblock %}
{% block extra_css %}
<style>
[hidden] { display: none !important; }
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tabs a { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface, #fff);
text-decoration: none; color: var(--text, #1e293b); font-weight: 600; font-size: .93em; }
.tabs a.actief { border-color: var(--primary-color); color: var(--primary-color); background: var(--primary-light); }
.balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.balk input[type=search] { flex: 0 1 320px; margin: 0; }
.balk select { width: auto; min-width: 220px; margin: 0; }
.balk .rechts { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
td.nowrap { white-space: nowrap; }
td.acties { white-space: nowrap; text-align: right; }
td.acties form, td.acties button { display: inline; margin: 0 0 0 4px; }
.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; }
.velden2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.velden2 .vol { grid-column: 1 / -1; }
.lijst-kaarten { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.lijst-kaart { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; text-decoration: none; color: inherit;
display: block; background: var(--surface, #fff); }
.lijst-kaart:hover, .lijst-kaart.actief { border-color: var(--primary-color); background: var(--primary-light); }
.lijst-kaart strong { display: block; font-size: 1.02em; }
.lijst-kaart .muted { font-size: .85em; }
.lijst-kaart.nieuw { display: grid; place-items: center; border-style: dashed; color: var(--primary-color); font-weight: 600; min-height: 90px; }
.ou-kader { border: 1px solid var(--border); border-radius: var(--radius); max-height: 200px; overflow: auto; padding: 6px 10px; }
.ou-kader label { display: flex; gap: 8px; align-items: center; margin: 0; padding: 3px 0; font-weight: 400; cursor: pointer; }
.ou-kader input, .klein-vink input { width: auto; margin: 0; }
.klein-vink { display: flex; gap: 8px; align-items: center; font-weight: 400; margin-top: 8px; }
.lijst-raster { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(260px, 1fr); gap: 16px; }
@media (max-width: 900px) { .lijst-raster, .velden2 { grid-template-columns: 1fr; } }
#items td { padding: 4px 6px; }
#items input { margin: 0; padding: 5px 8px; font-size: 13px; min-width: 110px; }
.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; }
.zicht { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: .85em; font-weight: 600; }
.zicht.ja { background: #dcfce7; color: #15803d; } .zicht.nee { background: #fee2e2; color: #b91c1c; }
</style>
{% endblock %}
{% block content %}
<nav class="tabs" aria-label="Soort contacten">
<a href="{{ url_for('contacten.overzicht', tab='lijsten') }}" class="{{ 'actief' if tab == 'lijsten' }}">📇 Contactlijsten in mailboxen</a>
<a href="{{ url_for('contacten.overzicht', tab='adresboek') }}" class="{{ 'actief' if tab == 'adresboek' }}">👥 Adresboek van de organisatie</a>
</nav>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>{% endif %}
{# ======================= 1. Contactlijsten ======================= #}
{% if tab == 'lijsten' %}
<div class="card">
<h2 class="card-title">📇 Contactlijsten in de mailboxen</h2>
<p class="field-hint" style="margin-top:-6px">Een lijst is een label in Google Contacten (bv. "School - Directie") met vaste contacten.
PIVOT zet die contacten in de persoonlijke contacten van iedereen in de gekozen OU's en houdt ze bij: aanpassen of verwijderen
in de lijst doet PIVOT ook in de mailboxen. Andere contacten van de gebruikers blijven onaangeroerd.</p>
<div id="voortgangKaart" {{ '' if status.bezig or status.resultaat or status.fout else 'hidden' }} style="margin-bottom:14px">
<div class="small"><strong id="sLijst">{{ status.lijst or '' }}</strong> · <b id="tVoortgang">{{ status.voortgang }}</b> / <b id="tTotaal">{{ status.totaal }}</b> mailboxen{% if status.fouten %} · ⚠️ {{ status.fouten }}{% endif %}</div>
<div class="voortgang"><div id="balk"></div></div>
<div class="alert alert-success" id="resultaat" style="margin:6px 0 0" {{ '' if status.resultaat and not status.bezig else 'hidden' }}>{{ status.resultaat or '' }}</div>
<div class="alert alert-danger" id="foutmelding" style="margin:6px 0 0" {{ '' if status.fout else 'hidden' }}>{{ status.fout or '' }}</div>
<ul class="muted small" id="foutvoorbeelden">{% for f in status.foutvoorbeelden %}<li>{{ f }}</li>{% endfor %}</ul>
</div>
{% if lijsten is defined %}
<div class="lijst-kaarten">
{% for l in lijsten %}
<a class="lijst-kaart {{ 'actief' if gekozen and gekozen.ID == l.ID }}" href="{{ url_for('contacten.overzicht', tab='lijsten', lijst=l.ID) }}">
<strong>📇 {{ l.Naam }}</strong>
<span class="muted">{{ l.aantal }} contact(en) · {{ l.ous|join(', ') or 'geen OU' }}</span><br>
<span class="muted">{% if l.Laatste_sync %}In de mailboxen sinds {{ l.Laatste_sync.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog niet naar de mailboxen gestuurd{% endif %}</span>
</a>
{% endfor %}
<a class="lijst-kaart nieuw" href="{{ url_for('contacten.overzicht', tab='lijsten', nieuw=1) }}">➕ Nieuwe lijst</a>
</div>
{% endif %}
</div>
{% if gekozen or nieuw %}
{% set l = gekozen or {} %}
<div class="card" id="lijstKaart">
<div class="page-head" style="margin-bottom:8px">
<h2>{{ '✏️ ' ~ l.Naam if gekozen else '➕ Nieuwe contactlijst' }}</h2>
<div class="actions">
{% if gekozen %}
<button type="button" class="btn-danger btn-sm" id="lijstWeg">🗑️ Lijst verwijderen</button>
<button type="button" class="btn-light btn-sm" id="lijstSync" title="Nu (opnieuw) in alle mailboxen zetten">▶ Naar de mailboxen</button>
{% endif %}
<button type="button" class="btn-success btn-sm" id="lijstBewaar">💾 Bewaren</button>
</div>
</div>
{% if gekozen and gekozen.Resultaat %}<p class="muted small" style="margin-top:0">Laatste keer: {{ gekozen.Resultaat }}</p>{% endif %}
<div class="lijst-raster">
<div>
<div class="field"><label for="lNaam">Naam van de lijst (label in Google Contacten)</label>
<input type="text" id="lNaam" value="{{ l.Naam or '' }}" maxlength="100" placeholder="bv. School - Directie en secretariaat"></div>
{% if gekozen and gekozen.Laatste_sync %}<p class="field-hint">Wijzig je de naam of de OU's, dan past PIVOT dat na het bewaren ook in de mailboxen aan.</p>{% endif %}
</div>
<div class="field">
<label>Voor wie (OU's)</label>
<div class="ou-kader" id="ouKader">
{% for ou in ous %}<label><input type="checkbox" value="{{ ou }}" {{ 'checked' if ou in (l.ous or []) }}> {{ ou }}</label>{% endfor %}
</div>
<label class="klein-vink"><input type="checkbox" id="lSub" {{ 'checked' if (l.Met_sub_ous if gekozen else 1) }}> Ook de onderliggende OU's</label>
</div>
</div>
<h3 style="margin:18px 0 8px">Contacten in deze lijst</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Voornaam</th><th>Achternaam</th><th>Organisatie</th><th>Functie</th><th>E-mail</th><th>Telefoon</th><th>Notitie</th><th></th></tr></thead>
<tbody id="items"></tbody>
</table>
</div>
<div class="balk">
<button type="button" class="btn-light btn-sm" id="rijErbij">➕ Contact toevoegen</button>
<span class="muted small">Tip: plak meerdere regels uit Excel (voornaam, achternaam, organisatie, functie, e-mail, telefoon) in het eerste veld.</span>
</div>
<p class="field-hint">Vereist in de domeinbrede delegatie: <code>https://www.googleapis.com/auth/contacts</code>, en de
<strong>People API</strong> moet aanstaan in het Google Cloud-project van het serviceaccount.</p>
</div>
{% endif %}
{% endif %}
{# ======================= 2. Adresboek ======================= #}
{% if tab == 'adresboek' %}
<div class="card">
<h2 class="card-title">👥 Adresboek van de organisatie</h2>
<p class="field-hint" style="margin-top:-6px">Wie verborgen is, verschijnt niet meer in het adresboek van de school en niet bij het
aanvullen van e-mailadressen in Gmail (bv. leerlingen verbergen voor elkaar). De mailbox zelf blijft gewoon werken.</p>
<form class="balk" method="GET" action="{{ url_for('contacten.overzicht') }}" onchange="showLoader(); this.submit()">
<input type="hidden" name="tab" value="adresboek">
<select name="ou" aria-label="OU">
<option value="">— kies een OU —</option>
{% for o in ous or [] %}<option value="{{ o }}" {{ 'selected' if o == ou }}>{{ o }}</option>{% endfor %}
</select>
<input type="hidden" name="sub" value="0">
<label class="klein-vink" style="margin:0"><input type="checkbox" name="sub" value="1" {{ 'checked' if met_sub }}> met onderliggende OU's</label>
</form>
{% if ou and gebruikers is defined %}
{% if gebruikers %}
{% set verborgen = gebruikers|rejectattr('zichtbaar')|list|length %}
<div class="balk">
<input type="search" class="zoek" data-doel="adresBody" placeholder="Zoek op naam of e-mail..." aria-label="Zoeken">
<span class="muted small">{{ gebruikers|length }} gebruiker(s) · <b id="aantalVerborgen">{{ verborgen }}</b> verborgen</span>
<div class="rechts">
<button type="button" class="btn-sm btn-light" data-zet="0" data-wie="gekozen" disabled>🙈 Geselecteerden verbergen</button>
<button type="button" class="btn-sm btn-light" data-zet="1" data-wie="gekozen" disabled>👁 Geselecteerden tonen</button>
<button type="button" class="btn-sm btn-danger" data-zet="0" data-wie="alle">🙈 Iedereen verbergen</button>
<button type="button" class="btn-sm btn-success" data-zet="1" data-wie="alle">👁 Iedereen tonen</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th style="width:28px"><input type="checkbox" id="allesKiezen" style="width:auto;margin:0"></th>
<th>Naam</th><th>E-mail</th><th>OU</th><th>In het adresboek</th><th></th></tr></thead>
<tbody id="adresBody">
{% for u in gebruikers %}
<tr data-email="{{ u.email }}">
<td><input type="checkbox" class="kies" style="width:auto;margin:0"></td>
<td><strong>{{ u.naam }}</strong>{% if u.geschorst %} <span class="badge badge-warning">geschorst</span>{% endif %}</td>
<td class="small">{{ u.email }}</td>
<td class="small muted">{{ u.ou }}</td>
<td class="zichtcel">{% if u.zichtbaar %}<span class="zicht ja">zichtbaar</span>{% else %}<span class="zicht nee">verborgen</span>{% endif %}</td>
<td class="acties"><button type="button" class="btn-sm btn-light wissel">{{ '🙈 Verbergen' if u.zichtbaar else '👁 Tonen' }}</button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="muted">Geen gebruikers in deze OU.</p>{% endif %}
{% elif not fout %}<p class="muted">Kies een OU.</p>{% endif %}
<p class="field-hint" style="margin-top:14px">Of het adresboek voor iedereen zichtbaar is, stel je in de Admin-console in
(Directory › Instellingen voor directory › Zichtbaarheid); dat kan niet via de API.</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const post = (url, data) => fetch(url, { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data || {}) }).then(r => r.json());
window.addEventListener('click', e => { if (e.target.classList && e.target.classList.contains('modal')) e.target.style.display = 'none'; });
// Zoeken + paginering (generiek voor de tabellen met een zoekveld)
document.querySelectorAll('input.zoek').forEach(veld => {
const body = document.getElementById(veld.dataset.doel);
if (!body) return;
const kaart = body.closest('.card'), voet = document.createElement('div');
voet.className = 'tabel-voet';
voet.innerHTML = '<div class="muted small teller"></div><nav class="paginering"></nav>';
body.closest('.table-wrap').after(voet);
const kiezer = document.createElement('span');
veld.after(kiezer);
const rijen = Array.from(body.rows);
let PER = perPagina('contacten-' + veld.dataset.doel, 25), pagina = 1, z = '';
function toon() {
const lijst = rijen.filter(tr => !z || tr.textContent.toLowerCase().includes(z));
const grootte = PER || Infinity, paginas = Math.max(1, Math.ceil(lijst.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER ? (pagina - 1) * PER : 0, tot = van + grootte;
rijen.forEach(tr => tr.style.display = 'none');
lijst.forEach((tr, i) => tr.style.display = i >= van && i < tot ? '' : 'none');
voet.querySelector('.teller').textContent = lijst.length ? `${van + 1}–${Math.min(tot, lijst.length)} van ${lijst.length}` : 'Niets gevonden';
const nav = voet.querySelector('nav'); nav.innerHTML = '';
if (paginas > 1) {
const knop = (t, p, a) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = t;
b.className = 'btn-sm ' + (a ? '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); }
}
}
veld.addEventListener('input', () => { z = veld.value.trim().toLowerCase(); pagina = 1; toon(); });
perPaginaKiezer(kiezer, 'contacten-' + veld.dataset.doel, n => { PER = n; pagina = 1; toon(); }, 25);
toon();
});
{% if tab == 'adresboek' %}
(function () {
const body = document.getElementById('adresBody');
if (!body) return;
const rijen = Array.from(body.rows);
const gekozen = () => rijen.filter(tr => tr.querySelector('.kies').checked);
const tel = () => document.querySelectorAll('[data-wie="gekozen"]').forEach(b => b.disabled = !gekozen().length);
body.addEventListener('change', tel);
document.getElementById('allesKiezen').addEventListener('change', e => {
rijen.filter(tr => tr.style.display !== 'none').forEach(tr => tr.querySelector('.kies').checked = e.target.checked); tel();
});
function zet(trs, zichtbaar) {
if (!trs.length) return;
const tekst = `${trs.length} gebruiker(s) ${zichtbaar ? 'tonen in' : 'verbergen uit'} het adresboek van de organisatie?`;
if (trs.length > 1 && !confirm(tekst)) return;
showLoader('Bezig met Google...');
post({{ url_for('contacten.adresboek')|tojson }}, { emails: trs.map(tr => tr.dataset.email), zichtbaar }).then(r => {
hideLoader();
const fout = new Set((r.fouten || []).map(f => f.split(':')[0]));
trs.forEach(tr => {
if (fout.has(tr.dataset.email)) return;
tr.querySelector('.zichtcel').innerHTML = zichtbaar ? '<span class="zicht ja">zichtbaar</span>' : '<span class="zicht nee">verborgen</span>';
tr.querySelector('.wissel').textContent = zichtbaar ? '🙈 Verbergen' : '👁 Tonen';
tr.querySelector('.kies').checked = false;
});
document.getElementById('aantalVerborgen').textContent = rijen.filter(tr => tr.querySelector('.zicht.nee')).length;
tel();
if (r.fout || (r.fouten || []).length) alert(r.fout || ('Mislukt:\n' + r.fouten.slice(0, 5).join('\n')));
}).catch(() => { hideLoader(); alert('Er ging iets mis.'); });
}
body.addEventListener('click', e => {
const b = e.target.closest('.wissel'); if (!b) return;
const tr = b.closest('tr'); zet([tr], !tr.querySelector('.zicht.ja'));
});
document.querySelectorAll('[data-zet]').forEach(b => b.addEventListener('click', () =>
zet(b.dataset.wie === 'alle' ? rijen : gekozen(), b.dataset.zet === '1')));
})();
{% endif %}
{% if tab == 'lijsten' %}
const URL_STATUS = {{ url_for('contacten.status')|tojson }};
function kijk() {
fetch(URL_STATUS, { credentials: 'same-origin' }).then(r => r.json()).then(s => {
const k = document.getElementById('voortgangKaart'); k.hidden = false;
document.getElementById('sLijst').textContent = s.lijst || '';
document.getElementById('tVoortgang').textContent = s.voortgang; document.getElementById('tTotaal').textContent = s.totaal;
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('');
if (s.bezig) setTimeout(kijk, 1500);
}).catch(() => setTimeout(kijk, 4000));
}
{% if status.bezig %}kijk();{% endif %}
{% if gekozen or nieuw %}
(function () {
const ITEMS = {{ (gekozen['items'] if gekozen else [])|tojson }};
const LIJST_ID = {{ (gekozen.ID if gekozen else none)|tojson }};
const VELDEN = ['Voornaam', 'Achternaam', 'Organisatie', 'Functie', 'Email', 'Telefoon', 'Notitie'];
const SLEUTELS = ['voornaam', 'achternaam', 'organisatie', 'functie', 'email', 'telefoon', 'notitie'];
const body = document.getElementById('items');
const esc = t => String(t ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
function rij(i) {
const tr = document.createElement('tr');
tr.dataset.id = i.ID || '';
tr.innerHTML = VELDEN.map((v, n) => `<td><input type="${v === 'Email' ? 'email' : 'text'}" data-k="${SLEUTELS[n]}" value="${esc(i[v])}"></td>`).join('')
+ '<td class="acties"><button type="button" class="btn-sm btn-ghost weg" title="Weghalen">🗑️</button></td>';
body.appendChild(tr);
return tr;
}
ITEMS.forEach(rij);
if (!ITEMS.length) rij({});
document.getElementById('rijErbij').onclick = () => rij({}).querySelector('input').focus();
body.addEventListener('click', e => { if (e.target.closest('.weg')) e.target.closest('tr').remove(); });
// Plakken uit Excel: meerdere regels met tabs → meerdere rijen
body.addEventListener('paste', e => {
const tekst = (e.clipboardData || window.clipboardData).getData('text');
if (!tekst.includes('\t') && !tekst.includes('\n')) return;
e.preventDefault();
const startTr = e.target.closest('tr');
tekst.replace(/\r/g, '').split('\n').filter(r => r.trim()).forEach((regel, n) => {
const tr = n === 0 && startTr && !Array.from(startTr.querySelectorAll('input')).some(i => i.value) ? startTr : rij({});
regel.split('\t').slice(0, VELDEN.length).forEach((w, k) => tr.querySelectorAll('input')[k].value = w.trim());
});
});
const verzamel = () => ({
id: LIJST_ID, naam: document.getElementById('lNaam').value,
ous: Array.from(document.querySelectorAll('#ouKader input:checked')).map(x => x.value),
met_sub: document.getElementById('lSub').checked,
items: Array.from(body.rows).map(tr => Object.assign({ id: tr.dataset.id },
Object.fromEntries(Array.from(tr.querySelectorAll('input')).map(i => [i.dataset.k, i.value]))))
});
document.getElementById('lijstBewaar').onclick = () => {
const d = verzamel();
if (!d.ous.length && !confirm('Er is nog geen OU gekozen: de lijst gaat dan naar niemand. Toch bewaren?')) return;
showLoader('Bewaren...');
post({{ url_for('contacten.lijst_bewaren')|tojson }}, d).then(r => {
if (!r.ok) { hideLoader(); alert(r.fout || 'Bewaren mislukt.'); return; }
location.href = {{ url_for('contacten.overzicht', tab='lijsten')|tojson }} + '&lijst=' + r.id;
}).catch(() => { hideLoader(); alert('Bewaren mislukt.'); });
};
{% if gekozen %}
document.getElementById('lijstSync').onclick = () => {
if (!confirm('De contacten van deze lijst nu in alle mailboxen van de gekozen OU\'s zetten (of bijwerken)?\nBewaar eerst je wijzigingen.')) return;
post({{ url_for('contacten.lijst_sync', lijst_id=gekozen.ID)|tojson }}).then(r => {
if (!r.ok) { alert(r.fout || 'Starten mislukt.'); return; }
kijk(); window.scrollTo({ top: 0, behavior: 'smooth' });
});
};
document.getElementById('lijstWeg').onclick = () => {
if (!confirm('Deze lijst verwijderen?' + ({{ (gekozen.Laatste_sync is not none)|tojson }}
? '\n\nPIVOT haalt de contacten en het label dan ook uit alle mailboxen.' : ''))) return;
post({{ url_for('contacten.lijst_verwijderen', lijst_id=gekozen.ID)|tojson }}).then(r => {
if (!r.ok) { alert(r.fout || 'Verwijderen mislukt.'); return; }
if (r.gestart) { kijk(); document.getElementById('lijstKaart').remove(); }
else location.href = {{ url_for('contacten.overzicht', tab='lijsten')|tojson }};
});
};
{% endif %}
})();
{% endif %}
{% endif %}
</script>
{% endblock %}