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

292 lines
20 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 %}GO! Pro verzameltabel{% endblock %}
{% block page_title %}GO! Pro verzameltabel{% endblock %}
{% block main_class %}wide{% endblock %}
{% block extra_css %}
<style>
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 14px 0 10px; }
.filter-bar input[type=search] { flex: 0 1 320px; }
.filter-bar #schoolFilter { width: auto; min-width: 220px; flex: 0 1 280px; }
.filter-bar #perPagina select { width: auto; min-width: 0; }
td.school { min-width: 120px; max-width: 190px; }
/* Compacte tabel: alles past zonder horizontaal scrollen */
#body td, table th { padding: 8px 9px; }
#body td { font-size: 13.5px; }
td.mail { overflow-wrap: anywhere; min-width: 160px; max-width: 260px; }
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; }
td.nowrap { white-space: nowrap; }
.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; }
.lijst-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.lijst-tabs a { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface, #fff);
text-decoration: none; color: var(--text, #1e293b); font-weight: 600; font-size: .92em; }
.lijst-tabs a b { margin-left: 4px; font-weight: 700; color: var(--text-muted, #64748b); }
.lijst-tabs a.actief { border-color: var(--primary-color); color: var(--primary-color); background: var(--primary-light); }
.lijst-tabs a.rood.actief { border-color: #dc2626; color: #b91c1c; background: #fee2e2; }
.lijst-tabs a.verzonden.actief { border-color: #16a34a; color: #15803d; background: #dcfce7; }
.rode-lijst { border-top: 4px solid #dc2626; }
.verzonden-lijst { border-top: 4px solid #16a34a; }
.per-school { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.per-school .chip { display: inline-flex; gap: 6px; align-items: center; padding: 3px 10px; border-radius: 999px;
background: var(--surface-muted); border: 1px solid var(--border); font-size: .88em;
color: inherit; font-weight: 400; cursor: pointer; width: auto; margin: 0; }
.per-school .chip:hover { border-color: var(--primary-color); }
.per-school .chip[aria-pressed="true"] { border-color: var(--primary-color); background: var(--primary-light);
color: var(--primary-color); font-weight: 600; }
</style>
{% endblock %}
{% block content %}
{% set lijstnaam = 'de rode lijst' if rood else ('de lijst met verzonden personeelsleden' if verzonden else 'de verzameltabel') %}
<nav class="lijst-tabs" aria-label="Lijsten">
<a href="{{ url_for('gopro.verzameltabel') }}" class="{{ 'actief' if lijst == 'groen' }}">📚 Verzameltabel <b id="telGroen">{{ aantal_groen }}</b></a>
<a href="{{ url_for('gopro.verzameltabel', lijst='verzonden') }}" class="verzonden {{ 'actief' if verzonden }}">📨 Verzonden <b id="telVerzonden">{{ aantal.verzonden }}</b></a>
<a href="{{ url_for('gopro.verzameltabel', lijst='rood') }}" class="rood {{ 'actief' if rood }}">🚫 Rode lijst (genegeerd) <b id="telRood">{{ aantal_rood }}</b></a>
</nav>
<div class="card {{ 'rode-lijst' if rood }}{{ 'verzonden-lijst' if verzonden }}">
<div class="page-head">
<h2>{{ '🚫 Rode lijst GO! Pro' if rood else ('📨 Verzonden naar GO! Pro' if verzonden else '📚 Verzameltabel GO! Pro') }} <span class="muted small">(<span id="telHier">{{ rijen|length }}</span>)</span></h2>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.overzicht') }}">← Personeel van deze school</a>
{% if rijen and not rood %}
<a class="btn btn-success btn-sm" id="exportKnop" href="{{ url_for('gopro.verzameltabel_export', lijst='verzonden' if verzonden else None) }}">⬇️ CSV alle scholen</a>
{% endif %}
</div>
</div>
{% if rood %}
<p class="field-hint" style="margin-top:-6px">Personeelsleden die je met 🚫 naast hun naam op de GO! Pro-pagina hebt
weggezet. Ze worden genegeerd: ze komen niet in de CSV en verschijnen niet meer op de GO! Pro-pagina van hun school,
ook niet na een nieuwe ophaling. Haal je iemand hier weg, dan verschijnt hij daar opnieuw.</p>
{% elif verzonden %}
<p class="field-hint" style="margin-top:-6px">Personeelsleden die al naar GO! Pro doorgestuurd zijn (met
<strong>✅ Markeer als verzonden</strong> in de verzameltabel). Ze worden nooit opnieuw aan de verzameltabel toegevoegd,
ook niet door de nachtelijke verzameling, en verschijnen niet meer op de GO! Pro-pagina van hun school. Haal je iemand
hier weg, dan verschijnt hij daar opnieuw.</p>
{% else %}
<p class="field-hint" style="margin-top:-6px">De groene personeelsleden (account bestaat in Google) van elke school die je hier
verzamelt, komen samen in één tabel voor één grote GO! Pro-import. Wie hier staat, wordt op de GO! Pro-pagina van zijn
school niet meer getoond. Haal je iemand hier weg, dan verschijnt hij daar opnieuw.</p>
{% endif %}
{% if scholen %}
<div class="per-school">
{% for code, naam in scholen %}
<button type="button" class="chip" data-school="{{ code }}" aria-pressed="false" title="Enkel {{ naam }} tonen (nog eens klikken = alle scholen)">{{ naam }} <b>{{ per_school[code] }}</b></button>
{% endfor %}
</div>
{% endif %}
{% if rijen %}
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek op naam, e-mail, functie of rol..." aria-label="Zoeken">
<select id="schoolFilter" aria-label="School">
<option value="">Alle scholen</option>
{% for code, naam in scholen %}<option value="{{ code }}">{{ naam }}</option>{% endfor %}
</select>
<span id="perPagina"></span>
<nav class="paginering" id="pagineringBoven" aria-label="Pagina's" style="margin-left:auto"></nav>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>School <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Voornaam <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Achternaam <span class="pijl"></span></th>
<th class="sorteer" data-tekst>E-mail / UPN <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Instelling <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Functie / rol <span class="pijl"></span></th>
<th class="sorteer" title="Start en einde">Periode <span class="pijl"></span></th>
<th class="sorteer">{{ 'Verzonden' if verzonden else 'Toegevoegd' }} <span class="pijl"></span></th>
<th class="actions"></th>
</tr>
</thead>
<tbody id="body">
{% for r in rijen %}
<tr data-school="{{ r.School_code }}">
<td class="school" data-sort="{{ r.School_naam or r.School_code }}">{{ r.School_naam or r.School_code }}</td>
<td data-sort="{{ r.Voornaam or '' }}">{{ r.Voornaam or '' }}</td>
<td data-sort="{{ r.Achternaam or '' }}"><strong>{{ r.Achternaam or '' }}</strong></td>
<td class="mail" data-sort="{{ r.Email or '' }}">{{ (r.Email or '')|replace('@', '@\u200b') }}{% if r.UPN and r.UPN != r.Email %}<br><span class="muted small">UPN: {{ r.UPN }}</span>{% endif %}</td>
<td class="nowrap" data-sort="{{ r.Instellingsnummer or '' }}">{{ r.Instellingsnummer or '' }}</td>
<td data-sort="{{ r.Functie or r.Rol or '' }}">{{ r.Functie or '' }}{% if r.Rol and r.Rol != r.Functie %}<br><span class="muted small">{{ r.Rol }}</span>{% endif %}</td>
<td class="nowrap small" data-sort="{{ r.Startdatum.isoformat() if r.Startdatum else '' }}">{{ r.Startdatum.strftime('%d-%m-%Y') if r.Startdatum else '' }}{% if r.Einddatum %}<br><span class="muted">tot {{ r.Einddatum.strftime('%d-%m-%Y') }}</span>{% endif %}</td>
{% if verzonden and r.Verzonden %}
<td class="nowrap muted small" data-sort="{{ r.Verzonden.isoformat() }}" title="{{ r.Verzonden.strftime('%H:%M') }}{{ ' door ' ~ r.Verzonden_door if r.Verzonden_door }} · toegevoegd {{ r.Toegevoegd.strftime('%d-%m-%Y') }}">{{ r.Verzonden.strftime('%d-%m-%Y') }}</td>
{% else %}
<td class="nowrap muted small" data-sort="{{ r.Toegevoegd.isoformat() }}" title="{{ r.Toegevoegd.strftime('%H:%M') }}{{ ' door ' ~ r.Door if r.Door }}">{{ r.Toegevoegd.strftime('%d-%m-%Y') }}</td>
{% endif %}
<td class="actions" style="white-space:nowrap">
{% if lijst == 'groen' %}
{% set volle_naam = (r.Voornaam or '') ~ ' ' ~ (r.Achternaam or '') %}
<form method="POST" action="{{ url_for('gopro.verzameltabel_naar_rood') }}" class="rij-actie" data-soort="rood" style="margin:0;display:inline"
onsubmit="return confirm({{ (volle_naam ~ ' naar de rode lijst verplaatsen? Die wordt dan genegeerd: niet meer geëxporteerd en nooit meer getoond.')|tojson|forceescape }})">
<input type="hidden" name="id" value="{{ r.ID }}"><input type="hidden" name="naam" value="{{ volle_naam }}">
<button type="submit" class="btn-sm btn-ghost" title="Naar de rode lijst (negeren)">🚫</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('gopro.verzameltabel_verwijderen') }}" class="rij-actie" data-soort="weg" style="margin:0;display:inline"
onsubmit="return confirm({{ ((r.Voornaam or '') ~ ' ' ~ (r.Achternaam or '') ~ ' uit ' ~ lijstnaam ~ ' halen? Die verschijnt dan weer op de GO! Pro-pagina van de school.')|tojson|forceescape }})">
<input type="hidden" name="id" value="{{ r.ID }}">
{% if lijst != 'groen' %}<input type="hidden" name="lijst" value="{{ lijst }}">{% endif %}
<button type="submit" class="btn-sm btn-ghost" title="Uit {{ lijstnaam }} halen">🗑️</button>
</form>
</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>
<div class="filter-bar" style="margin-top:22px;padding-top:14px;border-top:1px solid var(--border)">
<form method="POST" action="{{ url_for('gopro.verzameltabel_verwijderen') }}" style="margin:0" id="schoolLeegForm" hidden
onsubmit="return confirm({{ ('Alle personeelsleden van de gekozen school uit ' ~ lijstnaam ~ ' halen?')|tojson|forceescape }})">
<input type="hidden" name="school" id="schoolLeeg">
{% if lijst != 'groen' %}<input type="hidden" name="lijst" value="{{ lijst }}">{% endif %}
<button type="submit" class="btn-sm btn-light">🗑️ Gekozen school uit {{ lijstnaam }} halen</button>
</form>
<form method="POST" action="{{ url_for('gopro.verzameltabel_verwijderen') }}" style="margin:0"
onsubmit="return confirm({{ ('De volledige ' ~ ('rode lijst' if rood else 'verzameltabel') ~ ' leegmaken (' ~ rijen|length ~ ' personeelsleden van alle scholen)?')|tojson|forceescape }})">
<input type="hidden" name="alles" value="1">
{% if lijst != 'groen' %}<input type="hidden" name="lijst" value="{{ lijst }}">{% endif %}
<button type="submit" class="btn-sm btn-danger">🗑️ {{ 'Rode lijst' if rood else ('Lijst verzonden' if verzonden else 'Verzameltabel') }} leegmaken</button>
</form>
{% if lijst == 'groen' %}
<form method="POST" action="{{ url_for('gopro.verzameltabel_verzonden') }}" style="margin:0" id="verzondenForm"
onsubmit="const s = this.school.value, n = s ? document.querySelectorAll('#body tr[data-school=&quot;' + CSS.escape(s) + '&quot;]').length : {{ rijen|length }};
return confirm(n + ' personeelsleden' + (s ? ' van ' + document.getElementById('schoolFilter').selectedOptions[0].text : ' van alle scholen') + ' markeren als verzonden?\n\nZe verdwijnen uit de verzameltabel en worden nooit opnieuw toegevoegd.');">
<input type="hidden" name="school" id="schoolVerzonden">
<button type="submit" class="btn-sm btn-success" id="verzondenKnop">✅ Markeer als verzonden</button>
</form>
{% endif %}
</div>
{% else %}
{% if verzonden %}
<p class="muted" style="margin-top:14px">Er is nog niemand als verzonden gemarkeerd.</p>
{% elif rood %}
<p class="muted" style="margin-top:14px">De rode lijst is nog leeg. Klik op GO! Pro op 🚫 naast een naam om iemand te negeren.</p>
{% else %}
<p class="muted" style="margin-top:14px">De verzameltabel is nog leeg. Open GO! Pro in een school en klik op
<strong>➕ Groene naar verzameltabel</strong>.</p>
{% endif %}
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const body = document.getElementById('body');
if (!body) return;
const zoekveld = document.getElementById('zoek'), schoolSel = document.getElementById('schoolFilter');
const exp = document.getElementById('exportKnop'), basisExport = exp ? exp.href : '';
let PER_PAGINA = perPagina('gopro-verzameltabel', 10), pagina = 1, zoek = '', school = '';
function toon() {
const rijen = Array.from(body.rows).filter(tr => (!school || tr.dataset.school === school)
&& (!zoek || tr.textContent.replace(/\u200b/g, '').toLowerCase().includes(zoek)));
const grootte = PER_PAGINA || Infinity, 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}` : 'Niets gevonden';
['paginering', 'pagineringBoven'].forEach(id => {
const nav = document.getElementById(id);
nav.innerHTML = '';
if (paginas <= 1) return;
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('‹ 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); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
});
}
zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
// 🚫 en 🗑️ per rij zonder de pagina te herladen: zoekopdracht, school en pagina blijven staan
const melding = document.createElement('div');
melding.className = 'alert'; melding.style.display = 'none'; melding.style.margin = '10px 0';
body.closest('.table-wrap').before(melding);
const tel = (id, d) => { const el = document.getElementById(id); if (el) el.textContent = Math.max(0, (+el.textContent || 0) + d); };
body.addEventListener('submit', e => {
const form = e.target;
if (!form.classList.contains('rij-actie') || e.defaultPrevented) return;
e.preventDefault();
const knop = form.querySelector('button'); knop.disabled = true;
fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin',
headers: { 'X-Requested-With': 'fetch' } })
.then(r => r.json()).then(r => {
melding.style.display = '';
melding.className = 'alert ' + (r.ok ? 'alert-success' : 'alert-danger');
melding.textContent = r.melding || (r.ok ? 'Gelukt.' : 'Mislukt.');
clearTimeout(melding._t); melding._t = setTimeout(() => melding.style.display = 'none', 4000);
if (!r.ok) { knop.disabled = false; return; }
form.closest('tr').remove();
tel('telHier', -1);
tel({{ ('telGroen' if lijst == 'groen' else ('telRood' if rood else 'telVerzonden'))|tojson }}, -1);
if (form.dataset.soort === 'rood') tel('telRood', 1);
toon();
}).catch(() => { knop.disabled = false; alert('Er ging iets mis; probeer opnieuw.'); });
});
schoolSel.addEventListener('change', () => {
school = schoolSel.value; pagina = 1; toon();
// Export en "school leegmaken" volgen de gekozen school
if (exp) {
exp.href = school ? basisExport + '?school=' + encodeURIComponent(school) : basisExport;
exp.textContent = school ? '⬇️ CSV ' + schoolSel.options[schoolSel.selectedIndex].text : '⬇️ CSV alle scholen';
}
document.querySelectorAll('.per-school .chip').forEach(c =>
c.setAttribute('aria-pressed', String(!!school && c.dataset.school === school)));
document.getElementById('schoolLeeg').value = school;
const vz = document.getElementById('schoolVerzonden');
if (vz) {
vz.value = school;
document.getElementById('verzondenKnop').textContent = school
? '✅ ' + schoolSel.options[schoolSel.selectedIndex].text + ' markeren als verzonden' : '✅ Markeer als verzonden';
}
document.getElementById('schoolLeegForm').hidden = !school;
});
// Klik op een schoollabel = filteren op die school (nog eens klikken = alle scholen)
document.querySelectorAll('.per-school .chip').forEach(c => c.addEventListener('click', () => {
schoolSel.value = schoolSel.value === c.dataset.school ? '' : c.dataset.school;
schoolSel.dispatchEvent(new Event('change'));
}));
let kol = null, op = true;
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : 'tekst' in th.dataset;
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-verzameltabel', n => { PER_PAGINA = n; pagina = 1; toon(); }, 10);
toon();
})();
</script>
{% endblock %}