PIVOT 1.1.0
This commit is contained in:
commit
8a53234eb6
161 files changed
+31109
No files matched your search
@@ -0,0 +1,291 @@
|
||||
{% 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="' + CSS.escape(s) + '"]').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 %}
|
||||
Reference in new issue
Block a user