Files
Pivot/templates/contacten.html
2026-10-14 13:34:37 +02:00

353 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}