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

+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}CO2-sensoren{% endblock %}
{% block page_title %}CO2-sensoren{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Netwerk wordt gescand...{% endblock %}
{% block loader_sub %}<div class="loader-sub">Dit kan tot 60 seconden duren. Even geduld a.u.b.</div>{% endblock %}
{% block extra_css %}
<style>
.settings-row { display: flex; flex-wrap: wrap; gap: 12px 32px; padding: 14px 16px; margin-bottom: 18px;
background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); }
.setting { display: flex; align-items: center; gap: 10px; }
.status-label { font-weight: 700; min-width: 28px; color: var(--text-muted); }
.ppm { font-variant-numeric: tabular-nums; font-weight: 600; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🌬️ Actuele CO2-metingen per lokaal</h2>
<div class="actions">
<a href="{{ url_for('co2_sensor.scan_co2') }}" class="btn btn-success" onclick="showLoader()">🔍 Handmatige scan</a>
</div>
</div>
<div class="settings-row">
<div class="setting">
<label class="switch">
<input type="checkbox" id="continuousScanToggle" onchange="handleToggle(this)">
<span class="slider"></span>
</label>
<span>Continu scannen</span>
<span id="status-text" class="status-label">Uit</span>
</div>
<div class="setting">
<label class="switch">
<input type="checkbox" id="autoDeleteToggle" onchange="handleDeleteToggle(this)">
<span class="slider"></span>
</label>
<span>Resultaten ouder dan 7 dagen automatisch verwijderen</span>
<span id="delete-status-text" class="status-label">Uit</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Lokaal</th>
<th>CO2</th>
<th>Status</th>
<th class="actions">Actie</th>
</tr>
</thead>
<tbody>
{% for row in sensors %}
<tr>
<td><strong>{{ row.lokaal }}</strong></td>
<td class="ppm">{{ row.ppm }} ppm</td>
<td>
{% if row.ppm < 800 %}<span class="badge badge-success">Goed</span>
{% elif row.ppm < 1200 %}<span class="badge badge-warning">Ventileren</span>
{% else %}<span class="badge badge-danger">Kritiek</span>
{% endif %}
</td>
<td class="actions">
<a href="{{ url_for('co2_sensor.grafiek', lokaal=row.lokaal) }}" class="btn btn-sm">📊 Grafiek</a>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4">Nog geen metingen. Start een handmatige scan.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block footer %}
<footer class="app-footer">{{ school_name_short }} – PIVOT – Versie {{ version }} – CO2-monitor</footer>
{% endblock %}
{% block scripts %}
<script>
function setLabel(id, active) {
const el = document.getElementById(id);
if (!el) return;
el.innerText = active ? "Aan" : "Uit";
el.style.color = active ? "var(--success)" : "var(--text-muted)";
}
function updateUI(active) { setLabel('status-text', active); }
function updateDeleteUI(active) { setLabel('delete-status-text', active); }
document.addEventListener('DOMContentLoaded', function () {
fetch("{{ url_for('co2_sensor.get_scan_status') }}")
.then(r => r.json())
.then(data => {
document.getElementById('continuousScanToggle').checked = data.active;
updateUI(data.active);
});
fetch("{{ url_for('co2_sensor.get_cleanup_status') }}")
.then(r => r.json())
.then(data => {
document.getElementById('autoDeleteToggle').checked = data.active;
updateDeleteUI(data.active);
});
});
function postToggle(url, checkbox, updateFn) {
const isActive = checkbox.checked;
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: isActive })
})
.then(r => { if (!r.ok) throw new Error('Server error'); return r.json(); })
.then(data => { updateFn(data.active); checkbox.checked = data.active; })
.catch(err => { console.error(err); checkbox.checked = !isActive; updateFn(!isActive); });
}
function handleToggle(checkbox) { postToggle("{{ url_for('co2_sensor.toggle_continuous_scan') }}", checkbox, updateUI); }
function handleDeleteToggle(checkbox) { postToggle("{{ url_for('co2_sensor.toggle_auto_delete') }}", checkbox, updateDeleteUI); }
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Leerlingenbeheer{% endblock %}
{% block page_title %}Leerlingenbeheer{% endblock %}
{% block main_class %}wide{% endblock %}
{% block extra_css %}
<style>
/* Bewerken-knop altijd zichtbaar: laatste kolom blijft rechts staan bij horizontaal scrollen */
.lln-tabel td.actions, .lln-tabel th.actions {
position: sticky; right: 0; background: #fff; white-space: nowrap;
box-shadow: -6px 0 6px -6px rgba(15, 23, 42, .15);
}
.lln-tabel th.actions { background: var(--surface-muted); }
.lln-tabel td { font-size: 14px; }
.lln-tabel td.nowrap { white-space: nowrap; }
.lln-tabel tr.vertrokken td:not(.actions) { color: var(--text-muted, #64748b); }
</style>
{% endblock %}
{% block loader_text %}Synchroniseren met Informat &amp; Smartschool... Even geduld.{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>👥 Leerlingen</h2>
<div class="actions">
<button type="button" class="btn-success" onclick="openAddModal()">➕ Leerling toevoegen</button>
<form action="{{ url_for('students.sync_informat') }}" method="POST" style="margin:0" onsubmit="showLoader()">
<button type="submit" class="btn-secondary">🔄 Informat &amp; SS sync</button>
</form>
</div>
</div>
<form class="search-bar" method="POST" action="{{ url_for('students.inventaris_leerlingen') }}">
<input type="text" name="query" placeholder="Zoek op naam of internnummer..." value="{{ query or '' }}">
{% if toon_vertrokken %}<input type="hidden" name="vertrokken" value="1">{% endif %}
<button type="submit" class="btn-search">🔍 Zoeken</button>
{% if query %}
<a href="{{ url_for('students.inventaris_leerlingen', vertrokken='1' if toon_vertrokken else None) }}" class="btn btn-light">✕ Wissen</a>
{% endif %}
</form>
{% if aantal_vertrokken %}
<p class="muted small" style="margin:-4px 0 12px">
{% if toon_vertrokken %}Vertrokken leerlingen worden getoond ({{ aantal_vertrokken }}).
<a href="{{ url_for('students.inventaris_leerlingen', query=query or None) }}">Verbergen</a>
{% else %}{{ aantal_vertrokken }} leerling{{ 'en' if aantal_vertrokken != 1 }} niet meer actief in Informat (vertrokken) verborgen.
<a href="{{ url_for('students.inventaris_leerlingen', vertrokken='1', query=query or None) }}">Toon ook vertrokken leerlingen</a>{% endif %}
</p>
{% endif %}
<div class="table-wrap">
<table class="lln-tabel">
<thead>
<tr>
<th>Internnummer</th>
<th>Gebruikersnaam</th>
<th>Voornaam</th>
<th>Naam</th>
<th>Smartschool nr.</th>
<th>Google e-mailadres</th>
<th>Schooljaar</th>
<th class="actions">Acties</th>
</tr>
</thead>
<tbody>
{% if results %}
{% for row in results %}
<tr{% if row['Vertrokken_op'] %} class="vertrokken"{% endif %}>
<td><strong>{{ row['Internnummer'] }}</strong>{% if row['Vertrokken_op'] %}
<span class="badge badge-warning" title="Niet meer actief in Informat sinds {{ row['Vertrokken_op'].strftime('%d-%m-%Y') }}">vertrokken</span>{% endif %}</td>
<td>{{ row['Gebruikersnaam'] or '' }}</td>
<td>{{ row['Voornaam'] or '' }}</td>
<td>{{ row['Naam'] or '' }}</td>
<td class="nowrap">{{ row['SS_nummer'] or '' }}</td>
<td>{{ row['Google_adres'] or '' }}</td>
<td class="nowrap">{{ row['Schooljaar'] or '' }}</td>
<td class="actions">
<button type="button" class="btn-bewerk btn-sm"
data-intern="{{ row['Internnummer'] }}"
data-gebruikersnaam="{{ row['Gebruikersnaam'] or '' }}"
data-voornaam="{{ row['Voornaam'] or '' }}"
data-naam="{{ row['Naam'] or '' }}"
data-ss="{{ row['SS_nummer'] or '' }}"
data-google="{{ row['Google_adres'] or '' }}"
onclick="openEditModalFromButton(this)">✏️ Bewerken</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr class="empty-row"><td colspan="8">Geen leerlingen gevonden in de database.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block modals %}
<div id="addStudentModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeAddModal()">&times;</span>
<h3>Leerling handmatig toevoegen</h3>
<form action="{{ url_for('students.registreer_leerling') }}" method="POST">
<div class="field"><label>Internnummer</label><input type="text" name="inter_id" required></div>
<div class="field"><label>Gebruikersnaam</label><input type="text" name="gebruikersnaam" required></div>
<div class="field"><label>Voornaam</label><input type="text" name="voornaam"></div>
<div class="field"><label>Naam</label><input type="text" name="naam"></div>
<div class="field"><label>Smartschool nummer</label><input type="text" name="ss_nummer"></div>
<div class="field"><label>Google adres</label><input type="email" name="google_adres"></div>
<button type="submit" class="btn-block btn-success">💾 Opslaan</button>
</form>
</div>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<h3>Leerling bewerken</h3>
<form action="{{ url_for('students.bewerk_gebruiker') }}" method="POST">
<div class="field"><label>Internnummer (ID)</label><input type="text" id="edit_internnummer" name="internnummer" readonly></div>
<div class="field"><label>Gebruikersnaam</label><input type="text" id="edit_gebruikersnaam" name="gebruikersnaam"></div>
<div class="field"><label>Voornaam</label><input type="text" id="edit_voornaam" name="voornaam"></div>
<div class="field"><label>Naam</label><input type="text" id="edit_naam" name="naam"></div>
<div class="field"><label>Smartschool nummer</label><input type="text" id="edit_ss_nummer" name="ss_nummer"></div>
<div class="field"><label>Google adres</label><input type="email" id="edit_google_adres" name="google_adres"></div>
<button type="submit" class="btn-block">💾 Wijzigingen opslaan</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openAddModal() { document.getElementById('addStudentModal').style.display = 'block'; }
function closeAddModal() { document.getElementById('addStudentModal').style.display = 'none'; }
function openEditModalFromButton(btn) {
document.getElementById('edit_internnummer').value = btn.getAttribute('data-intern') || '';
document.getElementById('edit_gebruikersnaam').value = btn.getAttribute('data-gebruikersnaam') || '';
document.getElementById('edit_voornaam').value = btn.getAttribute('data-voornaam') || '';
document.getElementById('edit_naam').value = btn.getAttribute('data-naam') || '';
document.getElementById('edit_ss_nummer').value = btn.getAttribute('data-ss') || '';
document.getElementById('edit_google_adres').value = btn.getAttribute('data-google') || '';
document.getElementById('editModal').style.display = 'block';
}
function closeModal() { document.getElementById('editModal').style.display = 'none'; }
window.onclick = function (event) {
if (event.target == document.getElementById('editModal')) closeModal();
if (event.target == document.getElementById('addStudentModal')) closeAddModal();
};
</script>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<style>
/* Opmaak van de inhoud van een handleiding (lezen én editor) */
.hl-inhoud { line-height: 1.65; font-size: 15px; color: var(--text); max-width: 860px; }
.hl-inhoud h2 { font-size: 1.25rem; margin: 28px 0 10px; padding-bottom: 6px; border-bottom: 2px solid var(--primary-light);
color: var(--primary-color); scroll-margin-top: 80px; }
.hl-inhoud h3 { font-size: 1.08rem; margin: 20px 0 8px; scroll-margin-top: 80px; }
.hl-inhoud h4 { font-size: 1rem; margin: 16px 0 6px; }
.hl-inhoud p { margin: 0 0 12px; }
.hl-inhoud ol, .hl-inhoud ul { margin: 0 0 14px; padding-left: 26px; }
.hl-inhoud li { margin: 4px 0; }
.hl-inhoud ol > li::marker { font-weight: 700; color: var(--primary-color); }
.hl-inhoud code, .hl-inhoud kbd { background: var(--surface-muted); border: 1px solid var(--border); border-radius: 4px;
padding: 1px 5px; font-size: .88em; word-break: break-all; }
.hl-inhoud pre { background: #0f172a; color: #e2e8f0; padding: 12px 14px; border-radius: 8px; overflow-x: auto;
white-space: pre-wrap; word-break: break-all; }
.hl-inhoud pre code { background: none; border: 0; color: inherit; padding: 0; }
.hl-inhoud img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 8px; margin: 6px 0;
box-shadow: var(--shadow-sm); }
.hl-inhoud a { color: var(--primary-color); }
.hl-inhoud blockquote { margin: 0 0 12px; padding: 6px 14px; border-left: 4px solid var(--border-strong); color: var(--text-muted); }
.hl-inhoud table { border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: .93em; }
.hl-inhoud th, .hl-inhoud td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; }
.hl-inhoud th { background: var(--surface-muted); }
.hl-inhoud mark { background: #fef08a; padding: 0 2px; }
.hl-inhoud .tip, .hl-inhoud .let-op, .hl-inhoud .info { padding: 10px 14px; border-radius: 8px; margin: 0 0 14px; border-left: 4px solid; }
.hl-inhoud .tip { background: var(--success-bg); border-color: var(--success); color: var(--success-text); }
.hl-inhoud .let-op { background: var(--warning-bg); border-color: var(--warning); color: var(--warning-text); }
.hl-inhoud .info { background: var(--info-bg); border-color: var(--info); color: var(--info-text); }
.hl-inhoud .tip > :last-child, .hl-inhoud .let-op > :last-child, .hl-inhoud .info > :last-child { margin-bottom: 0; }
</style>
+32
View File
@@ -0,0 +1,32 @@
{# Gedeelde stukjes voor de personeelsmodule #}
{% macro status_badge(status) -%}
{%- set s = STATUS.get(status, (status, '')) -%}
<span class="badge {{ 'badge-' ~ s[1] if s[1] else '' }}">{{ s[0] }}</span>
{%- endmacro %}
{% macro soort_label(soort) -%}
{%- set s = SOORTEN.get(soort) -%}{{ s.icoon }} {{ s.label }}
{%- endmacro %}
{% macro periode(i) -%}
{%- if i.Van_datum == i.Tot_datum -%}
{{ ['ma','di','wo','do','vr','za','zo'][i.Van_datum.weekday()] }} {{ i.Van_datum.strftime('%d/%m/%Y') }}
{%- else -%}
{{ i.Van_datum.strftime('%d/%m') }} – {{ i.Tot_datum.strftime('%d/%m/%Y') }}
{%- endif -%}
<span class="muted"> · {{ DAGDELEN.get(i.Dagdeel, '') }}{% if i.Dagdeel == 'uren' and i.Lesuren %} ({{ i.Lesuren }}){% endif %}</span>
{%- endmacro %}
{% macro subnav(actief) %}
<div class="tabs" style="margin-bottom: 18px;">
<a class="tab-link {{ 'active' if actief == 'mijn' }}" href="{{ url_for('personeel.mijn') }}" style="text-decoration:none">📋 Mijn aanvragen</a>
{% if 'directie' in p_rollen %}
<a class="tab-link {{ 'active' if actief == 'beoordelen' }}" href="{{ url_for('personeel.beoordelen') }}" style="text-decoration:none">
✅ Te beoordelen{% if p_te_beoordelen %} <span class="badge badge-warning">{{ p_te_beoordelen }}</span>{% endif %}</a>
{% endif %}
{% if 'secretariaat' in p_rollen %}
<a class="tab-link {{ 'active' if actief == 'dagoverzicht' }}" href="{{ url_for('personeel.dagoverzicht') }}" style="text-decoration:none">🗓️ Dagoverzicht</a>
{% endif %}
</div>
{% endmacro %}
+90
View File
@@ -0,0 +1,90 @@
{# Zoekvelden met suggesties voor uitdelen / inleveren:
- Internnummer (<input id="internnummer"> + <ul id="leerlingSuggesties">): zoeken op internnummer of naam
- Toestelnummer (<input id="serienummer"> + <ul id="toestelSuggesties">): zoeken op toestel- of serienummer
Opties: toestel_voorkeur = 'beschikbaar' | 'uitgeleend' (die eerst tonen),
toestel_vul_intern = bij inleveren het andere veld automatisch invullen. #}
<style>
.zoek-wrap { position: relative; }
.suggesties { display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: 20; margin: 2px 0 0; padding: 4px;
list-style: none; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
box-shadow: var(--shadow-lg); max-height: 320px; overflow-y: auto; }
.suggesties.open { display: block; }
.suggesties li { padding: 8px 10px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 10px; }
.suggesties li.actief, .suggesties li:hover { background: var(--primary-light); }
.suggesties .nr { font-weight: 700; }
.suggesties .sub { color: var(--text-muted); font-size: .85em; }
.suggesties .leeg { color: var(--text-muted); cursor: default; }
</style>
<script>
const zoekEsc = t => String(t ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function zoekFlits(el) { el.style.backgroundColor = '#d1fae5'; setTimeout(() => el.style.backgroundColor = '', 600); }
// Algemeen zoekveld met suggestielijst (pijltjes, Enter, Escape, muis). Enter zonder keuze = gewoon indienen (scanner).
function zoekveld({ veldId, lijstId, url, regel, kies, leeg }) {
const veld = document.getElementById(veldId), lijst = document.getElementById(lijstId);
if (!veld || !lijst) return;
let timer, items = [], actief = -1;
const sluit = () => { lijst.classList.remove('open'); veld.setAttribute('aria-expanded', 'false'); actief = -1; };
const neem = i => { const t = items[i]; if (!t) return; kies(t, veld); sluit(); };
const teken = () => {
lijst.innerHTML = items.length
? items.map((t, i) => `<li role="option" data-i="${i}" class="${i === actief ? 'actief' : ''}">${regel(t)}</li>`).join('')
: `<li class="leeg">${leeg}</li>`;
lijst.classList.add('open'); veld.setAttribute('aria-expanded', 'true');
};
veld.addEventListener('input', () => {
clearTimeout(timer);
const q = veld.value.trim();
if (!q) { sluit(); return; }
timer = setTimeout(() => {
fetch(url + encodeURIComponent(q), { credentials: 'same-origin' })
.then(r => r.json()).then(d => { items = d; actief = -1; teken(); }).catch(sluit);
}, 200);
});
veld.addEventListener('keydown', e => {
if (!lijst.classList.contains('open') || !items.length) return;
if (e.key === 'ArrowDown') { actief = (actief + 1) % items.length; teken(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { actief = (actief - 1 + items.length) % items.length; teken(); e.preventDefault(); }
else if (e.key === 'Enter' && actief >= 0) { neem(actief); e.preventDefault(); }
else if (e.key === 'Escape') sluit();
});
lijst.addEventListener('mousedown', e => { const li = e.target.closest('li[data-i]'); if (li) { e.preventDefault(); neem(+li.dataset.i); } });
veld.addEventListener('blur', () => setTimeout(sluit, 150));
}
// Internnummer: zoeken op internnummer of naam; het internnummer komt in het veld
zoekveld({
veldId: 'internnummer', lijstId: 'leerlingSuggesties', leeg: 'Geen leerling gevonden',
url: "{{ url_for('devices.zoek_leerlingen') }}?q=",
regel: l => `<span><span class="nr">${zoekEsc(l.naam || '(geen naam)')}</span>
<span class="sub"> · ${zoekEsc(l.intern)}${l.gebruiker ? ' · ' + zoekEsc(l.gebruiker) : ''}</span></span>
<span class="sub">${l.toestellen ? '💻 ' + zoekEsc(l.toestellen) : 'geen toestel'}</span>`,
kies: (l, veld) => {
veld.value = l.intern;
{% if toestel_vul_intern %}
// Inleveren: het toestel van deze leerling meteen invullen (als het nog leeg is en hij er juist één heeft)
const toestel = document.getElementById('serienummer');
if (toestel && !toestel.value.trim() && l.toestellen && !l.toestellen.includes(',')) { toestel.value = l.toestellen; zoekFlits(toestel); }
{% endif %}
document.getElementById('serienummer')?.focus();
},
});
// Toestelnummer: zoeken op toestel- of serienummer
zoekveld({
veldId: 'serienummer', lijstId: 'toestelSuggesties', leeg: 'Geen toestel gevonden',
url: "{{ url_for('devices.zoek_toestellen') }}?voorkeur={{ toestel_voorkeur|default('') }}&q=",
regel: t => `<span><span class="nr">${zoekEsc(t.nummer || '(geen toestelnummer)')}</span>
<span class="sub"> · ${zoekEsc(t.serie)}${t.type ? ' · ' + zoekEsc(t.type) : ''}</span></span>
<span class="sub">${t.bij ? 'uitgeleend aan ' + zoekEsc(t.bij) : zoekEsc(t.status || 'Beschikbaar')}</span>`,
kies: (t, veld) => {
veld.value = t.nummer || t.serie;
{% if toestel_vul_intern %}
// Inleveren: internnummer van de leerling die het toestel heeft meteen invullen (als het nog leeg is)
const intern = document.getElementById('internnummer');
if (intern && !intern.value.trim() && t.bij) { intern.value = t.bij; zoekFlits(intern); }
{% endif %}
veld.focus();
},
});
</script>
+247
View File
@@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block title %}Aanwezigheden internaat{% endblock %}
{% block page_title %}Aanwezigheden internaat{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Register laden...{% endblock %}
{% block extra_css %}
<style>
.weeknav { display: flex; align-items: center; gap: 8px; }
.weeknav .label { min-width: 150px; text-align: center; line-height: 1.2; }
.weeknav .label strong { display: block; }
.weeknav .label span { font-size: .8em; color: var(--text-muted, #64748b); }
.werkbalk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin: 14px 0 12px; }
.werkbalk .links { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.werkbalk .links > * { flex: 0 0 auto; }
.werkbalk .links input#zoek { width: 220px !important; max-width: 220px; }
.werkbalk select { width: auto; min-width: 0; }
.legende { display: flex; gap: 14px; align-items: center; font-size: .85em; color: var(--text-muted, #64748b); }
.legende i { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
.aw-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg, 12px); }
table.aw { border-collapse: collapse; width: 100%; min-width: 820px; font-size: 13px; }
table.aw th, table.aw td { border-bottom: 1px solid var(--border); padding: 0; }
table.aw th.naam, table.aw td.naam { position: sticky; left: 0; background: var(--surface, #fff); text-align: left;
padding: 9px 14px; min-width: 190px; width: 220px; z-index: 1; box-shadow: 1px 0 0 var(--border); }
table.aw thead th { background: var(--surface-muted); font-weight: 600; }
table.aw thead th.dag { text-align: center; padding: 8px 4px 2px; border-left: 1px solid var(--border); }
table.aw thead th.dag small { display: block; font-weight: 400; color: var(--text-muted, #64748b); font-size: .85em; }
table.aw thead th.sessie { text-align: center; font-size: 10px; color: var(--text-muted, #64748b); padding: 2px 0 6px; font-weight: 500; }
table.aw .vandaag { background: #fff7e6 !important; }
table.aw td.cel { text-align: center; padding: 5px 3px; border-left: 1px solid var(--border); }
table.aw td.cel + td.cel { border-left-style: dashed; }
td.naam strong { display: block; font-weight: 600; }
td.naam span { color: var(--text-muted, #64748b); font-size: .85em; }
button.st { all: unset; box-sizing: border-box; width: 30px; height: 30px; border-radius: 7px; cursor: pointer;
display: inline-grid; place-items: center; font-weight: 700; font-size: 15px; transition: background .12s;
background: #f1f5f9; border: 1px solid #cbd5e1; color: #94a3b8; }
button.st:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
button.st.aanwezig { background: #16a34a; border-color: transparent; color: #fff; }
button.st.afwezig { background: #dc2626; border-color: transparent; color: #fff; }
button.st.bezig { opacity: .5; }
@keyframes stempel { from { transform: scale(1.35); opacity: 0; } to { transform: scale(1); opacity: 1; } }
button.st span { display: inline-block; animation: stempel .16s ease-out; }
@media (prefers-reduced-motion: reduce) { button.st span { animation: none; } }
tr.groepkop td { background: var(--surface-muted); padding: 0; }
tr.groepkop button { all: unset; box-sizing: border-box; width: 100%; display: flex; gap: 10px; align-items: center;
padding: 9px 14px; cursor: pointer; font-weight: 700; }
tr.groepkop button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
tr.groepkop .pijl { display: inline-block; transition: transform .12s; font-size: .8em; }
tbody.dicht tr.groepkop .pijl { transform: rotate(-90deg); }
tbody.dicht tr.lid { display: none; }
tr.groepkop .aantal { font-weight: 400; color: var(--text-muted, #64748b); font-size: .88em; }
tr.groepkop .mini { margin-left: auto; font-weight: 400; font-size: .85em; color: var(--text-muted, #64748b); }
.mini b.g, .samenvatting b.g { color: #15803d; }
.mini b.r, .samenvatting b.r { color: #b91c1c; }
.samenvatting { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-top: 12px; font-size: .92em; }
#bewaard { color: #15803d; font-size: .85em; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>✅ Aanwezigheden · {{ school_naam }}</h2>
<div class="weeknav">
<a class="btn btn-light btn-sm" href="{{ url_for('internaat.aanwezigheden', week=vorige.isoformat()) }}" onclick="showLoader()" aria-label="Vorige week">←</a>
<div class="label">
<strong>{{ dagen[0].day }} {{ ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][dagen[0].month - 1] }} –
{{ dagen[6].day }} {{ ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][dagen[6].month - 1] }}</strong>
<span>{{ 'Deze week' if dagen[0] <= vandaag <= dagen[6] else 'Week ' ~ weeknr }}</span>
</div>
<a class="btn btn-light btn-sm" href="{{ url_for('internaat.aanwezigheden', week=volgende.isoformat()) }}" onclick="showLoader()" aria-label="Volgende week">→</a>
{% if not (dagen[0] <= vandaag <= dagen[6]) %}
<a class="btn btn-secondary btn-sm" href="{{ url_for('internaat.aanwezigheden') }}" onclick="showLoader()">Vandaag</a>
{% endif %}
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not groepen %}
<p class="muted">Er zijn deze week geen internen ingeschreven in Informat.</p>
{% else %}
<div class="werkbalk">
<div class="links">
<input type="search" id="zoek" placeholder="Zoek een intern..." aria-label="Zoek een intern">
{% if groepen|length > 1 %}
<select id="groepFilter" aria-label="Groep">
<option value="">Alle groepen</option>
{% for g in groepen %}<option>{{ g }}</option>{% endfor %}
</select>
<button type="button" class="btn-light btn-sm" id="allesKlappen">Alles inklappen</button>
{% endif %}
<a class="btn btn-light btn-sm" id="exportKnop" href="{{ url_for('internaat.aanwezigheden_export', week=maandag.isoformat()) }}">⬇️ Excel</a>
</div>
<div class="legende">
<span><i style="background:#16a34a"></i>Aanwezig</span>
<span><i style="background:#dc2626"></i>Afwezig</span>
<span><i style="background:#f1f5f9;border:1px solid #cbd5e1"></i>Nog niet gecontroleerd</span>
</div>
</div>
<div class="aw-wrap">
<table class="aw">
<thead>
<tr>
<th class="naam" rowspan="2">Intern</th>
{% for d in dagen %}
<th class="dag {{ 'vandaag' if d == vandaag }}" colspan="2">{{ dagnamen[loop.index0] }}<small>{{ d.strftime('%d/%m') }}</small></th>
{% endfor %}
</tr>
<tr>
{% for d in dagen %}
<th class="sessie {{ 'vandaag' if d == vandaag }}" title="Ochtend">O</th>
<th class="sessie {{ 'vandaag' if d == vandaag }}" title="Avond">A</th>
{% endfor %}
</tr>
</thead>
{% for groep, leden in groepen.items() %}
<tbody class="groep" data-groep="{{ groep }}">
<tr class="groepkop"><td colspan="15">
<button type="button" aria-expanded="true"><span class="pijl">▾</span> {{ groep }}
<span class="aantal">{{ leden|length }} intern{{ 'en' if leden|length != 1 }}</span>
<span class="mini"></span></button>
</td></tr>
{% for r in leden %}
<tr class="lid" data-zoek="{{ (r.naam ~ ' ' ~ r.voornaam ~ ' ' ~ r.voornaam ~ ' ' ~ r.naam)|lower }}">
<td class="naam"><strong>{{ r.naam }} {{ r.voornaam }}</strong><span>{{ r.kamer or r.vestiging }}</span></td>
{% for d in dagen %}{% set i = loop.index0 %}
{% for m in ('ochtend', 'avond') %}
{% set st = data.get(r.pid ~ ':' ~ i ~ ':' ~ m, 'leeg') %}
<td class="cel {{ 'vandaag' if d == vandaag }}">
<button type="button" class="st {{ st }}" data-status="{{ st }}" data-datum="{{ d.isoformat() }}"
data-persoon="{{ r.pid }}" data-moment="{{ m }}" data-naam="{{ r.naam }} {{ r.voornaam }}"
aria-label="{{ r.voornaam }} {{ r.naam }}, {{ dagnamen_voluit[i] }} {{ m }}: {{ {'aanwezig': 'aanwezig', 'afwezig': 'afwezig'}.get(st, 'nog niet gecontroleerd') }}. Klik om te wijzigen."><span>{{ {'aanwezig': '✓', 'afwezig': '✕'}.get(st, '·') }}</span></button>
</td>
{% endfor %}{% endfor %}
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
<div class="samenvatting" aria-live="polite">
<span><b class="g" id="sAanwezig">0</b> aanwezig</span>
<span><b class="r" id="sAfwezig">0</b> afwezig</span>
<span><b id="sLeeg">0</b> nog te controleren</span>
<span id="bewaard"></span>
</div>
<p class="muted small" style="margin-top:12px">Klik op een vakje om te wisselen: nog niet gecontroleerd → aanwezig → afwezig.
Alles wordt meteen bewaard en is zichtbaar voor alle collega's met dit recht. De internen komen uit Informat.</p>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const tabel = document.querySelector('table.aw');
if (!tabel) return;
const VOLGORDE = ['leeg', 'aanwezig', 'afwezig'], TEKEN = { leeg: '·', aanwezig: '✓', afwezig: '✕' };
const LABEL = { leeg: 'nog niet gecontroleerd', aanwezig: 'aanwezig', afwezig: 'afwezig' };
const zoekveld = document.getElementById('zoek'), groepSel = document.getElementById('groepFilter');
const exp = document.getElementById('exportKnop'), basisExport = exp.href;
let zoek = '', groep = '', bewaardTimer = null;
const SLEUTEL = 'aw-dicht';
let dicht = {};
try { dicht = JSON.parse(localStorage.getItem(SLEUTEL) || '{}'); } catch (e) {}
function tel(knoppen) {
const t = { aanwezig: 0, afwezig: 0, leeg: 0 };
knoppen.forEach(b => t[b.dataset.status]++);
return t;
}
function zichtbareRijen(tbody) {
return Array.from(tbody.querySelectorAll('tr.lid')).filter(tr => tr.style.display !== 'none');
}
function herteken() {
let alles = [];
document.querySelectorAll('tbody.groep').forEach(tb => {
const toon = !groep || tb.dataset.groep === groep;
let zichtbaar = 0;
tb.querySelectorAll('tr.lid').forEach(tr => {
const ok = toon && (!zoek || tr.dataset.zoek.includes(zoek));
tr.style.display = ok ? '' : 'none';
if (ok) zichtbaar++;
});
tb.style.display = toon && zichtbaar ? '' : 'none';
tb.classList.toggle('dicht', !!dicht[tb.dataset.groep] && !zoek);
tb.querySelector('tr.groepkop button').setAttribute('aria-expanded', String(!tb.classList.contains('dicht')));
const knoppen = zichtbareRijen(tb).flatMap(tr => Array.from(tr.querySelectorAll('button.st')));
const t = tel(knoppen);
tb.querySelector('.mini').innerHTML = `<b class="g">${t.aanwezig}</b> · <b class="r">${t.afwezig}</b> · <b>${t.leeg}</b>`;
alles = alles.concat(knoppen);
});
const t = tel(alles);
document.getElementById('sAanwezig').textContent = t.aanwezig;
document.getElementById('sAfwezig').textContent = t.afwezig;
document.getElementById('sLeeg').textContent = t.leeg;
const u = new URL(basisExport);
if (groep) u.searchParams.set('groep', groep); if (zoek) u.searchParams.set('zoek', zoek);
exp.href = u.toString();
const ak = document.getElementById('allesKlappen');
if (ak) ak.textContent = Array.from(document.querySelectorAll('tbody.groep')).every(tb => dicht[tb.dataset.groep]) ? 'Alles uitklappen' : 'Alles inklappen';
}
function bewaarDicht() { try { localStorage.setItem(SLEUTEL, JSON.stringify(dicht)); } catch (e) {} }
tabel.addEventListener('click', e => {
const kop = e.target.closest('tr.groepkop button');
if (kop) {
const g = kop.closest('tbody').dataset.groep;
dicht[g] = !dicht[g]; bewaarDicht(); herteken();
return;
}
const b = e.target.closest('button.st');
if (!b || b.classList.contains('bezig')) return;
const oud = b.dataset.status, nieuw = VOLGORDE[(VOLGORDE.indexOf(oud) + 1) % VOLGORDE.length];
const zet = st => {
b.dataset.status = st; b.className = 'st ' + st;
b.innerHTML = `<span>${TEKEN[st]}</span>`;
b.setAttribute('aria-label', b.getAttribute('aria-label').replace(/: [^:.]+\. Klik/, `: ${LABEL[st]}. Klik`));
};
zet(nieuw); b.classList.add('bezig'); herteken();
fetch({{ url_for('internaat.aanwezigheid_zet')|tojson }}, {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ datum: b.dataset.datum, persoon: b.dataset.persoon, moment: b.dataset.moment, status: nieuw, naam: b.dataset.naam })
}).then(r => r.json()).then(res => {
b.classList.remove('bezig');
if (!res.ok) { zet(oud); herteken(); alert(res.fout || 'Bewaren mislukt.'); return; }
const n = document.getElementById('bewaard'); n.textContent = 'Opgeslagen';
clearTimeout(bewaardTimer); bewaardTimer = setTimeout(() => n.textContent = '', 1200);
}).catch(() => { b.classList.remove('bezig'); zet(oud); herteken(); alert('Bewaren mislukt: geen verbinding met PIVOT.'); });
});
zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); herteken(); });
if (groepSel) groepSel.addEventListener('change', () => { groep = groepSel.value; herteken(); });
const ak = document.getElementById('allesKlappen');
if (ak) ak.addEventListener('click', () => {
const tbs = Array.from(document.querySelectorAll('tbody.groep'));
const allesDicht = tbs.every(tb => dicht[tb.dataset.groep]);
tbs.forEach(tb => dicht[tb.dataset.groep] = !allesDicht);
bewaarDicht(); herteken();
});
herteken();
})();
</script>
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Gebruikers & rechten{% endblock %}
{% block page_title %}Gebruikers &amp; rechten{% endblock %}
{% block extra_css %}
<style>
.school-chip { display: inline-block; margin: 2px 4px 2px 0; }
.user-row-inactief td { opacity: .55; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🔧 PIVOT-gebruikers</h2>
<div class="actions">
<button type="button" class="btn-success" onclick="openModal()">➕ Nieuwe gebruiker</button>
</div>
</div>
<p class="muted">Eén account per persoon, voor alle scholen. Per school vink je aan op welke onderdelen iemand rechten heeft.
{% if is_admin %}Een <strong>administrator</strong> heeft altijd alle rechten in alle scholen.{% endif %}</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Gebruiker</th>
<th>Rol</th>
<th>Rechten per school</th>
<th>Laatste login</th>
<th class="actions">Beheren</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="{{ '' if u.Actief else 'user-row-inactief' }}">
<td>
<strong>{{ u.Gebruikersnaam }}</strong>
{% if not u.Actief %}<span class="badge badge-warning">uitgeschakeld</span>{% endif %}
{% if u.Emailadres %}<div class="muted small">{{ u.Emailadres }}</div>{% endif %}
</td>
<td><span class="badge {{ 'badge-danger' if u.Veiligheidsgroep == ADMIN else 'badge-info' }}">{{ u.Rol }}</span></td>
<td>
{% if u.Veiligheidsgroep == ADMIN %}
<span class="muted small">Alle rechten in alle scholen</span>
{% else %}
{% for naam, n in u.scholen[:3] %}
<span class="badge school-chip" title="{{ n }} van {{ totaal_onderdelen }} onderdelen">🏫 {{ naam }} · {{ n }}</span>
{% else %}
<span class="muted small">Nog geen rechten</span>
{% endfor %}
{% if u.scholen|length > 3 %}
<a class="badge school-chip" href="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID) }}"
title="{% for naam, n in u.scholen[3:] %}{{ naam }} · {{ n }}{% if not loop.last %}&#10;{% endif %}{% endfor %}">+{{ u.scholen|length - 3 }} scholen</a>
{% endif %}
{% endif %}
</td>
<td class="nowrap small">{{ u.Last_login.strftime('%d-%m-%Y %H:%M') if u.Last_login else '–' }}</td>
<td class="actions">
<div style="display:inline-flex;gap:6px;justify-content:flex-end;white-space:nowrap">
{% if u.Veiligheidsgroep != ADMIN %}
<a class="btn btn-sm btn-secondary" href="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID) }}">🔐 Rechten</a>
{% endif %}
<a class="btn btn-sm btn-warning" href="{{ url_for('admin.gebruiker', gid=u.Gebruiker_ID) }}">👤 Account</a>
</div>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Nog geen gebruikers.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block modals %}
<div id="addUserModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<h3>Nieuwe gebruiker</h3>
<form method="POST" action="{{ url_for('admin.nieuw') }}">
<div class="field">
<label for="username">Gebruikersnaam</label>
<input type="text" id="username" name="username" placeholder="Bijv. jansen_j" required autocomplete="off">
</div>
<div class="field">
<label for="email">E-mailadres <span class="muted small">(optioneel – om met Google in te loggen)</span></label>
<input type="email" id="email" name="email" placeholder="voornaam.naam@school.be" autocomplete="off">
</div>
<div class="field">
<label for="password">Wachtwoord <span class="muted small">(leeg = enkel Google-login)</span></label>
<div class="password-wrap">
<input type="password" id="password" name="password" placeholder="Minstens 8 tekens" autocomplete="new-password">
<button type="button" class="password-toggle" onclick="togglePassword('password', this)">👁️</button>
</div>
</div>
<div class="field">
<label for="password_confirm">Wachtwoord bevestigen</label>
<div class="password-wrap">
<input type="password" id="password_confirm" name="password_confirm" placeholder="Wachtwoord" autocomplete="new-password">
<button type="button" class="password-toggle" onclick="togglePassword('password_confirm', this)">👁️</button>
</div>
</div>
{% if is_admin %}
<div class="field">
<label for="veiligheidsgroep">Rol</label>
<select id="veiligheidsgroep" name="veiligheidsgroep">
{% for nr, naam in groepen.items() %}
<option value="{{ nr }}" {% if nr != ADMIN %}selected{% endif %}>{{ naam }}</option>
{% endfor %}
</select>
<div class="field-hint">Een gebruiker krijgt enkel de rechten die je daarna per school aanvinkt.</div>
</div>
{% endif %}
<button type="submit" class="btn-block btn-success">💾 Aanmaken en rechten instellen</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var modal = document.getElementById("addUserModal");
function openModal() { modal.style.display = "block"; }
function closeModal() { modal.style.display = "none"; }
window.onclick = function (event) { if (event.target == modal) closeModal(); };
function togglePassword(fieldId, button) {
var f = document.getElementById(fieldId);
f.type = f.type === "password" ? "text" : "password";
button.textContent = f.type === "password" ? "👁️" : "🙈";
}
{% if request.args.get('nieuw') %}openModal();{% endif %}
</script>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ u.Gebruikersnaam }} – account{% endblock %}
{% block page_title %}Gebruiker: {{ u.Gebruikersnaam }}{% endblock %}
{% block extra_css %}
<style>
.account-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 18px; }
.terug { margin-bottom: 14px; display: inline-block; }
</style>
{% endblock %}
{% block content %}
<a class="terug" href="{{ url_for('admin.settings') }}">← Alle gebruikers</a>
{# ---------------- Account ---------------- #}
<div class="card">
<div class="page-head">
<h2>👤 Account van {{ u.Gebruikersnaam }}</h2>
{% if u.Veiligheidsgroep != ADMIN %}
<a class="btn btn-sm btn-secondary" href="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID) }}">🔐 Rechten</a>
{% endif %}
</div>
<form method="POST" action="{{ url_for('admin.gebruiker', gid=u.Gebruiker_ID) }}" autocomplete="off">
<input type="hidden" name="actie" value="account">
<div class="account-grid">
<div class="field">
<label>Gebruikersnaam</label>
<input type="text" value="{{ u.Gebruikersnaam }}" disabled>
</div>
<div class="field">
<label for="email">E-mailadres (Google-login)</label>
<input type="email" id="email" name="email" value="{{ u.Emailadres or '' }}" placeholder="leeg = geen Google-login">
</div>
{% if is_admin %}
<div class="field">
<label for="veiligheidsgroep">Rol</label>
<select id="veiligheidsgroep" name="veiligheidsgroep" {% if zelf %}disabled{% endif %}>
{% for nr, naam in groepen.items() %}
<option value="{{ nr }}" {% if nr == u.Veiligheidsgroep %}selected{% endif %}>{{ naam }}</option>
{% endfor %}
</select>
{% if zelf %}<input type="hidden" name="veiligheidsgroep" value="{{ u.Veiligheidsgroep }}">{% endif %}
</div>
{% endif %}
<div class="field">
<label for="new_password">Nieuw wachtwoord</label>
<input type="password" id="new_password" name="new_password" placeholder="leeg = ongewijzigd" autocomplete="new-password">
<div class="field-hint">Minstens {{ min_wachtwoord }} tekens.</div>
</div>
<div class="field">
<label for="new_password_confirm">Nieuw wachtwoord bevestigen</label>
<input type="password" id="new_password_confirm" name="new_password_confirm" autocomplete="new-password">
</div>
<div class="field">
<label>&nbsp;</label>
<label class="check-box"><input type="checkbox" name="actief" value="1" style="width:auto"
{% if u.Actief %}checked{% endif %} {% if zelf %}disabled{% endif %}> Account is actief</label>
{% if zelf %}<input type="hidden" name="actief" value="1">{% endif %}
</div>
</div>
<div class="muted small" style="margin-bottom:12px">
Laatste login: {{ u.Last_login.strftime('%d-%m-%Y %H:%M') if u.Last_login else 'nog nooit' }}{% if u.IP_laatste_login %} vanaf {{ u.IP_laatste_login }}{% endif %}
</div>
<button type="submit" class="btn-warning">💾 Account opslaan</button>
</form>
{% if is_admin and not zelf %}
<form method="POST" action="{{ url_for('admin.verwijderen', gid=u.Gebruiker_ID) }}" style="margin-top:18px"
onsubmit="return confirm('Gebruiker {{ u.Gebruikersnaam }} en al zijn rechten verwijderen?')">
<button type="submit" class="btn-danger btn-sm">🗑️ Gebruiker verwijderen</button>
</form>
{% endif %}
</div>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}{{ u.Gebruikersnaam }} – rechten{% endblock %}
{% block page_title %}Gebruiker: {{ u.Gebruikersnaam }}{% endblock %}
{% block extra_css %}
<style>
.terug { margin-bottom: 14px; display: inline-block; }
.rechten-layout { display: grid; grid-template-columns: minmax(240px, 320px) 1fr; gap: 20px; align-items: start; }
.scholen-paneel { border: 1px solid var(--border); border-radius: var(--radius-lg, 12px); overflow: hidden;
position: sticky; top: 12px; }
.scholen-paneel .zoek { padding: 10px; border-bottom: 1px solid var(--border); background: var(--surface-muted); }
.scholen-paneel .zoek input[type=search] { width: 100%; }
.scholen-paneel .zoek label { font-size: .85rem; margin-top: 8px; display: flex; gap: 6px; align-items: center; font-weight: 400; }
.scholen-lijst { list-style: none; margin: 0; padding: 0; max-height: 62vh; overflow-y: auto; }
.scholen-lijst a { display: flex; justify-content: space-between; gap: 8px; padding: 9px 12px; text-decoration: none;
color: inherit; border-bottom: 1px solid var(--border); font-size: .92rem; }
.scholen-lijst a:hover { background: var(--primary-light); }
.scholen-lijst a.actief { background: var(--primary-light); border-left: 3px solid var(--primary-color); font-weight: 700; }
.scholen-lijst a.leeg { color: var(--text-muted, #64748b); }
.scholen-lijst .teller { white-space: nowrap; font-size: .8rem; font-weight: 600; color: var(--primary-color); }
.scholen-lijst a.leeg .teller { color: var(--text-muted, #94a3b8); font-weight: 400; }
.scholen-lijst .geen { padding: 12px; color: var(--text-muted, #64748b); font-size: .9rem; }
.school-select { display: none; margin-bottom: 14px; }
.cat { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 12px; }
.cat-kop { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px;
background: var(--surface-muted); border-bottom: 1px solid var(--border); border-radius: 10px 10px 0 0; }
.cat-kop strong { color: var(--primary-color); font-size: .92rem; }
.cat-kop .knoppen button, .globaal button { padding: 2px 10px; font-size: 12px; }
.vinkjes { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 2px 16px; padding: 8px 12px; }
.vinkjes label { display: flex; gap: 8px; align-items: center; font-weight: 400; padding: 5px 0; cursor: pointer; margin: 0; }
.vinkjes input { width: 17px; height: 17px; flex: none; }
.globaal { display: flex; gap: 6px; align-items: center; }
details.ook { border: 1px dashed var(--border); border-radius: 10px; padding: 10px 12px; margin: 14px 0; }
details.ook summary { cursor: pointer; font-weight: 600; }
.ook-lijst { max-height: 260px; overflow-y: auto; margin-top: 8px; columns: 2 260px; }
.ook-lijst label { display: flex; gap: 8px; align-items: center; font-weight: 400; margin: 0; padding: 3px 0; break-inside: avoid; }
.ook-lijst input, details.ook .alle input { width: auto; }
@media (max-width: 760px) {
.rechten-layout { grid-template-columns: 1fr; }
.scholen-paneel { display: none; }
.school-select { display: block; }
}
</style>
{% endblock %}
{% block content %}
<a class="terug" href="{{ url_for('admin.settings') }}">← Alle gebruikers</a>
<div class="card">
<div class="page-head">
<h2>🔐 Rechten van {{ u.Gebruikersnaam }}</h2>
<a class="btn btn-sm btn-light" href="{{ url_for('admin.gebruiker', gid=u.Gebruiker_ID) }}">👤 Account</a>
</div>
{% if u.Veiligheidsgroep == ADMIN %}
<div class="alert alert-info">{{ u.Gebruikersnaam }} is <strong>administrator</strong> en heeft daardoor altijd alle rechten in alle scholen.
De vinkjes hieronder gelden pas als de rol ooit "Gebruiker" wordt.</div>
{% endif %}
{% if not scholen %}
<p class="muted">Je mag in geen enkele school rechten instellen.</p>
{% else %}
<div class="rechten-layout">
{# ---------- Scholen ---------- #}
<div class="scholen-paneel">
<div class="zoek">
<input type="search" id="zoekSchool" placeholder="🔍 Zoek school… ({{ scholen|length }})" oninput="filterScholen()">
<label><input type="checkbox" id="enkelMet" onchange="filterScholen()" style="width:auto"> Enkel scholen met rechten</label>
</div>
<ul class="scholen-lijst" id="scholenLijst">
{% for s in scholen %}
{% set n = aantallen.get(s.id, 0) %}
<li data-naam="{{ s.naam|lower }}" data-n="{{ n }}">
<a href="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID, school=s.id) }}"
class="{{ 'actief' if gekozen and s.id == gekozen.id else '' }} {{ 'leeg' if not n else '' }}">
<span>{{ s.naam }}{% if not s.actief %} <span class="badge badge-warning">inactief</span>{% endif %}</span>
<span class="teller">{{ n }}/{{ totaal }}</span>
</a>
</li>
{% endfor %}
<li class="geen" id="geenScholen" style="display:none">Geen scholen gevonden.</li>
</ul>
</div>
{# ---------- Rechten van de gekozen school ---------- #}
<div>
<select class="school-select" onchange="location.href=this.value" aria-label="School">
{% for s in scholen %}
<option value="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID, school=s.id) }}"
{% if gekozen and s.id == gekozen.id %}selected{% endif %}>{{ s.naam }} ({{ aantallen.get(s.id, 0) }}/{{ totaal }})</option>
{% endfor %}
</select>
{% if gekozen %}
<form method="POST" action="{{ url_for('admin.gebruiker_rechten', gid=u.Gebruiker_ID) }}" id="rechtenForm"
onsubmit="return bevestig()">
<input type="hidden" name="school_id" value="{{ gekozen.id }}">
<div class="page-head" style="margin-bottom:10px">
<h3 style="margin:0">🏫 {{ gekozen.naam }}</h3>
<div class="globaal">
<span class="muted small" id="teller"></span>
<button type="button" class="btn-light" onclick="zet(null, true)">alles</button>
<button type="button" class="btn-light" onclick="zet(null, false)">niets</button>
</div>
</div>
<p class="muted small" style="margin-top:0">Zonder vinkje in deze school kan {{ u.Gebruikersnaam }} er niet inloggen.</p>
{% for cat, items in categorieen.items() %}
<div class="cat">
<div class="cat-kop">
<strong>{{ cat }}</strong>
<span class="knoppen">
<button type="button" class="btn-light" onclick="zet('{{ loop.index }}', true)">alles</button>
<button type="button" class="btn-light" onclick="zet('{{ loop.index }}', false)">niets</button>
</span>
</div>
<div class="vinkjes">
{% set ci = loop.index %}
{% for code, naam in items %}
<label><input type="checkbox" name="r|{{ code }}" value="1" data-cat="{{ ci }}" onchange="tel()"
{% if code in huidig %}checked{% endif %}> {{ naam }}</label>
{% endfor %}
</div>
</div>
{% endfor %}
{% if scholen|length > 1 %}
<details class="ook">
<summary>📋 Deze rechten ook toepassen op andere scholen…</summary>
<p class="field-hint">De aangevinkte scholen krijgen exact dezelfde rechten als {{ gekozen.naam }}. Wat {{ u.Gebruikersnaam }} daar nu mag, wordt overschreven.</p>
<label class="alle" style="font-weight:600;display:flex;gap:8px;align-items:center">
<input type="checkbox" name="ook_alle" value="1" id="ookAlle" onchange="ookAlleWissel()"> Alle {{ scholen|length }} scholen</label>
<input type="search" placeholder="🔍 Zoek school…" oninput="filterOok(this.value)" style="margin-top:8px">
<div class="ook-lijst" id="ookLijst">
{% for s in scholen if s.id != gekozen.id %}
<label data-naam="{{ s.naam|lower }}"><input type="checkbox" name="ook" value="{{ s.id }}"> {{ s.naam }}
<span class="muted small">({{ aantallen.get(s.id, 0) }})</span></label>
{% endfor %}
</div>
</details>
{% endif %}
<button type="submit" class="btn-success">💾 Rechten opslaan</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function vinkjes(cat) {
var sel = '#rechtenForm .vinkjes input' + (cat ? '[data-cat="' + cat + '"]' : '');
return document.querySelectorAll(sel);
}
function zet(cat, aan) { vinkjes(cat).forEach(function (cb) { cb.checked = aan; }); tel(); }
function tel() {
var t = document.getElementById('teller'); if (!t) return;
var alle = vinkjes(null), n = 0;
alle.forEach(function (cb) { if (cb.checked) n++; });
t.textContent = n + ' van ' + alle.length + ' aangevinkt';
}
function filterScholen() {
var q = document.getElementById('zoekSchool').value.trim().toLowerCase();
var enkel = document.getElementById('enkelMet').checked, zichtbaar = 0;
document.querySelectorAll('#scholenLijst li[data-naam]').forEach(function (li) {
var ok = li.dataset.naam.indexOf(q) !== -1 && (!enkel || li.dataset.n !== '0');
li.style.display = ok ? '' : 'none'; if (ok) zichtbaar++;
});
document.getElementById('geenScholen').style.display = zichtbaar ? 'none' : '';
}
function filterOok(q) {
q = q.trim().toLowerCase();
document.querySelectorAll('#ookLijst label').forEach(function (l) {
l.style.display = l.dataset.naam.indexOf(q) !== -1 ? '' : 'none';
});
}
function ookAlleWissel() {
var aan = document.getElementById('ookAlle').checked;
document.querySelectorAll('#ookLijst input').forEach(function (cb) { cb.checked = aan; cb.disabled = aan; });
}
function bevestig() {
var alle = document.getElementById('ookAlle');
var n = (alle && alle.checked) ? {{ scholen|length - 1 }} : document.querySelectorAll('#ookLijst input:checked').length;
if (!n) { if (typeof showLoader === 'function') showLoader(); return true; }
var ok = confirm('Dezelfde rechten ook instellen in ' + n + ' andere school/scholen? Wat daar nu aangevinkt is, wordt overschreven.');
if (ok && typeof showLoader === 'function') showLoader();
return ok;
}
tel();
// gekozen school in beeld scrollen
var actief = document.querySelector('#scholenLijst a.actief');
if (actief) { var l = document.getElementById('scholenLijst'); l.scrollTop = actief.parentNode.offsetTop - l.clientHeight / 2; }
</script>
{% endblock %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}Afwezigheden{% endblock %}
{% block page_title %}Afwezigheden ({{ codes|join('/') }}-codes){% endblock %}
{% block extra_css %}
<style>
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat { padding: 18px 20px; }
.stat + .stat { margin-top: 0; }
.stat .val { font-size: 28px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .lbl { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; margin-top: 4px; }
.stat .sub { font-size: 13px; color: var(--text-muted); }
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; align-items: end; }
.filter-grid .wide { grid-column: span 2; }
.filter-grid label { display: block; margin-bottom: 6px; }
.filter-actions { display: flex; gap: 8px; }
.filter-actions > * { flex: 1; }
tr.lln { cursor: pointer; }
tr.lln td:first-child::before { content: "▸"; display: inline-block; width: 16px; color: var(--text-muted); transition: transform .15s; }
tr.lln.open td:first-child::before { transform: rotate(90deg); }
tr.detail td { background: var(--surface-muted); padding: 10px 14px 14px 44px; }
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; border-radius: 999px; background: #fff;
border: 1px solid var(--border); font-size: 13px; }
.day-chip .dd { font-weight: 700; color: var(--danger-text); }
.count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700;
background: var(--danger-bg); color: var(--danger-text); }
.count-badge.lvl1 { background: var(--warning-bg); color: var(--warning-text); }
.sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.progress { flex: 1 1 200px; height: 8px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
</style>
{% endblock %}
{% block content %}
{# ---------- Kerncijfers ---------- #}
<div class="stats">
<div class="card stat">
<div class="val">{{ rows|length }}</div>
<div class="lbl">Leerlingen met {{ f.code or codes|join('/') }}</div>
</div>
<div class="card stat">
<div class="val">{{ totaal_halve_dagen }}</div>
<div class="lbl">Halve dagen</div>
{% if rows %}<div class="sub">gem. {{ '%.1f'|format(totaal_halve_dagen / rows|length) }} per leerling</div>{% endif %}
</div>
<div class="card stat">
<div class="val" style="font-size:18px; padding-top:6px">
{% if laatste.m %}{{ laatste.m.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}
</div>
<div class="lbl">Laatst gesynchroniseerd</div>
<div class="sub">Automatisch elke ochtend</div>
</div>
</div>
{% if fout %}<div class="flash-message error"><span>{{ fout }}</span></div>{% endif %}
{% if not laatste.m and not status.bezig %}
<div class="alert alert-warning">
Er zijn nog geen gegevens opgehaald uit Smartschool. Klik op <strong>Nu synchroniseren</strong> om het huidige schooljaar op te halen
(de eerste keer duurt dit enkele minuten).
</div>
{% endif %}
{# ---------- Synchronisatie ---------- #}
<div class="card" id="syncCard">
<div class="sync-bar">
<form method="POST" action="{{ url_for('afwezigheden.sync') }}" style="margin:0">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig %}disabled{% endif %}>🔄 Nu synchroniseren</button>
</form>
<div class="progress {% if not status.bezig %}hidden{% endif %}" id="syncProgress"><span id="syncProgressBar"></span></div>
<span class="muted small" id="syncText">
{% if status.bezig %}Bezig…{% elif status.laatste_resultaat %}{{ status.laatste_resultaat }}{% endif %}
</span>
</div>
</div>
{# ---------- Filters ---------- #}
<div class="card">
<form method="GET" action="{{ url_for('afwezigheden.overzicht') }}">
<div class="filter-grid">
<div class="wide">
<label for="zoek">Leerling</label>
<input type="search" id="zoek" name="zoek" value="{{ f.zoek }}" placeholder="Naam, gebruikersnaam of internnummer">
</div>
<div>
<label for="van">Van</label>
<input type="date" id="van" name="van" value="{{ f.van.isoformat() }}">
</div>
<div>
<label for="tot">Tot en met</label>
<input type="date" id="tot" name="tot" value="{{ f.tot.isoformat() }}">
</div>
<div>
<label for="min">Minimum halve dagen</label>
<input type="number" id="min" name="min" min="1" value="{{ f.minimum }}">
</div>
{% if codes|length > 1 %}
<div>
<label for="code">Code</label>
<select id="code" name="code">
<option value="">Alle ({{ codes|join(', ') }})</option>
{% for c in codes %}<option value="{{ c }}" {% if f.code == c %}selected{% endif %}>{{ c }}</option>{% endfor %}
</select>
</div>
{% endif %}
<div class="filter-actions">
<button type="submit">🔍 Filteren</button>
<a class="btn btn-light" href="{{ url_for('afwezigheden.overzicht') }}">Wissen</a>
</div>
</div>
</form>
</div>
{# ---------- Ranglijst ---------- #}
<div class="card">
<div class="page-head">
<h2>📋 Leerlingen</h2>
<div class="actions">
{% if rows %}<a class="btn btn-light" href="{{ url_for('afwezigheden.export_csv', **request.args) }}">⬇️ Export CSV</a>{% endif %}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Leerling</th>
<th>Internnummer</th>
<th class="center">Halve dagen</th>
<th class="center">Dagen</th>
<th>Eerste</th>
<th>Laatste</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="lln" data-intern="{{ r.Internnummer }}" onclick="toggleDetail(this)">
<td><strong>{% if r.Naam or r.Voornaam %}{{ r.Naam }} {{ r.Voornaam }}{% else %}<span class="muted">Onbekend in PIVOT</span>{% endif %}</strong>
{% if r.Gebruikersnaam %}<span class="muted small"> · {{ r.Gebruikersnaam }}</span>{% endif %}</td>
<td>{{ r.Internnummer }}</td>
<td class="center"><span class="count-badge {{ 'lvl1' if r.Halve_dagen < 5 else '' }}">{{ r.Halve_dagen }}</span></td>
<td class="center">{{ r.Dagen }}</td>
<td class="nowrap">{{ r.Eerste.strftime('%d-%m-%Y') }}</td>
<td class="nowrap">{{ r.Laatste.strftime('%d-%m-%Y') }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">
{% if laatste.m %}Geen leerlingen met {{ f.code or codes|join('/') }}-codes in deze periode.{% else %}Nog geen gegevens.{% endif %}
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint">Klik op een leerling om de datums te zien. Vanaf 5 halve dagen kleurt het aantal rood.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
const filterQuery = {{ request.query_string.decode()|tojson }};
function toggleDetail(tr) {
const next = tr.nextElementSibling;
if (next && next.classList.contains('detail')) { next.remove(); tr.classList.remove('open'); return; }
tr.classList.add('open');
const row = document.createElement('tr');
row.className = 'detail';
row.innerHTML = '<td colspan="6" class="muted">Laden…</td>';
tr.after(row);
fetch(`{{ url_for('afwezigheden.overzicht') }}leerling/${encodeURIComponent(tr.dataset.intern)}.json?${filterQuery}`)
.then(r => r.json())
.then(dagen => {
const chips = dagen.map(d => {
const deel = d.vm && d.nm ? 'hele dag' : (d.vm ? 'VM' : 'NM');
return `<span class="day-chip">${d.weekdag} ${d.datum} <span class="dd">${deel}</span></span>`;
}).join('');
row.firstElementChild.className = '';
row.firstElementChild.innerHTML = `<div class="day-chips">${chips || 'Geen datums.'}</div>`;
})
.catch(() => { row.firstElementChild.textContent = 'Kon de datums niet laden.'; });
}
// Voortgang van een lopende synchronisatie volgen
(function pollSync() {
const bar = document.getElementById('syncProgressBar');
const box = document.getElementById('syncProgress');
const txt = document.getElementById('syncText');
const btn = document.getElementById('syncBtn');
let wasBusy = {{ 'true' if status.bezig else 'false' }};
function tick() {
fetch("{{ url_for('afwezigheden.status') }}").then(r => r.json()).then(s => {
if (s.bezig) {
wasBusy = true;
box.classList.remove('hidden'); btn.disabled = true;
const pct = s.totaal ? Math.round(s.voortgang / s.totaal * 100) : 0;
bar.style.width = pct + '%';
txt.textContent = `Bezig: dag ${s.voortgang} van ${s.totaal}…`;
setTimeout(tick, 2000);
} else if (wasBusy) {
window.location.reload(); // klaar → nieuwe gegevens tonen
}
}).catch(() => {});
}
if (wasBusy) tick();
})();
</script>
{% endblock %}
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PIVOT{% endblock %} – {{ school_name_short|upper or 'PIVOT' }}</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='pivot_mark.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}">
{% block head %}{% endblock %}
<style>.header-school { display: inline-block; max-width: 200px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }</style>
{% block extra_css %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{# ---------- Laadscherm (gedeeld) ---------- #}
<div id="loader-overlay" aria-live="polite">
<div class="spinner"></div>
<div class="loader-text" id="loader-text">{% block loader_text %}Verwerken...{% endblock %}</div>
{% block loader_sub %}{% endblock %}
</div>
{# ---------- Header ---------- #}
{% block header %}
<header class="app-header {% block header_class %}{% endblock %}">
<div class="header-left">
<a href="{{ url_for('main.dashboard') }}" class="brand" title="Naar het dashboard">
<span class="brand-mark"><img src="{{ url_for('static', filename='pivot_mark.png') }}" alt=""></span>
<span class="brand-name">PIVOT</span>
</a>
{% block header_nav %}
<a href="{{ url_for('main.dashboard') }}" class="home-btn">🏠 Home</a>
{% endblock %}
</div>
<h1 class="page-title">{% block page_title %}{% endblock %}</h1>
<div class="header-right">
{% if school %}
{% if is_superbeheerder %}
<a href="{{ url_for('scholen.overzicht') }}" class="header-user header-school" style="color:inherit;text-decoration:none" title="{{ school.naam }} – andere school kiezen">🏫 {{ school.naam }} ⇄</a>
{% elif andere_scholen %}
<select class="header-user header-school" title="Andere school kiezen" aria-label="School kiezen"
style="width:auto;color:inherit;background:transparent;border:0;cursor:pointer"
onchange="if (this.value) location.href = this.value">
<option value="" selected>🏫 {{ school.naam }} ⇄</option>
{% for s in andere_scholen %}
<option value="{{ url_for('scholen.wissel', code=s.code) }}" style="color:#1e293b">{{ s.naam }}</option>
{% endfor %}
</select>
{% else %}
<span class="header-user header-school" title="{{ school.naam }}">🏫 {{ school.naam }}</span>
{% endif %}
{% endif %}
{% if session.get('user') %}
<span class="header-user">👤 {{ session.get('user') }}</span>
{% endif %}
<form action="{{ url_for('auth.logout') }}" method="get">
<button type="submit" class="logout-btn">Uitloggen</button>
</form>
</div>
</header>
{% endblock %}
<main class="app-main {% block main_class %}{% endblock %}">
{# ---------- Flash-berichten (gedeeld) ---------- #}
{% block flashes %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="flash-message {{ category if category else 'info' }}" role="alert">
<span>{{ message }}</span>
<button type="button" class="flash-close" aria-label="Sluiten" onclick="this.parentElement.remove()">&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}
{% block content %}{% endblock %}
</main>
{% block modals %}{% endblock %}
{% block footer %}
<footer class="app-footer">
{% if school %}{{ school.naam }} – {% endif %}PIVOT – Versie {{ version }}
</footer>
{% endblock %}
<script>
/* Gedeelde helpers – beschikbaar op elke pagina */
/* Aantal rijen per pagina in tabellen: 10 / 25 / 50 / 100 / alles (0). Standaard 25 (of de opgegeven standaard).
De keuze wordt per tabel onthouden in deze browser. */
const PER_PAGINA_KEUZES = [10, 25, 50, 100, 0];
function perPagina(sleutel, standaard = 25) {
try {
const v = localStorage.getItem('pivot.perpagina.' + sleutel);
if (v !== null && PER_PAGINA_KEUZES.includes(+v)) return +v;
} catch (e) { /* geen opslag beschikbaar */ }
return standaard;
}
function perPaginaKiezer(container, sleutel, opWijzig, standaard = 25) {
if (!container) return;
const huidig = perPagina(sleutel, standaard);
container.innerHTML = '<label class="per-pagina">Toon <select aria-label="Aantal per pagina">'
+ PER_PAGINA_KEUZES.map(n => `<option value="${n}" ${n === huidig ? 'selected' : ''}>${n || 'alles'}</option>`).join('')
+ '</select> per pagina</label>';
container.querySelector('select').addEventListener('change', e => {
const n = +e.target.value;
try { localStorage.setItem('pivot.perpagina.' + sleutel, n); } catch (err) { /* negeren */ }
opWijzig(n);
});
}
function showLoader(text) {
if (text) document.getElementById('loader-text').innerText = text;
document.getElementById('loader-overlay').style.display = 'flex';
}
function hideLoader() {
document.getElementById('loader-overlay').style.display = 'none';
}
// Loader verbergen wanneer de pagina via 'terug' uit de cache komt
window.addEventListener('pageshow', function (e) { if (e.persisted) hideLoader(); });
// Succes- en infoberichten verdwijnen na 6 seconden; foutmeldingen blijven staan
setTimeout(function () {
document.querySelectorAll('.flash-message:not(.error):not(.danger)').forEach(function (f) {
f.style.transition = 'opacity .5s ease';
f.style.opacity = '0';
setTimeout(function () { f.remove(); }, 500);
});
}, 6000);
// Escape sluit open modals
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('.modal, .modal-overlay').forEach(function (m) {
if (m.style.display && m.style.display !== 'none') m.style.display = 'none';
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block title %}Bingel{% endblock %}
{% block page_title %}Bingel{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Leerlingen ophalen uit Informat...{% endblock %}
{% block extra_css %}
<style>
.balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.balk input[type=search] { flex: 0 1 300px; margin: 0; }
.balk select { width: auto; min-width: 180px; margin: 0; }
.tellers { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 6px; }
.tellers button { width: auto; margin: 0; padding: 4px 12px; border-radius: 999px; background: var(--surface-muted);
border: 1px solid var(--border); font-size: .88em; color: inherit; font-weight: 400; cursor: pointer; }
.tellers button:hover { border-color: var(--primary-color); }
.tellers button[aria-pressed="true"] { border-color: var(--primary-color); background: var(--primary-light); color: var(--primary-color); font-weight: 600; }
.tellers b { margin-left: 4px; }
.tellers .let { background: #fef3c7; border-color: #fcd34d; }
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.leeg { background: #fff7ed; }
.klaskeuze { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; margin: 10px 0; }
.klaskeuze-kop { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 8px; }
.klaskeuze-kop button { margin: 0; }
.klasvinken { display: flex; flex-wrap: wrap; gap: 6px 10px; }
.klasvinken label { display: inline-flex; gap: 6px; align-items: center; margin: 0; padding: 4px 10px; font-weight: 600;
border: 1px solid var(--border); border-radius: 999px; cursor: pointer; }
.klasvinken label:has(input:checked) { border-color: var(--primary-color); background: var(--primary-light); }
.klasvinken input { width: auto; margin: 0; }
.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 %}
<div class="card">
<div class="page-head">
<h2>📗 Bingel · {{ school_naam }}</h2>
<div class="actions">
{% if rijen %}<a class="btn btn-success btn-sm" id="exportKnop" href="{{ url_for('basisonderwijs.bingel_export') }}">⬇️ Excel alle klassen</a>{% endif %}
<a class="btn btn-secondary btn-sm" href="{{ url_for('basisonderwijs.bingel', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<p class="field-hint" style="margin-top:-6px">Alle leerlingen uit Informat, klaar voor de import in Bingel. Kolommen in de Excel:
{{ kolommen|join(', ') }}. Het wachtwoord blijft leeg; het klasnummer is alfabetisch binnen de klas (familienaam, voornaam).</p>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not rijen %}
<p class="muted">Informat gaf geen leerlingen terug.</p>
{% else %}
{% if waarschuwing %}<div class="alert alert-warning">{{ waarschuwing }}</div>{% endif %}
<div class="tellers" id="tellers">
<button type="button" data-filter="" aria-pressed="true" title="Alle leerlingen tonen">Leerlingen <b>{{ rijen|length }}</b></button>
<button type="button" data-filter="" title="Alle leerlingen tonen">Klassen <b>{{ klassen|length }}</b></button>
{% if zonder_klas %}<button type="button" class="let" data-filter="klas" aria-pressed="false" title="Enkel leerlingen zonder klas">Zonder klas <b>{{ zonder_klas }}</b></button>{% endif %}
{% if zonder_geslacht %}<button type="button" class="let" data-filter="geslacht" aria-pressed="false" title="Enkel leerlingen zonder geslacht M/V">Geslacht niet M/V <b>{{ zonder_geslacht }}</b></button>{% endif %}
</div>
<div class="klaskeuze">
<div class="klaskeuze-kop">
<strong>Klassen in de lijst</strong> <span class="muted small" id="klasTeller"></span>
<button type="button" class="btn-sm btn-light" id="allesAan">Alles aanvinken</button>
<button type="button" class="btn-sm btn-light" id="allesUit">Niets</button>
</div>
<div class="klasvinken" id="klasVinken">
{% for k in klassen %}
<label><input type="checkbox" value="{{ k }}" checked> {{ k or '(geen klas)' }}
<span class="muted small">{{ rijen|selectattr('klas', 'equalto', k)|list|length }}</span></label>
{% endfor %}
</div>
</div>
<div class="balk">
<input type="search" id="zoek" placeholder="Zoek op naam..." aria-label="Zoeken">
<span id="perPagina"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr>{% for k in kolommen %}<th class="{{ 'sorteer' if k != 'Wachtwoord' }}" {{ 'data-tekst' if k not in ('Klasnummer',) }}>{{ k }}{% if k != 'Wachtwoord' %} <span class="pijl"></span>{% endif %}</th>{% endfor %}</tr></thead>
<tbody id="body">
{% for r in rijen %}
<tr data-klas="{{ r.klas }}" data-geen="{{ ' '.join([('klas' if not r.klas else ''), ('geslacht' if r.geslacht not in ('M', 'V') else '')]) }}">
<td class="{{ 'leeg' if not r.klas }}" data-sort="{{ r.klas }}"><strong>{{ r.klas }}</strong></td>
<td data-sort="{{ r.voornaam }}">{{ r.voornaam }}</td>
<td data-sort="{{ r.naam }}">{{ r.naam }}</td>
<td class="muted"></td>
<td data-sort="{{ r.klasnummer if r.klasnummer is not none else '' }}">{{ r.klasnummer if r.klasnummer is not none else '' }}</td>
<td class="{{ 'leeg' if r.geslacht not in ('M', 'V') }}" data-sort="{{ r.geslacht }}">{{ r.geslacht }}</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>
{% if (zonder_klas or zonder_nummer or zonder_geslacht) and voorbeeld %}
<details style="margin-top:14px">
<summary class="muted small">Velden die Informat teruggeeft (om na te kijken waarom iets leeg blijft)</summary>
<p class="small" style="margin-top:6px"><strong>Leerling:</strong> <code>{{ voorbeeld.leerling|join(', ') }}</code></p>
<p class="small"><strong>Inschrijving:</strong> <code>{{ voorbeeld.inschrijving|join(', ') or '— geen inschrijvingen ontvangen —' }}</code></p>
{% if voorbeeld.klas_item %}<p class="small"><strong>Voorbeeld van een klas (inschrKlassen):</strong> <code>{{ voorbeeld.klas_item }}</code></p>{% endif %}
</details>
{% endif %}
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const body = document.getElementById('body');
if (!body) return;
const rijen = Array.from(body.rows), zoekVeld = document.getElementById('zoek');
const vinken = Array.from(document.querySelectorAll('#klasVinken input'));
const gekozen = () => new Set(vinken.filter(v => v.checked).map(v => v.value));
const exp = document.getElementById('exportKnop'), basis = exp.href;
let PER = perPagina('bingel', 25), pagina = 1, filter = '';
document.querySelectorAll('#tellers button').forEach(b => b.addEventListener('click', () => {
filter = b.dataset.filter;
document.querySelectorAll('#tellers button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
pagina = 1; toon();
}));
let kol = null, op = true;
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : true; kol = k;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
rijen.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();
}));
function toon() {
const k = gekozen(), z = zoekVeld.value.trim().toLowerCase();
const lijst = rijen.filter(tr => k.has(tr.dataset.klas) && (!z || tr.textContent.toLowerCase().includes(z))
&& (!filter || tr.dataset.geen.split(' ').includes(filter)));
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');
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 = (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); }
}
}
function klassenGewijzigd() {
const k = gekozen(), alle = k.size === vinken.length;
exp.href = alle ? basis : basis + '?' + Array.from(k).map(x => 'klas=' + encodeURIComponent(x)).join('&');
exp.textContent = alle ? '⬇️ Excel alle klassen' : (k.size === 1 ? '⬇️ Excel ' + Array.from(k)[0] : `⬇️ Excel ${k.size} klassen`);
exp.classList.toggle('disabled', !k.size);
exp.style.pointerEvents = k.size ? '' : 'none';
exp.style.opacity = k.size ? '' : '.5';
document.getElementById('klasTeller').textContent = `${k.size} van ${vinken.length} aangevinkt`;
pagina = 1; toon();
}
vinken.forEach(v => v.addEventListener('change', klassenGewijzigd));
document.getElementById('allesAan').onclick = () => { vinken.forEach(v => v.checked = true); klassenGewijzigd(); };
document.getElementById('allesUit').onclick = () => { vinken.forEach(v => v.checked = false); klassenGewijzigd(); };
zoekVeld.addEventListener('input', () => { pagina = 1; toon(); });
perPaginaKiezer(document.getElementById('perPagina'), 'bingel', n => { PER = n; pagina = 1; toon(); }, 25);
klassenGewijzigd();
})();
</script>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Bug rapporteren{% endblock %}
{% block page_title %}🐞 Bug rapporteren{% endblock %}
{% block header_class %}header--bug{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Bugreport verwerken...{% endblock %}
{% block extra_css %}
<style>
.info-box { margin-top: 14px; padding: 14px; border: 2px dashed var(--bug); border-radius: var(--radius); background: var(--success-bg); }
.info-box ul { padding-left: 20px; margin: 6px 0 0; }
</style>
{% endblock %}
{% block content %}
<div class="split">
<div class="card">
<h2 class="card-title">Fout of bug rapporteren</h2>
<form method="POST" action="{{ url_for('bugreport.bugreport_submit') }}" onsubmit="showLoader()">
<div class="field">
<label for="tijdstip">Tijdstip van de fout</label>
<input type="datetime-local" id="tijdstip" name="tijdstip" required>
</div>
<div class="field">
<label for="omschrijving">Omschrijving van de bug</label>
<textarea id="omschrijving" name="omschrijving" required placeholder="Wat ging er precies mis? Welke stappen heb je ondernomen?"></textarea>
</div>
<div class="field">
<label for="foutcode">Foutcode <span class="muted">(indien beschikbaar)</span></label>
<textarea id="foutcode" name="foutcode" placeholder="Bijv. Error 404, NullPointerException of een tekstuele foutmelding..."></textarea>
</div>
<button class="btn-block btn-lg btn-success" type="submit">Rapport indienen</button>
</form>
</div>
<div class="card">
<h2 class="card-title">ℹ️ Informatie</h2>
<p>Heeft de applicatie een probleem of werkt er iets niet naar behoren? Meld het direct aan het supportteam.</p>
<div class="info-box">
<strong>Automatische gegevens</strong>
<ul>
<li><strong>Gebruiker:</strong> wordt automatisch gekoppeld aan jouw account.</li>
<li><strong>Status:</strong> staat standaard op <span class="badge badge-info">Nieuw</span>.</li>
</ul>
</div>
<p class="small muted" style="margin-top: 14px;">
Probeer de omschrijving zo nauwkeurig mogelijk in te vullen, zodat we het probleem snel kunnen reproduceren en oplossen.
</p>
</div>
</div>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Bugoverzicht{% endblock %}
{% block page_title %}🐞 Bugoverzicht{% endblock %}
{% block header_class %}header--bug{% endblock %}
{% block main_class %}wide{% endblock %}
{% block extra_css %}
<style>
.text-wrap { max-width: 380px; white-space: pre-line; word-break: break-word; }
.foutcode-block { font-family: ui-monospace, Consolas, monospace; background: var(--surface-muted); border: 1px solid var(--border); padding: 3px 8px; border-radius: 4px; font-size: 12.5px; display: inline-block; max-width: 260px; word-break: break-all; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>Ingediende bugreports</h2>
<div class="actions">
<a class="btn btn-success" href="{{ url_for('bugreport.bugreport') }}">➕ Nieuwe bug melden</a>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Gebruiker</th>
<th>Datum / tijd</th>
<th>Omschrijving</th>
<th>Foutcode</th>
<th>Status</th>
<th class="actions">Actie</th>
</tr>
</thead>
<tbody>
{% for bug in results %}
<tr>
<td><strong>#{{ bug.Bug_ID }}</strong></td>
<td>{{ bug.Gebruiker }}</td>
<td class="nowrap">{{ bug.Datum.strftime('%d-%m-%Y %H:%M') if bug.Datum else '' }}</td>
<td class="text-wrap">{{ bug.Omschrijving }}</td>
<td>
{% if bug.Foutcode %}<span class="foutcode-block">{{ bug.Foutcode }}</span>
{% else %}<span class="muted"><em>Geen</em></span>{% endif %}
</td>
<td>
{% if bug.Status == 'Fixed' %}<span class="badge badge-success">✅ Fixed</span>
{% elif bug.Status == 'In behandeling' %}<span class="badge badge-warning">🧑‍💻 In behandeling</span>
{% else %}<span class="badge badge-info">ℹ️ Nieuw</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn-sm btn-success" onclick="openStatusModal('{{ bug.Bug_ID }}')">Wijzig status</button>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="7">Er zijn momenteel geen bugreports.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block modals %}
<div id="statusModal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('statusModal').style.display='none'">&times;</span>
<h3>Status wijzigen voor bug <span id="modalBugId"></span></h3>
<form id="statusForm" method="POST" action="{{ url_for('bugreport_overzicht.bugreport_wijzig_status') }}">
<input type="hidden" name="Bug_ID" id="modalHiddenBugId">
<input type="hidden" name="new_status" id="modalHiddenStatus">
<button type="button" class="status-button status-info" id="btn-nieuw" onclick="submitStatus('Nieuw')">ℹ️ Nieuw</button>
<button type="button" class="status-button status-warning" id="btn-behandeling" onclick="submitStatus('In behandeling')">🧑‍💻 In behandeling</button>
<button type="button" class="status-button status-success" id="btn-fixed" onclick="submitStatus('Fixed')">✅ Fixed</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openStatusModal(bugId) {
document.getElementById('modalBugId').textContent = '#' + bugId;
document.getElementById('modalHiddenBugId').value = bugId;
document.getElementById('statusModal').style.display = 'block';
}
function submitStatus(chosenStatus) {
document.getElementById('modalHiddenStatus').value = chosenStatus;
document.getElementById('statusForm').submit();
}
window.onclick = function (event) {
const modal = document.getElementById('statusModal');
if (event.target === modal) modal.style.display = 'none';
};
</script>
{% endblock %}
+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 %}
+268
View File
@@ -0,0 +1,268 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block header_nav %}{% endblock %}
{% block content %}
<div class="tile-grid">
{% if mag('toestellen_beheren', 'eigendom_bekijken', 'toestellen_uitdelen', 'herstellingen', 'signpost') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">
{# Het icoon van Chrome zelf (google.com/chrome); lukt dat niet, dan het gewone icoon #}
<img src="https://www.google.com/chrome/static/images/favicons/favicon-32x32.png" alt="" width="24" height="24"
onerror="this.replaceWith(document.createTextNode('💻'))">
</span><h3>Chromebooks</h3></div>
<ul class="tile-links">
{% if mag('toestellen_beheren') %}<li><a href="{{ url_for('devices.inventaris_toestellen') }}">💻 Alle toestellen</a></li>{% endif %}
{% if mag('eigendom_bekijken') %}<li><a href="{{ url_for('devices.inventaris_eigendom') }}">🏷️ Eigendom bekijken</a></li>{% endif %}
{% if mag('toestellen_uitdelen') %}
<li><a href="{{ url_for('devices.inleveren_page') }}">📥 Toestel inleveren</a></li>
<li><a href="{{ url_for('devices.uitdelen_page') }}">📤 Toestel uitdelen</a></li>
{% endif %}
{% if mag('signpost') %}<li><a href="{{ url_for('signpost_devices.signpost_database_overzicht') }}">📄 Signpost-toestellen</a></li>{% endif %}
{% if mag('herstellingen') %}<li><a href="{{ url_for('repairs.herstellingen') }}">🛠️ Signpost herstellingen</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('google_wachtwoord', 'google_accounts', 'tweestaps_codes', 'tweestaps_uitzetten', 'google_licenties', 'gedeelde_drives') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">
{# Het icoon van Google zelf (google.com); lukt dat niet, dan het gewone icoon #}
<img src="https://www.google.com/favicon.ico" alt="" width="24" height="24"
onerror="this.replaceWith(document.createTextNode('🔑'))">
</span><h3>Google</h3></div>
<ul class="tile-links">
{% if mag('google_wachtwoord', 'google_accounts') %}<li><a href="{{ url_for('google.password_reset') }}">🔑 Wachtwoord resetten</a></li>{% endif %}
{% if mag('tweestaps_codes', 'tweestaps_uitzetten') %}<li><a href="{{ url_for('tweestaps.overzicht') }}">🛡️ 2FA-hulp</a></li>{% endif %}
{% if mag('google_licenties') %}<li><a href="{{ url_for('google_licenties.overzicht') }}">🎓 Licenties</a></li>{% endif %}
{% if mag('gedeelde_drives') %}<li><a href="{{ url_for('gedeelde_drives.overzicht') }}" onclick="showLoader('Gedeelde drives ophalen bij Google...')">🗂️ Gedeelde drives</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('gmail_delegatie', 'verzendlijsten', 'gmail_labels', 'gmail_handtekeningen', 'gmail_contacten') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">
{# Het icoon van Gmail zelf; lukt dat niet, dan het gewone icoon #}
<img src="https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico" alt="" width="24" height="24"
onerror="this.replaceWith(document.createTextNode('✉️'))">
</span><h3>Gmail</h3></div>
<ul class="tile-links">
{% if mag('gmail_delegatie') %}<li><a href="{{ url_for('gmail_delegatie.overzicht') }}">✉️ Mailbox-delegatie</a></li>{% endif %}
{% if mag('verzendlijsten') %}<li><a href="{{ url_for('verzendlijsten.overzicht') }}" onclick="showLoader('Verzendlijsten ophalen bij Google...')">📮 Verzendlijsten</a></li>{% endif %}
{% if mag('gmail_labels') %}<li><a href="{{ url_for('gmail_labels.overzicht') }}">🏷️ Mails verplaatsen</a></li>{% endif %}
{% if mag('gmail_handtekeningen') %}<li><a href="{{ url_for('handtekeningen.overzicht') }}">✍️ Handtekeningen</a></li>{% endif %}
{% if mag('gmail_contacten') %}<li><a href="{{ url_for('contacten.overzicht') }}">📇 Contacten</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('smartschool', 'smartschool_activiteit', 'afwezigheden', 'laatkomers', 'skore', 'sgr_groepen') %}
{% set ss_platform = (school.waarden.get('SS_API_PLATFORM') if school else '') or '' %}
{% set ss_platform = ss_platform.replace('https://', '').replace('http://', '').strip('/') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">
{# Het icoon van het eigen Smartschool-platform; lukt dat niet, dan het gewone icoon #}
{% if ss_platform %}<img src="https://{{ ss_platform }}/favicon.ico" alt="" width="26" height="26" style="border-radius:5px"
onerror="this.replaceWith(document.createTextNode('🏫'))">{% else %}🏫{% endif %}
</span><h3>Smartschool</h3></div>
<ul class="tile-links">
{% if mag('smartschool') %}<li><a href="{{ url_for('/ss.smartschool') }}">🏫 Wachtwoord &amp; info</a></li>{% endif %}
{% if mag('smartschool_activiteit') %}
<li><a href="{{ url_for('smartschool.coaccount_overzicht') }}">📈 Co-accounts</a></li>
<li><a href="{{ url_for('smartschool.coaccount_overzicht') }}">📧 Bericht verzenden</a></li>
{% endif %}
{% if mag('afwezigheden') %}<li><a href="{{ url_for('afwezigheden.overzicht') }}">🚩 Afwezigheden (B-codes)</a></li>{% endif %}
{% if mag('laatkomers') %}<li><a href="{{ url_for('afwezigheden.laatkomers') }}">⏰ Laatkomers</a></li>{% endif %}
{% if mag('skore') %}<li><a href="{{ url_for('skore.overzicht') }}">🔁 Skore-synchronisatie</a></li>{% endif %}
{% if mag('sgr_groepen') %}<li><a href="{{ url_for('sgr_groepen.overzicht') }}">👥 SGR groepen</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('leerlingen_bekijken', 'leerlingenaantal') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🎒</span><h3>Leerlingen</h3></div>
<ul class="tile-links">
{% if mag('leerlingen_bekijken') %}<li><a href="{{ url_for('students.inventaris_leerlingen') }}">👥 Leerlingen</a></li>{% endif %}
{% if mag('leerlingenaantal') %}<li><a href="{{ url_for('leerlingenaantal.leerlingenaantal_page') }}">📊 Leerlingenaantallen</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('internen', 'internaat_aanwezigheid') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🛏️</span><h3>Internaat</h3></div>
<ul class="tile-links">
{% if mag('internen') %}<li><a href="{{ url_for('internaat.internen') }}" onclick="showLoader('Internen ophalen uit Informat...')">🧑‍🎓 Internen</a></li>{% endif %}
{% if mag('internaat_aanwezigheid') %}<li><a href="{{ url_for('internaat.aanwezigheden') }}" onclick="showLoader('Register laden...')">✅ Aanwezigheden</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if school and school.waarden.get('ONDERWIJSNIVEAU') == 'basis' and mag('bingel') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🎨</span><h3>Basisonderwijs</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('basisonderwijs.bingel') }}" onclick="showLoader('Leerlingen ophalen uit Informat...')">📗 Bingel</a></li>
</ul>
</section>
{% endif %}
{% if mag('leerlingfacturatie') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🧾</span><h3>Leerlingfacturatie</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('facturatie.overzicht') }}" onclick="showLoader('Facturen ophalen uit Informat...')">💶 Facturen</a></li>
</ul>
</section>
{% endif %}
{% if mag('gopro', 'personeelsaantal') %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🧑‍🏫</span><h3>Personeel</h3></div>
<ul class="tile-links">
{% if mag('gopro') %}<li><a href="{{ url_for('gopro.overzicht') }}" onclick="showLoader('Personeel ophalen uit Informat...')">🪪 GO! Pro</a></li>{% endif %}
{% if mag('gopro') %}<li><a href="{{ url_for('gopro.verzameltabel') }}">📚 Verzameltabel GO! Pro</a></li>{% endif %}
{% if mag('personeelsaantal') %}<li><a href="{{ url_for('personeelsaantal.personeelsaantal_page') }}">📊 Personeelsaantallen</a></li>{% endif %}
</ul>
</section>
{% endif %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🗓️</span><h3>Afwezigheden &amp; activiteiten</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('personeel.nieuw', soort='afwezigheid') }}">🤒 Afwezigheid melden</a></li>
<li><a href="{{ url_for('personeel.mijn') }}">📋 Nascholing / uitstap aanvragen</a></li>
{% if 'directie' in personeel_rollen %}
<li><a href="{{ url_for('personeel.beoordelen') }}">✅ Te beoordelen</a></li>
{% endif %}
{% if 'secretariaat' in personeel_rollen %}
<li><a href="{{ url_for('personeel.dagoverzicht') }}">🗓️ Dagoverzicht secretariaat</a></li>
{% endif %}
</ul>
</section>
<section class="tile tile-bug">
<div class="tile-head"><span class="tile-icon">🐞</span><h3>Bugs</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('bugreport.bugreport') }}">🐞 Bug rapporteren</a></li>
{% if mag('bugs_bekijken', 'bugs_beheren') %}<li><a href="{{ url_for('bugreport_overzicht.bugreport_overzicht') }}">📕 Bugs bekijken</a></li>{% endif %}
</ul>
</section>
{% if mag_handleidingen() %}
{% set hls = handleidingen_lijst() %}
<section class="tile tile-handleiding">
<div class="tile-head"><span class="tile-icon">📘</span><h3>Handleidingen</h3></div>
<ul class="tile-links">
{% for h in hls[:5] %}
<li><a href="{{ url_for('handleidingen.bekijk', slug=h.Slug) }}">📄 {{ h.Titel }}</a></li>
{% endfor %}
<li><a href="{{ url_for('handleidingen.overzicht') }}">📚 {{ 'Alle handleidingen' if hls else 'Handleidingen' }}{% if hls|length > 5 %} ({{ hls|length }}){% endif %}</a></li>
</ul>
</section>
{% endif %}
<section class="tile tile-scholengroep">
<div class="tile-head"><span class="tile-icon">
<img src="{{ url_for('static', filename='img/go-logo.png') }}" alt="" width="26" height="26" style="display:block;border-radius:5px">
</span><h3>Scholengroep</h3></div>
<ul class="tile-links">
{# Helpdesk van de scholengroep (osTicket): voor iedereen, opent in een nieuw tabblad #}
<li><a href="http://helpdesk.go-antwerpen.be/" target="_blank" rel="noopener">
<img src="http://helpdesk.go-antwerpen.be/favicon.ico" alt="" width="16" height="16"
style="vertical-align:-3px;margin-right:2px"
onerror="this.replaceWith(document.createTextNode('🎫'))"> Helpdesk</a></li>
</ul>
</section>
{% if mag('qr_codes') %}
<section class="tile tile-tools">
<div class="tile-head"><span class="tile-icon">🧰</span><h3>Tools</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('qr_generator.qr_generator') }}">💡 QR-code generator</a></li>
</ul>
</section>
{% endif %}
{% if school %}
{% set web_domein = (school.domein or '').strip().lower() %}
{% set ss_url = ((school.waarden.get('SS_API_PLATFORM') or '').replace('https://', '').replace('http://', '').strip().strip('/')) %}
<section class="tile">
<div class="tile-head"><span class="tile-icon">🏫</span><h3>Links</h3></div>
<ul class="tile-links links-lijst">
{% macro ico(url, reserve) -%}
<span class="link-ico"><img src="{{ url }}" alt="" width="16" height="16" onerror="this.replaceWith(document.createTextNode({{ reserve|tojson|forceescape }}))"></span>
{%- endmacro %}
{% if web_domein %}
{% set web = web_domein if web_domein.startswith('www.') else 'www.' ~ web_domein %}
<li><a href="https://{{ web }}" target="_blank" rel="noopener"><span class="link-ico">🌐</span><span class="link-tekst">{{ web_domein }}</span></a></li>
{% endif %}
{% if ss_url %}<li><a href="https://{{ ss_url }}" target="_blank" rel="noopener" title="{{ ss_url }}">{{ ico('https://' ~ ss_url ~ '/favicon.ico', '📘') }}<span class="link-tekst">{{ ss_url }}</span></a></li>{% endif %}
<li><a href="https://start.informatsoftware.be/informat/informat.web/login.aspx?" target="_blank" rel="noopener">{{ ico('https://start.informatsoftware.be/favicon.ico', '📗') }}<span class="link-tekst">Informat</span></a></li>
<li><a href="https://pb-antwerpen.rvl.be/index.php" target="_blank" rel="noopener">{{ ico('https://pb-antwerpen.rvl.be/favicon.ico', '📙') }}<span class="link-tekst">Rivo</span></a></li>
</ul>
</section>
{% endif %}
</div>
{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist', 'database_toestellen') or is_admin or is_superbeheerder %}
<div class="dash-section-title admin">⚙️ Beheer</div>
<div class="tile-grid">
{% if mag('gebruikersbeheer', 'koppelingen') or is_superbeheerder %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🏢</span><h3>PIVOT</h3></div>
<ul class="tile-links">
{% if mag('gebruikersbeheer') %}<li><a href="{{ url_for('admin.settings') }}">🔐 Gebruikers &amp; rechten</a></li>{% endif %}
{% if mag('koppelingen') %}<li><a href="{{ url_for('scholen.mijn_school') }}">🔌 Koppelingen van deze school</a></li>{% endif %}
{% if is_superbeheerder %}<li><a href="{{ url_for('scholen.overzicht') }}">🏫 Alle scholen beheren</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if is_admin or mag('ip_whitelist') %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🛡️</span><h3>Beveiliging</h3></div>
<ul class="tile-links">
{% if is_admin %}<li><a href="{{ url_for('ipblokkade.overzicht') }}">🚫 IP-blokkades</a></li>
{% else %}<li><a href="{{ url_for('ipblokkade.overzicht') }}">✅ Toegestane IP-adressen</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('co2') or is_admin %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🌐</span><h3>IoT</h3></div>
<ul class="tile-links">
{% if is_admin %}<li><a href="{{ url_for('tools.internet_status') }}">🌐 Internet status</a></li>{% endif %}
{% if mag('co2') %}<li><a href="{{ url_for('co2_sensor.dashboard') }}">🌬️ CO2-sensor uitlezen</a></li>{% endif %}
</ul>
</section>
{% endif %}
{% if mag('nieuw_schooljaar') %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">📚</span><h3>Nieuw schooljaar</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('accounts_verplaatsen.accounts_verplaatsen') }}">👥 Accounts</a></li>
<li><a href="{{ url_for('toestellen_verplaatsen.move_toestellen') }}">💻 Devices</a></li>
</ul>
</section>
{% endif %}
{% if mag('database_toestellen') %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🗄️</span><h3>Database</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('database_beheer.toestellen') }}">💻 Toestellen importeren (Excel)</a></li>
</ul>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}Toestellen importeren{% endblock %}
{% block page_title %}Database › Toestellen{% endblock %}
{% block loader_text %}Bestand controleren...{% endblock %}
{% block extra_css %}
<style>
.stappen { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.stappen > .card + .card { margin-top: 0; }
@media (max-width: 860px) { .stappen { grid-template-columns: 1fr; } }
.stap-nr { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%;
background: var(--primary-color); color: #fff; font-size: 14px; margin-right: 6px; }
.kolom-lijst { margin: 10px 0 0; padding: 0; list-style: none; font-size: 14px; }
.kolom-lijst li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.kolom-lijst li:last-child { border-bottom: 0; }
.kolom-lijst strong { display: inline-block; min-width: 120px; }
.modus-keuze { display: grid; gap: 8px; margin: 4px 0 16px; }
.modus-keuze label { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-weight: 500; cursor: pointer; background: var(--surface-muted); }
.modus-keuze label:has(input:checked) { border-color: var(--primary-color); background: var(--primary-light); }
.modus-keuze input { margin-top: 2px; flex-shrink: 0; }
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; }
.teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px;
border-radius: var(--radius); border: 1px solid var(--border); background: #fff; font-weight: 600; font-size: 14px; }
.teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); }
.teller-knop .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; }
td.melding { font-size: 13px; color: var(--text-muted); min-width: 220px; }
td.melding .fout { color: var(--danger-text); }
td.melding .wijz { color: var(--text); }
.bevestig { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin-top: 16px; }
</style>
{% endblock %}
{% block content %}
{% if not plan %}
<div class="stappen">
<div class="card">
<h2 class="card-title"><span class="stap-nr">1</span>Sjabloon downloaden</h2>
<p class="muted">Vul het sjabloon in met één toestel per rij. De kolom <em>Lokaal</em> heeft een keuzelijst met
de lokalen van {{ school.naam if school else 'deze school' }}.</p>
<a class="btn" href="{{ url_for('database_beheer.toestellen_sjabloon') }}">⬇️ Sjabloon downloaden (.xlsx)</a>
<ul class="kolom-lijst">
{% for k in kolommen %}
<li><strong>{{ k[1] }}</strong> <span class="muted">{{ k[3] }}</span></li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-title"><span class="stap-nr">2</span>Excel uploaden</h2>
<p class="muted">Je krijgt eerst een voorbeeld te zien. Er wordt pas iets bewaard als je bevestigt.
{% if aantal is not none %}Er staan nu <strong>{{ aantal }}</strong> toestellen in de database.{% endif %}</p>
<form method="POST" action="{{ url_for('database_beheer.toestellen_controleren') }}" enctype="multipart/form-data"
onsubmit="showLoader('Bestand controleren...')">
<div class="field">
<label for="bestand">Excel-bestand (.xlsx)</label>
<input type="file" id="bestand" name="bestand" accept=".xlsx,.xlsm,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required>
<div class="field-hint">Max. {{ max_rijen }} rijen. Het tabblad <em>Toestellen</em> wordt gelezen (of anders het eerste tabblad).</div>
</div>
<label>Toestellen die al bestaan</label>
<div class="modus-keuze">
<label><input type="radio" name="modus" value="aanvullen" checked>
<span>Enkel lege velden aanvullen <small>Wat al ingevuld is in PIVOT blijft staan. (Aanbevolen)</small></span></label>
<label><input type="radio" name="modus" value="overschrijven">
<span>Ingevulde cellen overschrijven <small>Wat in Excel staat, vervangt de waarde in PIVOT (ook de status). Lege cellen wissen niets.</small></span></label>
</div>
<button type="submit">🔎 Controleren</button>
</form>
</div>
</div>
{% else %}
{% set t = plan.tellers %}
<div class="card">
<div class="page-head">
<h2>🔎 Voorbeeld: {{ bestandsnaam }}</h2>
<a class="btn btn-light" href="{{ url_for('database_beheer.toestellen') }}">↩️ Ander bestand</a>
</div>
<p class="muted" style="margin-top:-8px">Nog niets bewaard. Bestaande toestellen:
<strong>{{ 'ingevulde cellen overschrijven' if plan.modus == 'overschrijven' else 'enkel lege velden aanvullen' }}</strong>.</p>
{% if onbekend %}
<div class="alert alert-warning">Deze kolommen worden genegeerd: {{ onbekend|join(', ') }}.</div>
{% endif %}
<div class="tellers" role="group" aria-label="Filter">
<button type="button" class="teller-knop actief" data-filter="">Alle <span class="n">{{ plan.rijen|length }}</span></button>
<button type="button" class="teller-knop" data-filter="nieuw"><span class="badge badge-success">➕ Nieuw</span> <span class="n">{{ t.nieuw }}</span></button>
<button type="button" class="teller-knop" data-filter="bijwerken"><span class="badge badge-info">✏️ Bijwerken</span> <span class="n">{{ t.bijwerken }}</span></button>
<button type="button" class="teller-knop" data-filter="ongewijzigd"><span class="badge">= Ongewijzigd</span> <span class="n">{{ t.ongewijzigd }}</span></button>
<button type="button" class="teller-knop" data-filter="fout"><span class="badge badge-danger">⚠️ Fout</span> <span class="n">{{ t.fout }}</span></button>
</div>
<div class="table-wrap">
<table id="voorbeeld">
<thead>
<tr>
<th>Rij</th><th>Actie</th>
{% for k in kolommen %}<th>{{ k[1] }}</th>{% endfor %}
<th>Wat gebeurt er</th>
</tr>
</thead>
<tbody>
{% for r in plan.rijen %}
<tr data-actie="{{ r.actie }}" class="{{ 'rij-fout' if r.actie == 'fout' }}">
<td class="muted">{{ r.rij }}</td>
<td>
{% if r.actie == 'nieuw' %}<span class="badge badge-success">➕ Nieuw</span>
{% elif r.actie == 'bijwerken' %}<span class="badge badge-info">✏️ Bijwerken</span>
{% elif r.actie == 'ongewijzigd' %}<span class="badge">= Ongewijzigd</span>
{% else %}<span class="badge badge-danger">⚠️ Fout</span>{% endif %}
</td>
{% for k in kolommen %}<td class="{{ 'mono' if k[0] in ('schooleigen_id', 'serienummer') }}">{{ r.invoer[k[0]] }}</td>{% endfor %}
<td class="melding">
{% if r.actie == 'fout' %}{% for m in r.meldingen %}<div class="fout">{{ m }}</div>{% endfor %}
{% else %}
{% if r.actie == 'nieuw' %}<div class="wijz">Wordt toegevoegd{% if not r.invoer.status %} (status Beschikbaar){% endif %}.</div>{% endif %}
{% if r.label %}<div>Toestel {{ r.label }}</div>{% endif %}
{% for w in r.wijzigingen %}<div class="wijz">{{ w }}</div>{% endfor %}
{% for m in r.meldingen %}<div>{{ m }}</div>{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set te_doen = t.nieuw + t.bijwerken %}
<form method="POST" action="{{ url_for('database_beheer.toestellen_importeren') }}" class="bevestig"
onsubmit="return confirm({{ ('Nu ' ~ t.nieuw ~ ' toestel(len) toevoegen en ' ~ t.bijwerken ~ ' bijwerken?' ~ (' De ' ~ t.fout ~ ' rij(en) met een fout worden overgeslagen.' if t.fout else ''))|tojson|forceescape }}) && (showLoader('Importeren...'), true)">
<input type="hidden" name="modus" value="{{ plan.modus }}">
<input type="hidden" name="rijen" value="{{ rijen_json }}">
<span class="muted">
{% if te_doen %}{{ t.nieuw }} nieuw en {{ t.bijwerken }} bij te werken toestel(len).
{% if t.fout %}Rijen met een fout worden overgeslagen; verbeter ze in Excel en upload opnieuw.{% endif %}
{% else %}Er is niets te importeren.{% endif %}
</span>
<span style="display:flex;gap:8px">
<a class="btn btn-light" href="{{ url_for('database_beheer.toestellen') }}">Annuleren</a>
<button type="submit" class="btn-success" {{ 'disabled' if not te_doen }}>✅ Importeren</button>
</span>
</form>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Voorbeeld filteren op actie (nieuw / bijwerken / ongewijzigd / fout)
document.querySelectorAll('.teller-knop').forEach(knop => knop.addEventListener('click', () => {
document.querySelectorAll('.teller-knop').forEach(k => k.classList.toggle('actief', k === knop));
const f = knop.dataset.filter;
document.querySelectorAll('#voorbeeld tbody tr').forEach(tr => {
tr.style.display = !f || tr.dataset.actie === f ? '' : 'none';
});
}));
</script>
{% endblock %}
+213
View File
@@ -0,0 +1,213 @@
{% extends "base.html" %}
{% block title %}Leerlingfacturatie{% endblock %}
{% block page_title %}Leerlingfacturatie{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Facturen ophalen uit Informat...{% endblock %}
{% block extra_css %}
<style>
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.filter-bar input[type=search] { flex: 0 1 300px; }
.filter-bar select { width: auto; min-width: 0; }
.statusfilter { display: flex; flex-wrap: wrap; gap: 6px; }
.statusfilter button { all: unset; box-sizing: border-box; cursor: pointer; padding: 5px 12px; border-radius: 999px;
border: 1px solid var(--border); font-size: .88em; color: var(--text-muted, #64748b); }
.statusfilter button b { margin-left: 4px; color: var(--text, #1e293b); }
.statusfilter button.actief { border-color: var(--primary-color); color: var(--primary-color); background: var(--primary-light); font-weight: 600; }
.totalen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 6px 0 14px; }
.totalen div { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; }
.totalen span { display: block; font-size: .8em; color: var(--text-muted, #64748b); text-transform: uppercase; letter-spacing: .04em; }
.totalen strong { font-size: 1.25rem; font-variant-numeric: tabular-nums; }
.totalen .open strong { color: #b91c1c; }
@media (max-width: 700px) { .totalen { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
td.bedrag, th.bedrag { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td.nowrap { white-space: nowrap; }
td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .86em; white-space: nowrap; }
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; }
.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 %}
<div class="card">
<div class="page-head">
<h2>🧾 Facturen leerlingen · {{ school_naam }}</h2>
<div class="actions">
<form method="GET" action="{{ url_for('facturatie.overzicht') }}" style="margin:0" onchange="showLoader(); this.submit()">
<select name="schooljaar" aria-label="Schooljaar">
{% for j in jaren %}<option value="{{ j }}" {{ 'selected' if j == schooljaar }}>Schooljaar {{ j }}</option>{% endfor %}
</select>
</form>
{% if rijen %}<a class="btn btn-light btn-sm" id="exportKnop" href="{{ url_for('facturatie.export', schooljaar=schooljaar) }}">⬇️ CSV</a>{% endif %}
<a class="btn btn-secondary btn-sm" href="{{ url_for('facturatie.overzicht', schooljaar=schooljaar, vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not rijen %}
<p class="muted">Geen geboekte facturen gevonden voor schooljaar {{ schooljaar }}{% if groepen %} ({{ groepen|length }} factuurgroep(en) voor leerlingen){% endif %}.</p>
{% else %}
<div class="totalen" aria-live="polite">
<div><span>Facturen</span><strong id="tAantal">0</strong></div>
<div><span>Totaal</span><strong id="tTotaal">€ 0,00</strong></div>
<div><span>Betaald</span><strong id="tBetaald">€ 0,00</strong></div>
<div class="open"><span>Nog te betalen</span><strong id="tOpen">€ 0,00</strong></div>
</div>
<div class="filter-bar">
<div class="statusfilter" role="group" aria-label="Status">
<button type="button" class="actief" data-status="">Alle <b data-tel=""></b></button>
<button type="button" data-status="onbetaald">Nog te betalen <b data-tel="onbetaald"></b></button>
<button type="button" data-status="betaald">Betaald <b data-tel="betaald"></b></button>
<button type="button" data-status="credit">Creditnota's <b data-tel="credit"></b></button>
</div>
</div>
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek op leerling, klas, stamnummer of mededeling..." aria-label="Zoeken">
{% if groepen|length > 1 %}
<select id="groep" aria-label="Factuurgroep">
<option value="">Alle factuurgroepen</option>
{% for g in groepen %}<option value="{{ g.id }}">{{ g.naam }}</option>{% endfor %}
</select>
{% endif %}
<span id="perPagina"></span>
<span class="muted small">Opgehaald uit Informat op {{ opgehaald }}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>Leerling <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Klas <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Factuurgroep <span class="pijl"></span></th>
<th class="sorteer">Datum <span class="pijl"></span></th>
<th class="sorteer">Vervaldag <span class="pijl"></span></th>
<th class="sorteer bedrag">Totaal <span class="pijl"></span></th>
<th class="sorteer bedrag">Betaald <span class="pijl"></span></th>
<th class="sorteer bedrag">Open <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Status <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Mededeling <span class="pijl"></span></th>
<th class="actions"></th>
</tr>
</thead>
<tbody id="body">
{% for r in rijen %}
{% set vervallen = r.status in ('open', 'gedeeltelijk') and r.vervaldag and r.vervaldag < today %}
<tr data-status="{{ r.status }}" data-groep="{{ r.groep_id }}" data-totaal="{{ r.totaal * r.teken }}" data-betaald="{{ r.betaald * r.teken }}" data-open="{{ r.open * r.teken }}">
<td data-sort="{{ r.naam }} {{ r.voornaam }}"><strong>{{ r.naam }}</strong> {{ r.voornaam }}{% if r.stamnummer %}<br><span class="muted small">stamnr. {{ r.stamnummer }}</span>{% endif %}</td>
<td class="nowrap" data-sort="{{ r.klas }} {{ '%03d'|format(r.klasnummer or 0) }}">{{ r.klas }}{% if r.klasnummer %} <span class="muted small">nr. {{ r.klasnummer }}</span>{% endif %}</td>
<td data-sort="{{ r.groep }}">{{ r.groep }}</td>
<td class="nowrap" data-sort="{{ r.datum.isoformat() if r.datum else '' }}">{{ r.datum.strftime('%d-%m-%Y') if r.datum else '' }}</td>
<td class="nowrap" data-sort="{{ r.vervaldag.isoformat() if r.vervaldag else '' }}">{{ r.vervaldag.strftime('%d-%m-%Y') if r.vervaldag else '' }}</td>
<td class="bedrag" data-sort="{{ '%012.2f'|format(r.totaal * r.teken + 1000000) }}">{{ '− ' if r.teken < 0 }}{{ '€ %.2f'|format(r.totaal)|replace('.', ',') }}</td>
<td class="bedrag" data-sort="{{ '%012.2f'|format(r.betaald + 1000000) }}">{{ '€ %.2f'|format(r.betaald)|replace('.', ',') }}</td>
<td class="bedrag" data-sort="{{ '%012.2f'|format(r.open + 1000000) }}">{% if r.open %}<strong>{{ '€ %.2f'|format(r.open)|replace('.', ',') }}</strong>{% else %}€ 0,00{% endif %}</td>
<td data-sort="{{ labels[r.status] }}">
{% if r.status == 'betaald' %}<span class="badge badge-success">Betaald</span>
{% elif r.status == 'credit' %}<span class="badge badge-info">Creditnota</span>
{% elif vervallen %}<span class="badge badge-danger" title="Vervaldag {{ r.vervaldag.strftime('%d-%m-%Y') }} is voorbij">Vervallen</span>
{% elif r.status == 'gedeeltelijk' %}<span class="badge badge-warning">Gedeeltelijk</span>
{% else %}<span class="badge badge-warning">Open</span>{% endif %}
</td>
<td class="mono" data-sort="{{ r.ogm }}">{% if r.ogm|length == 12 %}+++{{ r.ogm[:3] }}/{{ r.ogm[3:7] }}/{{ r.ogm[7:] }}+++{% else %}{{ r.ogm }}{% endif %}
{%- if r.ogm_factuur %}<br><span class="muted small" title="Mededeling van de factuur waarbij deze creditnota hoort">bij {{ r.ogm_factuur }}</span>{% endif %}</td>
<td class="actions">
<a class="btn btn-sm btn-light" target="_blank" rel="noopener"
href="{{ url_for('facturatie.pdf', toegangsgroep=r.toegangsgroep, invoice_id=r.id, schooljaar=schooljaar, naam=('Factuur ' ~ r.naam ~ ' ' ~ r.voornaam ~ ' ' ~ (r.datum.isoformat() if r.datum else ''))|trim) }}"
title="PDF openen">📄 PDF</a>
</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>
{% endblock %}
{% block scripts %}
<script>
(function () {
const body = document.getElementById('body');
if (!body) return;
const zoekveld = document.getElementById('zoek'), groepSel = document.getElementById('groep');
const exp = document.getElementById('exportKnop'), basisExport = exp ? exp.href : '';
let PER_PAGINA = perPagina('facturatie'), pagina = 1, zoek = '', status = '', groep = '';
const euro = n => '€ ' + n.toLocaleString('nl-BE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const past = (tr, st) => !st || (st === 'onbetaald' ? ['open', 'gedeeltelijk'].includes(tr.dataset.status) : tr.dataset.status === st);
const basis = tr => (!groep || tr.dataset.groep === groep) && (!zoek || tr.textContent.toLowerCase().includes(zoek));
function toon() {
const kandidaten = Array.from(body.rows).filter(basis);
document.querySelectorAll('.statusfilter b[data-tel]').forEach(b => b.textContent = kandidaten.filter(tr => past(tr, b.dataset.tel)).length);
const rijen = kandidaten.filter(tr => past(tr, status));
const som = k => rijen.reduce((t, tr) => t + parseFloat(tr.dataset[k] || 0), 0);
document.getElementById('tAantal').textContent = rijen.length;
document.getElementById('tTotaal').textContent = euro(som('totaal'));
document.getElementById('tBetaald').textContent = euro(som('betaald'));
document.getElementById('tOpen').textContent = euro(som('open'));
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';
const nav = document.getElementById('paginering');
nav.innerHTML = '';
if (exp) {
const p = new URLSearchParams(new URL(basisExport).search);
if (groep) p.set('groep', groep); if (status) p.set('status', status); if (zoek) p.set('zoek', zoek);
exp.href = basisExport.split('?')[0] + '?' + p.toString();
}
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); }
}
zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
if (groepSel) groepSel.addEventListener('change', () => { groep = groepSel.value; pagina = 1; toon(); });
document.querySelectorAll('.statusfilter button').forEach(b => b.addEventListener('click', () => {
status = b.dataset.status;
document.querySelectorAll('.statusfilter button').forEach(x => x.classList.toggle('actief', x === b));
pagina = 1; toon();
}));
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'), 'facturatie', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
</script>
{% endblock %}
+320
View File
@@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Gedeelde drives{% endblock %}
{% block page_title %}Gedeelde drives{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Gedeelde drives ophalen bij Google...{% endblock %}
{% block extra_css %}
<style>
[hidden] { display: none !important; }
.balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.balk input[type=search] { flex: 0 1 320px; margin: 0; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
.filters button { width: auto; margin: 0; padding: 4px 12px; border-radius: 999px; background: var(--surface-muted);
border: 1px solid var(--border); font-size: .88em; color: inherit; font-weight: 400; cursor: pointer; }
.filters button[aria-pressed="true"] { border-color: var(--primary-color); background: var(--primary-light); color: var(--primary-color); font-weight: 600; }
.filters button.let { background: #fef3c7; border-color: #fcd34d; }
.filters button.let[aria-pressed="true"] { background: #fde68a; color: #92400e; }
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.getal { text-align: right; white-space: nowrap; }
tr.klik { cursor: pointer; }
tr.klik:hover td { background: var(--primary-light); }
.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; }
.toevoegen { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(160px, 1fr) auto auto; gap: 10px; align-items: end; }
.toevoegen .field { margin: 0; }
@media (max-width: 800px) { .toevoegen { grid-template-columns: 1fr; } }
td.acties { white-space: nowrap; text-align: right; }
td.acties form { display: inline; margin: 0; }
td.acties select { width: auto; margin: 0; padding: 4px 8px; font-size: 13px; }
.type { font-size: .8em; padding: 1px 8px; border-radius: 999px; background: var(--surface-muted); }
</style>
{% endblock %}
{% block content %}
{% if lid_van is not none %}
{# ======================= Waar is iemand lid van? ======================= #}
<div class="card">
<div class="page-head">
<h2>🔎 {{ lid }}</h2>
<div class="actions"><a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a></div>
</div>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>
{% elif not lid_van %}
<p class="muted">{{ lid }} is op geen enkele gedeelde drive lid (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).</p>
{% else %}
<p class="muted small" style="margin-top:-6px">Lid van {{ lid_van|length }} gedeelde drive(s) (rechtstreeks; lidmaatschap via een Google-groep telt hier niet mee).</p>
<div class="table-wrap"><table>
{% set meerdere = (lid_van|map(attribute='email')|unique|list|length) > 1 or '@' not in lid %}
<thead><tr><th>Drive</th>{% if meerdere %}<th>Lid</th>{% endif %}<th>Rol</th><th></th></tr></thead>
<tbody>{% for d in lid_van %}
<tr><td><a href="{{ url_for('gedeelde_drives.overzicht', drive=d.drive) }}" onclick="showLoader()"><strong>{{ d.naam }}</strong></a></td>
{% if meerdere %}<td class="small">{{ d.wie }}{% if d.wie != d.email %}<br><span class="muted">{{ d.email }}</span>{% endif %}</td>{% endif %}
<td>{{ d.rol_naam }}</td>
<td class="acties"><a class="btn btn-light btn-sm" href="https://drive.google.com/drive/folders/{{ d.drive }}" target="_blank" rel="noopener">Openen in Drive ↗</a></td></tr>
{% endfor %}</tbody>
</table></div>
{% if '@' in lid %}
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader()" style="margin-top:14px">
<input type="hidden" name="oud" value="{{ lid }}">
<span>🔁 Op al deze drives vervangen door</span>
<input type="email" name="nieuw" required placeholder="nieuwe gebruiker (e-mail)" style="flex:0 1 280px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Verder</button>
</form>
{% endif %}
{% endif %}
</div>
{% elif vervang %}
{# ======================= Gebruiker vervangen ======================= #}
<div class="card">
<div class="page-head">
<h2>🔁 {{ vervang.oud }} → {{ vervang.nieuw or '?' }}</h2>
<div class="actions"><a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a></div>
</div>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>
{% elif resultaat is not none %}
{% set ok = resultaat|selectattr('ok')|list|length %}
<div class="alert {{ 'alert-success' if ok == resultaat|length else 'alert-warning' }}">{{ ok }} van {{ resultaat|length }} drive(s) aangepast.</div>
<div class="table-wrap"><table>
<thead><tr><th>Drive</th><th></th><th>Resultaat</th></tr></thead>
<tbody>{% for r in resultaat %}<tr><td><strong>{{ r.naam }}</strong></td><td>{{ '✅' if r.ok else '⚠️' }}</td><td class="small">{{ r.melding }}</td></tr>{% endfor %}</tbody>
</table></div>
{% elif not vervang.drives %}
<p class="muted">{{ vervang.oud }} is op geen enkele gedeelde drive lid.</p>
{% else %}
<p class="muted small" style="margin-top:-6px">{{ vervang.oud }} is lid van {{ vervang.drives|length }} gedeelde drive(s).
{{ vervang.nieuw }} krijgt op elke aangevinkte drive exact dezelfde rol (ook als die er al lid van was).</p>
<form method="POST" action="{{ url_for('gedeelde_drives.vervangen') }}"
onsubmit="if (!this.querySelector('input[name=drive]:checked')) { alert('Vink minstens één drive aan.'); return false; }
if (!confirm({{ (vervang.oud ~ ' vervangen door ' ~ vervang.nieuw ~ ' op de aangevinkte drives?')|tojson|forceescape }})) return false; showLoader('Vervangen...');">
<input type="hidden" name="oud" value="{{ vervang.oud }}"><input type="hidden" name="nieuw" value="{{ vervang.nieuw }}">
<div class="table-wrap"><table>
<thead><tr><th style="width:28px"><input type="checkbox" checked style="width:auto;margin:0" onchange="this.closest('table').querySelectorAll('input[name=drive]').forEach(x => x.checked = this.checked)"></th>
<th>Drive</th><th>Rol van {{ vervang.oud }}</th></tr></thead>
<tbody>{% for d in vervang.drives %}
<tr><td><input type="checkbox" name="drive" value="{{ d.drive }}" checked style="width:auto;margin:0"></td>
<td><a href="{{ url_for('gedeelde_drives.overzicht', drive=d.drive) }}">{{ d.naam }}</a></td><td>{{ d.rol_naam }}</td></tr>
{% endfor %}</tbody>
</table></div>
<div class="balk">
<label style="display:flex;gap:6px;align-items:center;margin:0;font-weight:400"><input type="checkbox" name="verwijderen" value="1" checked style="width:auto;margin:0"> {{ vervang.oud }} daarna verwijderen</label>
<label style="display:flex;gap:6px;align-items:center;margin:0;font-weight:400"><input type="checkbox" name="melding" value="1" style="width:auto;margin:0"> e-mail sturen naar {{ vervang.nieuw }}</label>
<button type="submit" class="btn-success" style="margin:0 0 0 auto">🔁 Vervangen</button>
</div>
</form>
{% endif %}
</div>
{% elif gekozen %}
{# ======================= Detail van één drive ======================= #}
<div class="card">
<div class="page-head">
<h2>🗂️ {{ gekozen.naam }}</h2>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for('gedeelde_drives.overzicht') }}">← Alle gedeelde drives</a>
<a class="btn btn-light btn-sm" href="https://drive.google.com/drive/folders/{{ gekozen.id }}" target="_blank" rel="noopener">Openen in Drive ↗</a>
</div>
</div>
<p class="muted small" style="margin-top:-6px">{{ gekozen.leden }} lid/leden · {{ gekozen.beheerders }} beheerder(s)
{% if gekozen.aangemaakt %} · aangemaakt {{ gekozen.aangemaakt }}{% endif %}</p>
{% if not gekozen.beheerders %}<div class="alert alert-warning">⚠️ Deze drive heeft geen beheerder. Voeg hieronder iemand toe als <strong>Beheerder</strong>.</div>{% endif %}
{% if gekozen.extern %}<div class="alert alert-warning">🌍 {{ gekozen.extern }} lid/leden van buiten de school.</div>{% endif %}
<form class="toevoegen" method="POST" action="{{ url_for('gedeelde_drives.lid_toevoegen') }}" onsubmit="showLoader('Toevoegen...')">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<div class="field"><label for="email">Persoon of groep (e-mailadres)</label>
<input type="email" id="email" name="email" required placeholder="bv. jan.peeters@school.be of leerkrachten@school.be"></div>
<div class="field"><label for="rol">Rol</label>
<select id="rol" name="rol">{% for r, n in rollen %}<option value="{{ r }}" {{ 'selected' if r == 'writer' }}>{{ n }}</option>{% endfor %}</select></div>
<label style="display:flex;gap:6px;align-items:center;margin:0 0 10px;font-weight:400"><input type="checkbox" name="melding" value="1" style="width:auto;margin:0"> e-mail sturen</label>
<button type="submit" class="btn-success" style="margin:0 0 2px">➕ Toevoegen</button>
</form>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>Naam</th><th>E-mail</th><th>Soort</th><th>Rol</th><th></th></tr></thead>
<tbody>
{% for l in leden %}
<tr>
<td><strong>{{ l.naam or '—' }}</strong>{% if l.extern %} <span class="badge badge-warning" title="Buiten de school">extern</span>{% endif %}
{% if l.verwijderd %} <span class="badge badge-danger">account verwijderd</span>{% endif %}</td>
<td class="small">{{ l.email }}</td>
<td><span class="type">{{ {'user': 'persoon', 'group': 'groep', 'domain': 'domein', 'anyone': 'iedereen met link'}.get(l.type, l.type) }}</span></td>
<td class="acties" style="text-align:left">
<form method="POST" action="{{ url_for('gedeelde_drives.lid_wijzigen') }}">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<input type="hidden" name="perm" value="{{ l.id }}"><input type="hidden" name="email" value="{{ l.email or l.naam }}">
<select name="rol" onchange="if (confirm({{ ('Rol van ' ~ (l.email or l.naam) ~ ' wijzigen?')|tojson|forceescape }})) { showLoader(); this.form.submit(); } else { this.value = {{ l.rol|tojson|forceescape }}; }">
{% for r, n in rollen %}<option value="{{ r }}" {{ 'selected' if r == l.rol }}>{{ n }}</option>{% endfor %}
</select>
</form>
</td>
<td class="acties">
<form method="POST" action="{{ url_for('gedeelde_drives.lid_verwijderen') }}"
onsubmit="if (!confirm({{ ((l.email or l.naam) ~ ' uit ' ~ gekozen.naam ~ ' verwijderen?')|tojson|forceescape }})) return false; showLoader();">
<input type="hidden" name="drive" value="{{ gekozen.id }}"><input type="hidden" name="naam" value="{{ gekozen.naam }}">
<input type="hidden" name="perm" value="{{ l.id }}"><input type="hidden" name="email" value="{{ l.email or l.naam }}">
<button type="submit" class="btn-sm btn-ghost" title="Verwijderen">🗑️</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Deze drive heeft geen leden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint" style="margin-top:12px"><strong>Beheerder</strong>: alles, ook leden beheren · <strong>Inhoudsbeheerder</strong>:
bestanden toevoegen, bewerken, verplaatsen en verwijderen · <strong>Bijdrager</strong>: toevoegen en bewerken ·
<strong>Reageerder</strong>: bekijken en reageren · <strong>Lezer</strong>: enkel bekijken.</p>
</div>
{% else %}
{# ======================= Overzicht ======================= #}
<div class="card">
<div class="page-head">
<h2>🗂️ Gedeelde drives <span class="muted small">({{ drives|length }})</span></h2>
<div class="actions">
<button type="button" class="btn-success btn-sm" onclick="document.getElementById('nieuwVenster').style.display='block'">➕ Nieuwe gedeelde drive</button>
<a class="btn btn-secondary btn-sm" href="{{ url_for('gedeelde_drives.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader('Drives van deze gebruiker zoeken...')"
style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius);margin:0 0 10px">
<strong style="white-space:nowrap">🔎 Waar is iemand lid van?</strong>
<input type="text" name="lid" required placeholder="e-mailadres, of een deel van de naam of het adres" style="flex:1 1 300px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Zoeken</button>
</form>
<form class="balk" method="GET" action="{{ url_for('gedeelde_drives.overzicht') }}" onsubmit="showLoader('Drives van deze gebruiker zoeken...')"
style="padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius);margin-top:0">
<strong style="white-space:nowrap">🔁 Gebruiker vervangen</strong>
<input type="email" name="oud" required placeholder="oude gebruiker (e-mail)" style="flex:1 1 220px;margin:0">
<span>→</span>
<input type="email" name="nieuw" required placeholder="nieuwe gebruiker (e-mail)" style="flex:1 1 220px;margin:0">
<button type="submit" class="btn-light btn-sm" style="margin:0">Drives zoeken</button>
</form>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not drives %}
<p class="muted">Er zijn nog geen gedeelde drives in dit domein.</p>
{% else %}
{% set zonder = drives|selectattr('beheerders', 'equalto', 0)|list|length %}
{% set ext = drives|selectattr('extern')|list|length %}
<div class="balk">
<input type="search" id="zoek" placeholder="Zoek een drive..." aria-label="Zoeken">
<div class="filters" id="filters">
<button type="button" data-filter="" aria-pressed="true">Alle <b>{{ drives|length }}</b></button>
{% if zonder %}<button type="button" class="let" data-filter="zonder" aria-pressed="false">Zonder beheerder <b>{{ zonder }}</b></button>{% endif %}
{% if ext %}<button type="button" class="let" data-filter="extern" aria-pressed="false">Met externe leden <b>{{ ext }}</b></button>{% endif %}
</div>
<span id="perPagina"></span>
<span class="muted small">Opgehaald op {{ opgehaald }}</span>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th class="sorteer" data-tekst>Naam <span class="pijl"></span></th>
<th class="sorteer">Leden <span class="pijl"></span></th>
<th class="sorteer">Beheerders <span class="pijl"></span></th>
<th class="sorteer">Groepen <span class="pijl"></span></th>
<th class="sorteer">Extern <span class="pijl"></span></th>
<th class="sorteer">Aangemaakt <span class="pijl"></span></th>
</tr></thead>
<tbody id="body">
{% for d in drives %}
<tr class="klik" data-href="{{ url_for('gedeelde_drives.overzicht', drive=d.id) }}"
data-filters="{{ 'zonder' if not d.beheerders }} {{ 'extern' if d.extern }}">
<td data-sort="{{ d.naam }}"><strong>{{ d.naam }}</strong>{% if d.fout %} <span class="badge badge-danger" title="{{ d.fout }}">leden niet op te halen</span>{% endif %}</td>
<td class="getal" data-sort="{{ d.leden }}">{{ d.leden }}</td>
<td class="getal" data-sort="{{ d.beheerders }}">{% if d.beheerders %}{{ d.beheerders }}{% else %}<span class="badge badge-danger">geen</span>{% endif %}</td>
<td class="getal" data-sort="{{ d.groepen }}">{{ d.groepen or '' }}</td>
<td class="getal" data-sort="{{ d.extern }}">{% if d.extern %}<span class="badge badge-warning">{{ d.extern }}</span>{% endif %}</td>
<td data-sort="{{ d.aangemaakt }}" class="small">{{ d.aangemaakt }}</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 %}
<p class="field-hint" style="margin-top:14px">Vereist in de domeinbrede delegatie: <code>https://www.googleapis.com/auth/drive</code>,
en de <strong>Google Drive API</strong> moet aanstaan in het Cloud-project van het serviceaccount.</p>
</div>
{% endif %}
{% endblock %}
{% block modals %}
<div id="nieuwVenster" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('nieuwVenster').style.display='none'">&times;</span>
<h3>➕ Nieuwe gedeelde drive</h3>
<form method="POST" action="{{ url_for('gedeelde_drives.nieuw') }}" onsubmit="showLoader('Aanmaken...')">
<div class="field"><label>Naam</label><input type="text" name="naam" required maxlength="100"></div>
<div class="field"><label>Beheerder (optioneel)</label><input type="email" name="beheerder" placeholder="e-mailadres van persoon of groep">
<p class="field-hint">De beheerder van PIVOT wordt automatisch ook beheerder van de nieuwe drive.</p></div>
<button type="submit" class="btn-block">Aanmaken</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.addEventListener('click', e => { if (e.target.id === 'nieuwVenster') e.target.style.display = 'none'; });
(function () {
const body = document.getElementById('body');
if (!body) return;
const rijen = Array.from(body.rows), zoek = document.getElementById('zoek');
let PER = perPagina('gedeelde-drives', 25), pagina = 1, filter = '';
body.addEventListener('click', e => { const tr = e.target.closest('tr.klik'); if (tr) { showLoader(); location.href = tr.dataset.href; } });
document.querySelectorAll('#filters button').forEach(b => b.addEventListener('click', () => {
filter = b.dataset.filter;
document.querySelectorAll('#filters button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
pagina = 1; toon();
}));
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');
rijen.sort((a, b) => {
const c = (a.cells[k].dataset.sort || '').localeCompare(b.cells[k].dataset.sort || '', 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
function toon() {
const z = zoek.value.trim().toLowerCase();
const lijst = rijen.filter(tr => (!z || tr.cells[0].textContent.toLowerCase().includes(z))
&& (!filter || tr.dataset.filters.split(' ').includes(filter)));
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');
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 = (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); }
}
}
zoek.addEventListener('input', () => { pagina = 1; toon(); });
perPaginaKiezer(document.getElementById('perPagina'), 'gedeelde-drives', n => { PER = n; pagina = 1; toon(); }, 25);
toon();
})();
</script>
{% endblock %}
+273
View File
@@ -0,0 +1,273 @@
{% extends "base.html" %}
{% block title %}Mailbox-delegatie{% endblock %}
{% block page_title %}Mailbox-delegatie{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Bezig met Google...{% endblock %}
{% block extra_css %}
<style>
.mailbox-head { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
.avatar { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center;
background: var(--primary-light); color: var(--primary-color); font-weight: 800; font-size: 18px; flex-shrink: 0; }
.mailbox-head .who { flex: 1; min-width: 200px; }
.mailbox-head .who strong { display: block; font-size: 1.1rem; }
.counter { font-size: 13px; color: var(--text-muted); }
.add-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.add-form input[type=email] { flex: 1 1 260px; width: auto; }
.pick-list { list-style: none; margin: 0; padding: 0; }
.pick-list a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm);
text-decoration: none; color: var(--text); }
.pick-list a:hover { background: var(--primary-light); }
.pick-list .ou { color: var(--text-muted); font-size: 13px; }
.ov-kop { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
.ov-kop .card-title { margin: 0; }
.ov-balk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin: 14px 0 10px; }
.ov-balk input[type=search] { flex: 0 1 340px; }
.persoon strong { display: block; font-weight: 600; }
.persoon span { color: var(--text-muted); font-size: 13px; }
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; }
.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; }
.voortgang { height: 8px; border-radius: 999px; background: var(--surface-muted); overflow: hidden; margin-top: 8px; }
.voortgang div { height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
</style>
{% endblock %}
{% block content %}
{# ---------- Zoeken ---------- #}
<div class="card">
<h2 class="card-title">✉️ Welke mailbox?</h2>
<form class="search-bar" method="GET" action="{{ url_for('gmail_delegatie.overzicht') }}" onsubmit="showLoader()" style="margin:0">
<input type="text" name="zoek" placeholder="Naam of e-mailadres van de mailbox, bv. secretariaat@..." value="{{ zoek or (mailbox.email if mailbox else '') }}" autofocus>
<button type="submit">🔍 Zoeken</button>
</form>
{% if zoekresultaten %}
<p class="muted small" style="margin:14px 0 6px">Kies een mailbox:</p>
<ul class="pick-list">
{% for u in zoekresultaten %}
<li><a href="{{ url_for('gmail_delegatie.overzicht', mailbox=u.email) }}" onclick="showLoader()">
<span><strong>{{ u.naam }}</strong> · {{ u.email }}</span><span class="ou">{{ u.ou }}</span>
</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if fout %}
<div class="flash-message error" style="margin-top:20px"><span>{{ fout }}</span></div>
{% endif %}
{# ---------- Gedelegeerden van de gekozen mailbox ---------- #}
{% if mailbox %}
<div class="card">
<div class="mailbox-head">
<span class="avatar">{{ (mailbox.naam or mailbox.email)[:1]|upper }}</span>
<div class="who">
<strong>{{ mailbox.naam or mailbox.email }}</strong>
<span class="muted">{{ mailbox.email }}{% if mailbox.ou %} · {{ mailbox.ou }}{% endif %}</span>
{% if mailbox.geschorst %}<span class="badge badge-warning">Geschorst</span>{% endif %}
</div>
<span class="counter">{{ delegates|length }} / {{ max_delegates }} gedelegeerden</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Heeft toegang</th><th>Status</th><th class="actions">Actie</th></tr>
</thead>
<tbody>
{% for d in delegates %}
<tr>
<td><strong>{{ d.email }}</strong></td>
<td>
{% if d.status == 'accepted' %}<span class="badge badge-success">{{ d.status_label }}</span>
{% elif d.status == 'pending' %}<span class="badge badge-warning">{{ d.status_label }}</span>
{% else %}<span class="badge">{{ d.status_label }}</span>{% endif %}
</td>
<td class="actions">
<form method="POST" action="{{ url_for('gmail_delegatie.verwijderen') }}"
onsubmit="if (!confirm('Toegang van {{ d.email }} tot {{ mailbox.email }} intrekken?')) return false; showLoader();">
<input type="hidden" name="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="delegate" value="{{ d.email }}">
<button type="submit" class="btn-sm btn-danger">✕ Intrekken</button>
</form>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="3">Niemand anders heeft toegang tot deze mailbox.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if delegates|length < max_delegates %}
<form class="add-form" method="POST" action="{{ url_for('gmail_delegatie.toevoegen') }}" onsubmit="showLoader()">
<input type="hidden" name="mailbox" value="{{ mailbox.email }}">
<input type="email" name="delegate" required placeholder="E-mailadres van de collega die toegang krijgt">
<button type="submit" class="btn-success">➕ Toegang geven</button>
</form>
<p class="field-hint">Enkel accounts van jullie eigen Google Workspace. De collega ziet de mailbox binnen ongeveer een minuut via het profielmenu in Gmail.</p>
{% else %}
<div class="alert alert-warning" style="margin-top:16px">Deze mailbox heeft het maximum van {{ max_delegates }} gedelegeerden bereikt.</div>
{% endif %}
</div>
{% endif %}
{# ---------- Alle lopende machtigingen ---------- #}
<div class="card" id="overzicht">
<div class="ov-kop">
<h2 class="card-title">📋 Lopende machtigingen <span class="muted small">({{ machtigingen|length }})</span></h2>
<form method="POST" action="{{ url_for('gmail_delegatie.overzicht_vernieuwen') }}" style="margin:0">
<button type="submit" class="btn-light btn-sm" {{ 'disabled' if ov_status.bezig }}>🔄 Vernieuwen</button>
</form>
</div>
<p class="muted small" style="margin:6px 0 0">
{% if laatst and laatst.Opgehaald %}Alle mailboxen nagekeken op {{ laatst.Opgehaald.strftime('%d-%m-%Y om %H:%M') }}
({{ laatst.Mailboxen }} mailboxen{% if laatst.Fouten %}, {{ laatst.Fouten }} niet bevraagbaar{% endif %}).
Wijzigingen via PIVOT staan er meteen in.
{% else %}Nog niet opgehaald.{% endif %}
</p>
{% if ov_fout %}<div class="alert alert-danger" style="margin-top:12px">{{ ov_fout }}</div>{% endif %}
{% if ov_status.fout and not ov_status.bezig %}<div class="alert alert-danger" style="margin-top:12px">Ophalen mislukt: {{ ov_status.fout }}</div>{% endif %}
<div id="ovBezig" {{ '' if ov_status.bezig else 'hidden' }} style="margin-top:12px">
<span class="muted small" id="ovTekst">Mailboxen nakijken...</span>
<div class="voortgang"><div id="ovBalk"></div></div>
</div>
{% if machtigingen %}
<div class="ov-balk">
<input type="search" id="ovZoek" placeholder="Zoek een mailbox of persoon..." aria-label="Zoeken in de machtigingen">
<span class="per-pagina-plek"></span>
</div>
<div class="table-wrap">
<table id="ovTabel">
<thead>
<tr>
<th class="sorteer">Mailbox <span class="pijl"></span></th>
<th class="sorteer">Heeft toegang <span class="pijl"></span></th>
<th class="sorteer">Status <span class="pijl"></span></th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for m in machtigingen %}
<tr>
<td class="persoon" data-sort="{{ (m.Mailbox_naam or m.Mailbox)|lower }}"><strong>{{ m.Mailbox_naam or m.Mailbox }}</strong>{% if m.Mailbox_naam %}<span>{{ m.Mailbox }}</span>{% endif %}</td>
<td class="persoon" data-sort="{{ (m.Gedelegeerde_naam or m.Gedelegeerde)|lower }}"><strong>{{ m.Gedelegeerde_naam or m.Gedelegeerde }}</strong>{% if m.Gedelegeerde_naam %}<span>{{ m.Gedelegeerde }}</span>{% endif %}</td>
<td data-sort="{{ m.Status or '' }}">
{% if m.Status == 'accepted' %}<span class="badge badge-success">Actief</span>
{% elif m.Status == 'pending' %}<span class="badge badge-warning">In afwachting</span>
{% elif m.Status == 'rejected' %}<span class="badge">Geweigerd</span>
{% elif m.Status == 'expired' %}<span class="badge">Verlopen</span>
{% else %}<span class="badge">{{ m.Status or '?' }}</span>{% endif %}
</td>
<td class="actions"><a href="{{ url_for('gmail_delegatie.overzicht', mailbox=m.Mailbox) }}" class="btn btn-sm btn-light" onclick="showLoader()">Openen</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div class="muted small" id="ovTeller"></div>
<nav class="paginering" id="ovPaginas" aria-label="Pagina's"></nav>
</div>
{% elif laatst and laatst.Opgehaald %}
<p class="muted" style="margin-top:12px">Er zijn geen lopende machtigingen.</p>
{% endif %}
</div>
{# ---------- Logboek ---------- #}
{% if geschiedenis %}
<div class="card">
<h3 class="card-title">🕑 {{ 'Wijzigingen voor deze mailbox' if mailbox else 'Recente wijzigingen' }}</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Tijdstip</th><th>Door</th><th>Actie</th><th>Mailbox</th><th>Gedelegeerde</th></tr></thead>
<tbody>
{% for h in geschiedenis %}
<tr>
<td class="nowrap">{{ h.Tijdstip.strftime('%d-%m-%Y %H:%M') if h.Tijdstip else '' }}</td>
<td>{{ h.Gebruiker or '?' }}</td>
<td>{% if h.Actie == 'toegevoegd' %}<span class="badge badge-success">Toegevoegd</span>{% else %}<span class="badge badge-danger">Ingetrokken</span>{% endif %}</td>
<td>{{ h.Mailbox }}</td>
<td>{{ h.Gedelegeerde }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Ophaling op de achtergrond: voortgang tonen en daarna de pagina vernieuwen
(function () {
const blok = document.getElementById('ovBezig');
if (!blok || blok.hidden) return;
const tekst = document.getElementById('ovTekst'), balk = document.getElementById('ovBalk');
const kijk = () => fetch({{ url_for('gmail_delegatie.overzicht_status')|tojson }}).then(r => r.json()).then(s => {
if (s.totaal) {
tekst.textContent = `Mailboxen nakijken: ${s.voortgang} van ${s.totaal}...`;
balk.style.width = Math.round(100 * s.voortgang / s.totaal) + '%';
}
if (s.bezig) setTimeout(kijk, 2000);
else location.reload();
}).catch(() => setTimeout(kijk, 5000));
kijk();
})();
// Tabel: zoeken, sorteren en pagineren
(function () {
const tabel = document.getElementById('ovTabel');
if (!tabel) return;
const body = tabel.tBodies[0], zoekveld = document.getElementById('ovZoek');
let PER_PAGINA = perPagina('gmail-machtigingen'), pagina = 1, zoek = '';
function toon() {
const rijen = Array.from(body.rows).filter(tr => !zoek || tr.textContent.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('ovTeller').textContent = rijen.length
? `${van + 1}–${Math.min(tot, rijen.length)} van ${rijen.length}` : 'Niets gevonden';
const nav = document.getElementById('ovPaginas');
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); }
}
zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
let kol = null, op = true;
tabel.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : true;
kol = k;
tabel.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
Array.from(body.rows).sort((a, b) => {
const c = (a.cells[k].dataset.sort || '').localeCompare(b.cells[k].dataset.sort || '', 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(document.querySelector('#overzicht .per-pagina-plek'), 'gmail-machtigingen', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
</script>
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Mails verplaatsen{% endblock %}
{% block page_title %}Mails verplaatsen{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Bezig met Google...{% endblock %}
{% block extra_css %}
<style>
.stappen { display: grid; gap: 14px; }
.rij { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
.rij .field { flex: 1 1 260px; margin: 0; }
.modus { display: flex; flex-wrap: wrap; gap: 10px; }
.modus label { flex: 1 1 240px; display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; margin: 0;
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-weight: 400; }
.modus label:has(input:checked) { border-color: var(--primary-color); background: var(--primary-light); }
.modus input { width: auto; margin-top: 3px; }
.modus strong { display: block; }
.samenvatting { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; justify-content: space-between;
padding: 14px 16px; border-radius: var(--radius); background: var(--surface-muted); margin-top: 16px; }
.samenvatting .pijl { font-size: 1.3em; color: var(--text-muted, #64748b); }
.voortgang { height: 10px; border-radius: 999px; background: var(--surface-muted); overflow: hidden; margin: 10px 0 6px; }
.voortgang div { height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
.tellers { display: flex; flex-wrap: wrap; gap: 14px; font-size: .92em; }
td.nowrap { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<h2 class="card-title">🏷️ Berichten met een label naar een andere mailbox</h2>
<p class="field-hint" style="margin-top:-6px">Kies de mailbox en het label. PIVOT kopieert elk bericht met dat label (met bijlagen,
originele datum, gelezen/ongelezen en ster) naar de doelmailbox, onder een label met dezelfde naam.</p>
<form method="GET" action="{{ url_for('gmail_labels.overzicht') }}" class="stappen" onsubmit="showLoader()">
<div class="rij">
<div class="field">
<label for="bron">1. Bronmailbox</label>
<input type="email" id="bron" name="bron" required value="{{ bron.email if bron else bron_in }}" placeholder="bv. secretariaat@school.be">
</div>
<button type="submit" class="btn-secondary">🔍 Labels ophalen</button>
</div>
{% if bron %}
<div class="muted small" style="margin-top:-6px">📬 {{ bron.naam or bron.email }} · {{ bron.email }}{% if bron.ou %} · {{ bron.ou }}{% endif %}</div>
{% if labels %}
<div class="field" style="margin:0">
<label for="label">2. Label</label>
<select id="label" name="label" required>
<option value="">— kies een label —</option>
{% for l in labels %}
<option value="{{ l.id }}" {{ 'selected' if gekozen and gekozen.id == l.id }}>{{ l.naam }}{% if l.aantal is not none %} ({{ l.aantal }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="field" style="margin:0">
<label for="doel">3. Doelmailbox</label>
<input type="email" id="doel" name="doel" required value="{{ doel.email if doel else doel_in }}" placeholder="bv. directie@school.be">
</div>
<div><button type="submit" class="btn-secondary">Controleren</button></div>
{% elif not fout %}
<p class="muted">Deze mailbox heeft geen eigen labels.</p>
{% endif %}
{% endif %}
</form>
{% if fout %}<div class="alert alert-danger" style="margin-top:14px">{{ fout }}</div>{% endif %}
{% if bron and doel and gekozen %}
<form method="POST" action="{{ url_for('gmail_labels.start') }}" style="margin-top:18px"
onsubmit="const v = this.modus.value === 'verplaatsen'; return confirm({{ (gekozen.aantal|string if gekozen.aantal is not none else 'Alle') |tojson|forceescape }} + ' bericht(en) met label ' + {{ gekozen.naam|tojson|forceescape }} + (v ? ' VERPLAATSEN' : ' kopiëren') + ' van ' + {{ bron.email|tojson|forceescape }} + ' naar ' + {{ doel.email|tojson|forceescape }} + '?' + (v ? '\n\nIn de bronmailbox gaan ze naar de prullenbak (30 dagen terug te halen).' : ''));">
<input type="hidden" name="bron" value="{{ bron.email }}">
<input type="hidden" name="doel" value="{{ doel.email }}">
<input type="hidden" name="label" value="{{ gekozen.id }}">
<label style="margin-bottom:8px">4. Wat moet er gebeuren?</label>
<div class="modus">
<label><input type="radio" name="modus" value="kopieren" checked>
<span><strong>📋 Kopiëren</strong><span class="muted small">De berichten blijven ook in de bronmailbox staan.</span></span></label>
<label><input type="radio" name="modus" value="verplaatsen">
<span><strong>📦 Verplaatsen</strong><span class="muted small">Na het kopiëren gaan ze in de bronmailbox naar de prullenbak.</span></span></label>
</div>
<div class="samenvatting">
<span><strong>{{ gekozen.aantal if gekozen.aantal is not none else '?' }}</strong> bericht(en) met label <strong>{{ gekozen.naam }}</strong></span>
<span>{{ bron.email }} <span class="pijl">→</span> {{ doel.email }}</span>
<button type="submit" class="btn-success" {{ 'disabled' if status.bezig }}>▶ Starten</button>
</div>
</form>
{% endif %}
</div>
{% if status.bezig or status.resultaat or status.fout %}
<div class="card" id="voortgangKaart">
<h3 class="card-title">⏳ Opdracht</h3>
{% if status.opdracht %}<p class="muted small" id="opdracht">Label <strong>{{ status.opdracht.label }}</strong>:
{{ status.opdracht.bron }} → {{ status.opdracht.doel }} ({{ 'verplaatsen' if status.opdracht.modus == 'verplaatsen' else 'kopiëren' }})</p>{% endif %}
<div class="voortgang"><div id="balk"></div></div>
<div class="tellers">
<span><b id="tVoortgang">{{ status.voortgang }}</b> / <b id="tTotaal">{{ status.totaal }}</b></span>
<span>✅ <b id="tGelukt">{{ status.gelukt }}</b> gelukt</span>
<span>⏭️ <b id="tOver">{{ status.overgeslagen }}</b> stonden er al</span>
<span>⚠️ <b id="tFout">{{ status.fouten }}</b> mislukt</span>
</div>
<div class="alert alert-success" id="resultaat" style="margin-top:12px" {{ '' if status.resultaat and not status.bezig else 'hidden' }}>{{ status.resultaat or '' }}</div>
<div class="alert alert-danger" id="foutmelding" style="margin-top:12px" {{ '' 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>
{% endif %}
{% if geschiedenis %}
<div class="card">
<h3 class="card-title">🕑 Vorige opdrachten</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Gestart</th><th>Door</th><th>Label</th><th>Van → naar</th><th>Soort</th><th class="text-right">Gelukt</th><th class="text-right">Mislukt</th></tr></thead>
<tbody>
{% for h in geschiedenis %}
<tr title="{{ h.Melding or '' }}">
<td class="nowrap">{{ h.Gestart.strftime('%d-%m-%Y %H:%M') }}{% if not h.Klaar %} <span class="badge badge-warning">bezig</span>{% endif %}</td>
<td>{{ h.Door or '' }}</td>
<td><strong>{{ h.Label }}</strong></td>
<td class="small">{{ h.Bron }} → {{ h.Doel }}</td>
<td>{{ 'Verplaatst' if h.Modus == 'verplaatsen' else 'Gekopieerd' }}</td>
<td class="text-right">{{ h.Gelukt if h.Gelukt is not none else '' }}{% if h.Overgeslagen %} <span class="muted small">(+{{ h.Overgeslagen }})</span>{% endif %}</td>
<td class="text-right">{{ h.Fouten or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<p class="field-hint">Eenmalig nodig in de Google Admin-console (Beveiliging › API-beheer › Domeinbrede delegatie, bij het
serviceaccount van PIVOT): het bereik <code>{{ scope }}</code>. Een onderbroken opdracht kan je gewoon opnieuw starten:
berichten die al gekopieerd werden, worden overgeslagen.</p>
{% endblock %}
{% block scripts %}
<script>
(function () {
const kaart = document.getElementById('voortgangKaart');
if (!kaart) return;
const zet = (id, w) => document.getElementById(id).textContent = w;
function kijk() {
fetch({{ url_for('gmail_labels.status')|tojson }}, { credentials: 'same-origin' }).then(r => r.json()).then(s => {
zet('tVoortgang', s.voortgang); zet('tTotaal', s.totaal); zet('tGelukt', s.gelukt);
zet('tOver', s.overgeslagen); 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('');
if (s.bezig) setTimeout(kijk, 2000);
}).catch(() => setTimeout(kijk, 5000));
}
kijk();
})();
</script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}Google beheer{% endblock %}
{% block page_title %}Google accountbeheer{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Verzoek verwerken...{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🔍 Zoek Google-account</h2>
<div class="actions">
<button type="button" class="add-btn" onclick="openAddModal()">➕ Account toevoegen</button>
</div>
</div>
<form class="search-bar" method="POST" onsubmit="showLoader()">
<input type="text" name="query" class="search-input" placeholder="E-mail of naam..." value="{{ query or '' }}">
<button type="submit" class="btn-search">🔍 Zoek in Google</button>
</form>
{% if results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Primair e-mailadres</th>
<th class="actions">Actie</th>
</tr>
</thead>
<tbody>
{% for user in results %}
<tr>
<td><strong>{{ user.name.fullName }}</strong></td>
<td>{{ user.primaryEmail }}</td>
<td class="actions">
<button type="button" class="btn-danger btn-sm" onclick="openResetModal('{{ user.primaryEmail }}')">🔑 Reset wachtwoord</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif query %}
<p class="muted">Geen resultaten gevonden voor "{{ query }}".</p>
{% endif %}
</div>
{% endblock %}
{% block modals %}
<div id="passwordModal" class="modal-overlay">
<div class="modal-content">
<span class="close" onclick="closeModal('passwordModal')">&times;</span>
<h3>Wachtwoord herstellen</h3>
<p class="muted" id="displayEmail" style="font-weight:600"></p>
<div class="field">
<label for="modalPasswordInput">Nieuw wachtwoord</label>
<div class="input-group">
<input type="text" id="modalPasswordInput" placeholder="Nieuw wachtwoord">
<button type="button" class="btn-light" title="Kopiëren" onclick="copyToClipboard('modalPasswordInput')">📋</button>
<button type="button" class="btn-light" title="Willekeurig genereren" onclick="generateRandomPass('modalPasswordInput')">🎲</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="closeModal('passwordModal')">Annuleren</button>
<button type="button" onclick="submitPasswordReset()">Bevestig reset</button>
</div>
</div>
</div>
<div id="addAccountModal" class="modal-overlay">
<div class="modal-content">
<span class="close" onclick="closeModal('addAccountModal')">&times;</span>
<h3>Nieuw account toevoegen</h3>
<form action="{{ url_for('google.aanmaken_google_account') }}" method="POST" target="_blank" onsubmit="closeModal('addAccountModal')">
<div class="field">
<label for="addOrgUnit">Type account</label>
<select name="account_type" id="addOrgUnit">
<option value="Leerling">Leerling</option>
<option value="Leerkracht">Leerkracht</option>
</select>
</div>
<div class="field">
<label for="addFirstName">Voornaam</label>
<input type="text" name="first_name" id="addFirstName" placeholder="bv. Jan" required>
</div>
<div class="field">
<label for="addLastName">Naam</label>
<input type="text" name="last_name" id="addLastName" placeholder="bv. Peeters" required>
</div>
<div class="field">
<label for="addPassword">Wachtwoord</label>
<div class="input-group">
<input type="text" name="password" id="addPassword" placeholder="Wachtwoord" required>
<button type="button" class="btn-light" title="Willekeurig genereren" onclick="generateRandomPass('addPassword')">🎲</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="closeModal('addAccountModal')">Annuleren</button>
<button type="submit">Aanmaken &amp; PDF openen</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block footer %}
<footer class="app-footer">{{ school_name_short }} – PIVOT – Versie {{ version }} – Google Admin Tools</footer>
{% endblock %}
{% block scripts %}
<script>
let currentEmail = "";
function openResetModal(email) {
currentEmail = email;
document.getElementById('displayEmail').innerText = email;
document.getElementById('passwordModal').style.display = 'flex';
}
function openAddModal() { document.getElementById('addAccountModal').style.display = 'flex'; }
function closeModal(modalId) { document.getElementById(modalId).style.display = 'none'; }
function generateRandomPass(inputId) {
const chars = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%";
let pass = "";
for (let i = 0; i < 12; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById(inputId).value = pass;
}
function copyToClipboard(inputId) {
const passInput = document.getElementById(inputId);
if (!passInput.value) return;
passInput.select();
navigator.clipboard.writeText(passInput.value);
alert("Gekopieerd naar klembord!");
}
function submitPasswordReset() {
const newPass = document.getElementById('modalPasswordInput').value;
if (!newPass) { alert("Voer een wachtwoord in!"); return; }
showLoader();
fetch("{{ url_for('google.apply_password') }}", {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `email=${encodeURIComponent(currentEmail)}&new_password=${encodeURIComponent(newPass)}`
}).then(() => {
window.location.reload();
}).catch(() => {
hideLoader();
alert("Netwerkfout.");
});
}
window.onclick = function (event) {
if (event.target.classList.contains('modal-overlay')) event.target.style.display = 'none';
};
</script>
{% endblock %}
+269
View File
@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% block title %}Google-licenties{% endblock %}
{% block page_title %}Google-licenties{% endblock %}
{% block loader_text %}Licenties ophalen bij Google...{% endblock %}
{% block extra_css %}
<style>
.lic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; margin-bottom: 20px; }
.lic-card { padding: 20px 22px; }
.lic-card + .lic-card { margin-top: 0; }
.lic-name { font-weight: 700; color: var(--primary-color); margin-bottom: 2px; }
.lic-sku { font-size: 12px; color: var(--text-muted); font-family: ui-monospace, Consolas, monospace; }
.lic-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 16px 0 12px; }
.lic-num .val { font-size: 26px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.lic-num .lbl { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; }
.lic-num.free .val { color: var(--success); }
.lic-num.free.low .val { color: #b45309; }
.lic-num.free.over .val { color: var(--danger); }
.meter { height: 10px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.meter > span { display: block; height: 100%; background: var(--primary-color); border-radius: 999px; }
.meter.low > span { background: #f59e0b; }
.meter.over > span { background: var(--danger); }
.lic-foot { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; font-size: 13px; color: var(--text-muted); }
.lic-hint { margin-top: 10px; font-size: 13px; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.filters input[type=search] { flex: 1 1 240px; width: auto; }
.filters select { width: auto; min-width: 200px; }
.result-count { font-size: 13px; color: var(--text-muted); margin-top: 8px; }
tr.flagged td { background: #fffbeb; }
</style>
{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2 style="margin:0">🎓 Google Workspace for Education-licenties</h2>
{% if opgehaald %}<div class="muted small">Gegevens van Google opgehaald om {{ opgehaald }}</div>{% endif %}
</div>
<div class="actions">
<button type="button" class="btn-light" onclick="document.getElementById('purchasedModal').style.display='block'">✏️ Aangekochte aantallen</button>
{% if data %}<a class="btn btn-light" href="{{ url_for('google_licenties.export_csv') }}">⬇️ Export CSV</a>{% endif %}
<a class="btn" href="{{ url_for('google_licenties.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Vernieuwen</a>
</div>
</div>
{% if fout %}
<div class="flash-message error"><span><strong>Licenties konden niet opgehaald worden.</strong><br>{{ fout }}</span></div>
{% endif %}
{% if data %}
<div class="lic-grid">
{% for s in data.skus %}
{% set state = 'over' if s.aangekocht and s.vrij < 0 else ('low' if s.procent is not none and s.procent >= 90 else '') %}
<div class="card lic-card">
<div class="lic-name">{{ s.naam }}</div>
<div class="lic-sku">SKU {{ s.sku_id }}</div>
<div class="lic-numbers">
<div class="lic-num"><div class="val">{{ s.in_gebruik }}</div><div class="lbl">In gebruik</div></div>
<div class="lic-num"><div class="val">{{ s.aangekocht if s.aangekocht else '–' }}</div><div class="lbl">Aangekocht</div></div>
<div class="lic-num free {{ state }}"><div class="val">{{ s.vrij if s.aangekocht else '–' }}</div><div class="lbl">{{ 'Tekort' if state == 'over' else 'Vrij' }}</div></div>
</div>
{% if s.aangekocht %}
<div class="meter {{ state }}"><span style="width: {{ [s.procent, 100]|min }}%"></span></div>
<div class="lic-foot">
<span>{{ s.procent }}% gebruikt</span>
{% if s.bijgewerkt %}<span title="Aantal aangepast door {{ s.bijgewerkt_door or '?' }}">aantal: {{ s.bijgewerkt.strftime('%d-%m-%Y') }}</span>{% endif %}
</div>
{% else %}
<div class="lic-hint muted">Vul het aangekochte aantal in om te zien hoeveel er nog vrij zijn.</div>
{% endif %}
{% if state == 'over' %}
<div class="alert alert-danger lic-hint" style="margin-bottom:0">{{ -s.vrij }} licentie(s) meer toegewezen dan aangekocht.</div>
{% endif %}
{% if s.geschorst %}
<div class="alert alert-warning lic-hint" style="margin-bottom:0">
{{ s.geschorst }} op geschorste of gearchiveerde accounts –
<a href="#" onclick="filterFlagged('{{ s.sku_id }}'); return false;">bekijken</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="card">
<div class="page-head" style="margin-bottom: 12px;"><h3 style="margin:0">Toegewezen licenties</h3></div>
<div class="filters">
<input type="search" id="zoek" placeholder="Zoek op naam, e-mail of organisatie-eenheid..." oninput="applyFilters()">
<select id="skuFilter" onchange="applyFilters()">
<option value="">Alle licenties</option>
{% for s in data.skus if s.in_gebruik %}<option value="{{ s.sku_id }}">{{ s.naam }} ({{ s.in_gebruik }})</option>{% endfor %}
</select>
<label class="check-box" style="margin:0; padding:8px 12px"><input type="checkbox" id="flagFilter" onchange="applyFilters()"> Enkel geschorst / gearchiveerd</label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0" data-type="tekst">Naam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1" data-type="tekst">E-mail <span class="pijl"></span></th>
<th class="sorteer" data-kol="2" data-type="tekst">Organisatie-eenheid <span class="pijl"></span></th>
<th class="sorteer" data-kol="3" data-type="tekst">Licentie <span class="pijl"></span></th>
<th class="sorteer" data-kol="4" data-type="vast">Laatste login <span class="pijl"></span></th>
<th class="sorteer" data-kol="5" data-type="vast">Account <span class="pijl"></span></th>
</tr>
</thead>
<tbody id="licBody">
{% for u in data.users %}
<tr data-sku="{{ u.sku_id }}" data-flag="{{ 1 if (u.geschorst or u.gearchiveerd) else 0 }}" class="{{ 'flagged' if (u.geschorst or u.gearchiveerd) else '' }}">
<td data-sort="{{ u.naam|lower }}">{{ u.naam }}</td>
<td data-sort="{{ u.email|lower }}">{{ u.email }}</td>
<td class="muted" data-sort="{{ u.ou|lower }}">{{ u.ou }}</td>
<td data-sort="{{ u.sku_naam|lower }}"><span class="badge badge-info">{{ u.sku_naam }}</span></td>
<td class="nowrap" data-sort="{{ u.laatste_login or '' }}">{{ u.laatste_login or 'Nooit' }}</td>
{# volgorde bij sorteren: actief → geschorst → gearchiveerd → onbekend #}
<td data-sort="{{ '3' if u.gearchiveerd else ('2' if u.geschorst else ('4' if u.onbekend else '1')) }}">
{% if u.gearchiveerd %}<span class="badge badge-warning">Gearchiveerd</span>
{% elif u.geschorst %}<span class="badge badge-warning">Geschorst</span>
{% elif u.onbekend %}<span class="badge">Onbekend</span>
{% else %}<span class="badge badge-success">Actief</span>{% endif %}
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Er zijn nog geen Education-licenties toegewezen.</td></tr>
{% endfor %}
<tr class="empty-row hidden" id="noMatch"><td colspan="6">Geen licenties gevonden voor deze filter.</td></tr>
</tbody>
</table>
</div>
<div class="lic-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="result-count" id="resultCount"></div>
<span id="perPaginaKiezer"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
</div>
{% endif %}
{% endblock %}
{% block modals %}
<div id="purchasedModal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('purchasedModal').style.display='none'">&times;</span>
<h3>Aangekochte licenties</h3>
<p class="muted small">Google geeft het aantal aangekochte licenties niet door via de API. Vul hier het aantal in dat in de Admin-console (Facturering › Abonnementen) of op je factuur staat.</p>
<form method="POST" action="{{ url_for('google_licenties.aangekocht_opslaan') }}">
{% set shown = (data.skus|map(attribute='sku_id')|list) if data else ['1010310008'] %}
{% for sku_id in shown %}
{% set cur = (data.skus|selectattr('sku_id', 'equalto', sku_id)|first) if data else none %}
<div class="field">
<label for="aangekocht_{{ sku_id }}">{{ sku_names.get(sku_id, sku_id) }}</label>
<input type="number" min="0" step="1" id="aangekocht_{{ sku_id }}" name="aangekocht_{{ sku_id }}"
value="{{ cur.aangekocht if cur and cur.aangekocht else '' }}" placeholder="Aantal">
</div>
{% endfor %}
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="document.getElementById('purchasedModal').style.display='none'">Annuleren</button>
<button type="submit">💾 Opslaan</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
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; }
.lic-voet { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.lic-voet .result-count { margin: 0; }
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
</style>
<script>
let PER_PAGINA = perPagina('licenties');
let pagina = 1, sortKol = null, sortOp = true;
function rijen() { return Array.from(document.querySelectorAll('#licBody tr[data-sku]')); }
// Filteren → sorteren → pagineren (alles in de browser; Google wordt niet opnieuw bevraagd)
function applyFilters(houdPagina) {
if (!houdPagina) pagina = 1;
const q = (document.getElementById('zoek')?.value || '').toLowerCase();
const sku = document.getElementById('skuFilter')?.value || '';
const flagged = document.getElementById('flagFilter')?.checked;
const alle = rijen();
const passend = alle.filter(tr => (!q || tr.textContent.toLowerCase().includes(q))
&& (!sku || tr.dataset.sku === sku)
&& (!flagged || tr.dataset.flag === '1'));
if (sortKol !== null) {
const tekst = document.querySelector(`th.sorteer[data-kol="${sortKol}"]`).dataset.type === 'tekst';
passend.sort((a, b) => {
const x = a.cells[sortKol].dataset.sort || '', y = b.cells[sortKol].dataset.sort || '';
const c = tekst ? x.localeCompare(y, 'nl') : (x < y ? -1 : x > y ? 1 : 0);
return sortOp ? c : -c;
});
const body = document.getElementById('licBody'), nm = document.getElementById('noMatch');
passend.forEach(tr => body.insertBefore(tr, nm));
}
const grootte = PER_PAGINA || Infinity;
const paginas = Math.max(1, Math.ceil(passend.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte; // 0 = alles
alle.forEach(tr => tr.style.display = 'none');
passend.slice(van, tot).forEach(tr => tr.style.display = '');
const nm = document.getElementById('noMatch');
if (nm) nm.classList.toggle('hidden', !(alle.length && !passend.length));
const rc = document.getElementById('resultCount');
if (rc && alle.length) {
const bereik = passend.length ? `${van + 1}–${Math.min(tot, passend.length)} van ` : '';
rc.textContent = passend.length === alle.length ? `${bereik}${alle.length} licenties`
: `${bereik}${passend.length} gevonden (${alle.length} licenties in totaal)`;
}
tekenPaginering(paginas);
}
function tekenPaginering(paginas) {
const nav = document.getElementById('paginering');
if (!nav) return;
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
if (actief) b.setAttribute('aria-current', 'page');
b.onclick = () => { pagina = p; applyFilters(true); document.getElementById('licBody').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const kol = +th.dataset.kol;
sortOp = sortKol === kol ? !sortOp : true;
sortKol = kol;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(sortOp ? 'op' : 'af');
applyFilters();
}));
function filterFlagged(sku) {
document.getElementById('skuFilter').value = sku;
document.getElementById('flagFilter').checked = true;
applyFilters();
document.getElementById('licBody').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
window.onclick = function (e) { if (e.target.classList.contains('modal')) e.target.style.display = 'none'; };
document.addEventListener('DOMContentLoaded', () => {
perPaginaKiezer(document.getElementById('perPaginaKiezer'), 'licenties', n => { PER_PAGINA = n; applyFilters(); });
applyFilters();
});
</script>
{% endblock %}
+460
View File
@@ -0,0 +1,460 @@
{% extends "base.html" %}
{% block title %}GO! Pro{% endblock %}
{% block page_title %}GO! Pro{% endblock %}
{% block loader_text %}Personeel ophalen uit Informat...{% endblock %}
{% block extra_css %}
<style>
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; }
.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; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.filter-bar input[type=search] { flex: 1 1 260px; }
td.mail { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .86em; }
input.adres { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84em; padding: 5px 7px;
min-width: 250px; background: transparent; }
input.adres.aangepast { border-color: var(--primary-color); font-weight: 600; }
/* Kleur per rij volgens Google Workspace */
#personeelBody tr.g-account td { background: #e8f7ee; }
#personeelBody tr.g-alias td { background: #fff1e0; }
#personeelBody tr.g-geen td { background: #fdeaea; }
#personeelBody tr[class*="g-"] input.adres, #personeelBody tr[class*="g-"] select.rol { background: #fff; }
.legende { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; margin: -6px 0 14px; font-size: .88em; color: var(--text-muted); }
.legende button.kleurfilter { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center;
gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid transparent; }
.legende button.kleurfilter:hover { border-color: var(--border); color: var(--text, #1e293b); }
.legende button.kleurfilter:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
.legende button.kleurfilter.actief { border-color: var(--primary-color); color: var(--primary-color); font-weight: 600;
background: var(--surface); }
.legende b { font-weight: 600; font-variant-numeric: tabular-nums; }
.legende b:not(:empty)::before { content: "("; } .legende b:not(:empty)::after { content: ")"; }
.legende i { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.legende i.account { background: #e8f7ee; border: 1px solid #a7dcb9; }
.legende i.alias { background: #fff1e0; border: 1px solid #f3c48d; }
.legende i.geen { background: #fdeaea; border: 1px solid #f0a8a8; }
select.rol { min-width: 150px; max-width: 190px; padding: 5px 6px; }
th.breekt { white-space: normal; min-width: 90px; }
select.rol.anders { border-color: var(--primary-color); font-weight: 600; }
.rol-status { display: inline-block; width: 18px; margin-left: 4px; font-size: .9em; }
td.nowrap { white-space: nowrap; }
button.negeer { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px;
border-radius: 6px; font-size: 13px; opacity: .45; margin-right: 4px; vertical-align: middle; }
button.negeer:hover, button.negeer:focus-visible { opacity: 1; background: #fee2e2; outline: none; }
tr.weg { opacity: .3; transition: opacity .25s; }
button.verplaats { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px;
border-radius: 6px; font-size: 15px; font-weight: 800; color: #16a34a; margin-right: 2px; vertical-align: middle; }
button.verplaats:hover, button.verplaats:focus-visible { background: #dcfce7; outline: none; }
.vp-raster { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.vp-raster .field { margin-bottom: 12px; }
.vp-raster .breed { grid-column: 1 / -1; }
@media (max-width: 560px) { .vp-raster { grid-template-columns: 1fr; } }
#vpFout { display: none; margin: 4px 0 0; }
.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: 16px; }
.nacht.aan { border-color: var(--primary-color); background: var(--primary-light); }
.nacht strong { display: block; }
.nacht-acties { display: flex; gap: 12px; align-items: center; }
.schakelaar { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
.schakelaar .spoor { width: 44px; height: 24px; border-radius: 999px; background: #cbd5e1; position: relative; transition: background .15s; flex-shrink: 0; }
.schakelaar .spoor::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.schakelaar.aan .spoor { background: var(--primary-color); }
.schakelaar.aan .spoor::after { transform: translateX(20px); }
.schakelaar button { all: unset; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.schakelaar button:focus-visible .spoor { outline: 2px solid var(--primary-color); outline-offset: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🪪 Personeel {{ school_naam }}</h2>
<div class="actions">
{% if rijen %}
<form method="POST" action="{{ url_for('gopro.verzamel') }}" style="margin:0"
onsubmit="if (!confirm('Alle groene personeelsleden (account bestaat in Google) van deze school naar de verzameltabel zetten? Ze verdwijnen dan uit deze lijst.')) return false; showLoader('Verzamelen...');">
<button type="submit" class="btn-success btn-sm">➕ Groene naar verzameltabel</button>
</form>
<a class="btn btn-light btn-sm" id="exportKnop" href="{{ url_for('gopro.export') }}">⬇️ CSV voor GO! Pro</a>
{% endif %}
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel') }}"><span id="groenTeller">📚 Verzameltabel ({{ verzameld_totaal }})</span></a>
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel', lijst='rood') }}"><span id="roodTeller">🚫 Rode lijst ({{ genegeerd_totaal }})</span></a>
<a class="btn btn-light btn-sm" href="{{ url_for('gopro.verzameltabel', lijst='verzonden') }}" title="Personeelsleden van {{ school_naam }} die al naar GO! Pro verzonden zijn">📨 Verzonden van deze school ({{ verzonden_school }})</a>
<a class="btn btn-secondary btn-sm" href="{{ url_for('gopro.overzicht', vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
<div class="nacht {{ 'aan' if nacht.aan }}">
<div>
<strong>🌙 Elke nacht automatisch verzamelen</strong>
<span class="muted small">
{% if nacht.aan %}Staat <b>aan</b>: elke nacht tussen 2 en 6 uur worden van alle scholen de groene personeelsleden
naar de verzameltabel gezet (wie op de rode lijst staat niet){% if nacht.door %} · aangezet door {{ nacht.door }}{% endif %}.
{% else %}Staat uit: enkel met de knop ➕ Groene naar verzameltabel, per school.{% endif %}
{% if nacht.bezig %}<br>⏳ Er loopt nu een verzameling voor alle scholen…
{% elif nacht.resultaat %}<br>Laatste keer ({{ nacht.resultaat_op.strftime('%d-%m-%Y %H:%M') if nacht.resultaat_op else '?' }}): {{ nacht.resultaat }}{% endif %}
</span>
</div>
<div class="nacht-acties">
<form method="POST" action="{{ url_for('gopro.nachtelijk') }}" style="margin:0"
onsubmit="if (!confirm('Nu meteen van alle scholen de groene personeelsleden naar de verzameltabel zetten?')) return false;">
<input type="hidden" name="nu" value="1">
<button type="submit" class="btn-light btn-sm" {{ 'disabled' if nacht.bezig }}>▶ Nu voor alle scholen</button>
</form>
<form method="POST" action="{{ url_for('gopro.nachtelijk') }}" style="margin:0">
<input type="hidden" name="aan" value="{{ '0' if nacht.aan else '1' }}">
<span class="schakelaar {{ 'aan' if nacht.aan }}">
<button type="submit" role="switch" aria-checked="{{ 'true' if nacht.aan else 'false' }}">
<span class="spoor"></span> {{ 'Aan' if nacht.aan else 'Uit' }}
</button>
</span>
</form>
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% else %}
<div class="legende" role="group" aria-label="Legende en filter">
<button type="button" class="kleurfilter actief" data-kleur="" aria-pressed="true">Alle <b data-tel=""></b></button>
<button type="button" class="kleurfilter" data-kleur="account" aria-pressed="false"><i class="account"></i> Account bestaat in Google <b data-tel="account"></b></button>
<button type="button" class="kleurfilter" data-kleur="alias" aria-pressed="false"><i class="alias"></i> Adres is een alias van een ander account <b data-tel="alias"></b></button>
<button type="button" class="kleurfilter" data-kleur="geen" aria-pressed="false"><i class="geen"></i> Geen account gevonden in Google <b data-tel="geen"></b></button>
</div>
{% if not domein %}
<div class="alert alert-warning">Deze school heeft nog geen e-maildomein: vul het in via 🏫 Alle scholen beheren › Bewerken.</div>
{% endif %}
{% if verzameld_hier.groen or verzameld_hier.genegeerd or verzameld_hier.verzonden %}
<div class="alert alert-info">Niet meer getoond:
{% if verzameld_hier.groen %}{{ verzameld_hier.groen }} in de <a href="{{ url_for('gopro.verzameltabel') }}">verzameltabel</a>{% endif %}
{%- if verzameld_hier.genegeerd %}{{ ', ' if verzameld_hier.groen }}{{ verzameld_hier.genegeerd }} op de <a href="{{ url_for('gopro.verzameltabel', lijst='rood') }}">rode lijst</a>{% endif -%}
{%- if verzameld_hier.verzonden %}{{ ', ' if verzameld_hier.groen or verzameld_hier.genegeerd }}{{ verzameld_hier.verzonden }} al <a href="{{ url_for('gopro.verzameltabel', lijst='verzonden') }}">verzonden</a>{% endif -%}
.</div>
{% endif %}
{% if dubbel %}
<div class="alert alert-warning">⚠️ {{ dubbel }} rij(en) met een e-mailadres dat ook bij een ander personeelslid voorkomt (zelfde naam). Die zijn gemarkeerd.</div>
{% endif %}
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek op naam, e-mail, functie of rol..." aria-label="Zoeken">
<span class="muted small">Opgehaald uit Informat op {{ opgehaald }}</span>
</div>
<div class="filter-bar" style="margin-top:-4px">
<span class="muted small" id="googleStatus">Controleren in Google…</span>
<button type="button" class="btn-sm btn-light" id="googleKnop">🔄 Opnieuw controleren</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Voornaam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Achternaam <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">E-mail <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">UPN <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">Functie <span class="pijl"></span></th>
<th class="sorteer" data-kol="5">Rol <span class="pijl"></span></th>
<th class="sorteer breekt" data-kol="6">Startdatum functie <span class="pijl"></span></th>
<th class="sorteer breekt" data-kol="7">Einddatum functie <span class="pijl"></span></th>
<th class="sorteer" data-kol="8">Telefoon <span class="pijl"></span></th>
</tr>
</thead>
<tbody id="personeelBody">
{% for r in rijen %}
<tr>
<td data-sort="{{ r.voornaam }}" class="nowrap"><button type="button" class="verplaats" data-person="{{ r.person_id }}"
data-voornaam="{{ r.voornaam }}" data-achternaam="{{ r.achternaam }}" data-instelling="{{ r.instellingsnummer }}"
data-start="{{ r.start.isoformat() if r.start else '' }}" data-einde="{{ r.einde.isoformat() if r.einde else '' }}"
data-telefoon="{{ r.telefoon }}"
title="{{ r.voornaam }} {{ r.achternaam }} nakijken en naar de verzameltabel"
aria-label="{{ r.voornaam }} {{ r.achternaam }} naar de verzameltabel">+</button><button type="button" class="negeer" data-person="{{ r.person_id }}"
title="{{ r.voornaam }} {{ r.achternaam }} naar de rode lijst (negeren: niet exporteren en niet meer tonen)"
aria-label="{{ r.voornaam }} {{ r.achternaam }} negeren">🚫</button> {{ r.voornaam }}</td>
<td data-sort="{{ r.achternaam }}"><strong>{{ r.achternaam }}</strong></td>
{% for veld in ('email', 'upn') %}
<td class="nowrap" data-sort="{{ r[veld] }}">
<input type="text" class="adres {{ 'aangepast' if r[veld ~ '_aangepast'] }}" value="{{ r[veld] }}"
data-person="{{ r.person_id }}" data-veld="{{ veld }}" data-oud="{{ r[veld] }}"
aria-label="{{ 'E-mail' if veld == 'email' else 'UPN' }} van {{ r.voornaam }} {{ r.achternaam }}"
title="Leeg maken = terug naar het standaardadres"><span class="rol-status" aria-live="polite"></span>
{% if veld == 'email' and r.dubbel %}<span class="badge badge-warning" title="Dit adres komt meer dan één keer voor">dubbel</span>{% endif %}
</td>
{% endfor %}
{% for veld, label in (('functie', 'Functie'), ('rol', 'Rol')) %}
<td data-sort="{{ r[veld] }}" class="nowrap">
<select class="rol {{ 'anders' if r[veld] != 'Leerkracht' }}" aria-label="{{ label }} van {{ r.voornaam }} {{ r.achternaam }}"
data-person="{{ r.person_id }}" data-veld="{{ veld }}">
{% for rol in rollen %}<option {% if rol == r[veld] %}selected{% endif %}>{{ rol }}</option>{% endfor %}
</select><span class="rol-status" aria-live="polite"></span>
</td>
{% endfor %}
<td class="nowrap" data-sort="{{ r.start.isoformat() if r.start else '' }}">{{ r.start.strftime('%d-%m-%Y') if r.start else '' }}</td>
<td class="nowrap" data-sort="{{ r.einde.isoformat() if r.einde else '' }}">{{ r.einde.strftime('%d-%m-%Y') if r.einde else '' }}</td>
<td class="nowrap" data-sort="{{ r.telefoon }}">{{ r.telefoon }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="9">Informat gaf geen actieve personeelsleden terug.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="teller"></div>
<span id="perPagina"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
<div id="vpVenster" class="modal" role="dialog" aria-modal="true" aria-labelledby="vpTitel">
<div class="modal-content wide">
<span class="close" id="vpSluit" title="Annuleren">&times;</span>
<h3 id="vpTitel">Naar de verzameltabel</h3>
<form id="vpForm" autocomplete="off">
<input type="hidden" name="person_id">
<div class="vp-raster">
<div class="field"><label for="vpVoornaam">Voornaam</label><input id="vpVoornaam" name="voornaam" required maxlength="100"></div>
<div class="field"><label for="vpAchternaam">Achternaam</label><input id="vpAchternaam" name="achternaam" required maxlength="100"></div>
<div class="field breed"><label for="vpEmail">E-mailadres (aanmelden en communicatie)</label><input id="vpEmail" name="email" type="email" maxlength="255"></div>
<div class="field breed"><label for="vpUpn">UPN</label><input id="vpUpn" name="upn" maxlength="255"></div>
<div class="field"><label for="vpFunctie">Functie</label><select id="vpFunctie" name="functie">{% for rol in rollen %}<option>{{ rol }}</option>{% endfor %}</select></div>
<div class="field"><label for="vpRol">Rol</label><select id="vpRol" name="rol">{% for rol in rollen %}<option>{{ rol }}</option>{% endfor %}</select></div>
<div class="field"><label for="vpStart">Startdatum functie</label><input id="vpStart" name="start" type="date" readonly title="In de verzameltabel heeft iedereen het lopende schooljaar"></div>
<div class="field"><label for="vpEinde">Einddatum functie</label><input id="vpEinde" name="einde" type="date" readonly title="In de verzameltabel heeft iedereen het lopende schooljaar"></div>
<div class="field"><label for="vpInstelling">Onderwijsinstelling nr.</label><input id="vpInstelling" name="instellingsnummer" maxlength="20"></div>
<div class="field"><label for="vpTelefoon">Professioneel telefoonnummer</label><input id="vpTelefoon" name="telefoon" maxlength="50"></div>
</div>
<div class="alert alert-danger" id="vpFout"></div>
<div class="modal-footer">
<button type="button" class="btn-light" id="vpAnnuleer">Annuleren</button>
<button type="submit" class="btn-success" id="vpBewaar">💾 Bewaren en naar verzameltabel</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Welke adressen bestaan al in Google? Die rijen worden lichtgroen.
let googleAdressen = null; // {adres: 'primair' | 'alias'} zodra Google geantwoord heeft
let kleurFilter = ''; // '' = alle, of 'account' / 'alias' / 'geen'
let herteken = () => {}; // wordt hieronder de functie die de tabel opnieuw toont
let hertoon = () => {}; // idem, maar op dezelfde pagina blijven
function kleurRij(tr) {
if (!googleAdressen) return;
const soorten = Array.from(tr.querySelectorAll('input.adres')).map(i => googleAdressen[i.value.trim().toLowerCase()]);
const soort = soorten.includes('primair') ? 'account' : soorten.includes('alias') ? 'alias' : 'geen';
tr.classList.remove('g-account', 'g-alias', 'g-geen');
tr.classList.add('g-' + soort);
tr.title = {account: 'Account bestaat in Google', alias: 'Dit adres is een alias van een ander account in Google',
geen: 'Geen account gevonden in Google'}[soort];
}
function controleerGoogle(vernieuw) {
const status = document.getElementById('googleStatus');
if (!status) return;
status.textContent = 'Controleren in Google…';
const adressen = Array.from(document.querySelectorAll('input.adres')).map(i => i.value.trim().toLowerCase()).filter(Boolean);
fetch("{{ url_for('gopro.google_check') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({adressen, vernieuw: !!vernieuw})
}).then(r => r.json()).then(res => {
if (!res.ok) { status.textContent = '⚠️ ' + res.fout; return; }
googleAdressen = Object.assign(googleAdressen || {}, res.adressen);
const rijen = Array.from(document.querySelectorAll('#personeelBody tr')).filter(tr => tr.querySelector('input.adres'));
rijen.forEach(kleurRij);
const tel = k => rijen.filter(tr => tr.classList.contains('g-' + k)).length;
status.textContent = `${tel('account')} met account, ${tel('alias')} via een alias, ${tel('geen')} zonder account in Google.`;
document.querySelectorAll('.legende b[data-tel]').forEach(b => { b.textContent = b.dataset.tel ? tel(b.dataset.tel) : rijen.length; });
herteken();
}).catch(() => { status.textContent = '⚠️ Google kon niet bevraagd worden.'; });
}
// 🚫 naast de naam: personeelslid naar de rode lijst (negeren) en uit de tabel halen
function telLegende() {
const rijen = Array.from(document.querySelectorAll('#personeelBody tr')).filter(tr => tr.querySelector('input.adres'));
const tel = k => rijen.filter(tr => tr.classList.contains('g-' + k)).length;
document.querySelectorAll('.legende b[data-tel]').forEach(b => { b.textContent = b.dataset.tel ? tel(b.dataset.tel) : rijen.length; });
const status = document.getElementById('googleStatus');
if (status && googleAdressen) status.textContent = `${tel('account')} met account, ${tel('alias')} via een alias, ${tel('geen')} zonder account in Google.`;
}
// + naast de naam: venster met alle gegevens; bewaren = met de aanpassingen naar de verzameltabel
(function () {
const venster = document.getElementById('vpVenster'), form = document.getElementById('vpForm');
const fout = document.getElementById('vpFout'), bewaar = document.getElementById('vpBewaar');
let huidigeRij = null;
const sluit = () => { venster.style.display = 'none'; huidigeRij = null; };
document.querySelectorAll('button.verplaats').forEach(knop => knop.addEventListener('click', () => {
const tr = knop.closest('tr'), d = knop.dataset;
const veld = v => tr.querySelector(`[data-veld="${v}"]`);
huidigeRij = tr;
form.person_id.value = d.person;
form.voornaam.value = d.voornaam; form.achternaam.value = d.achternaam;
form.email.value = veld('email').value.trim(); form.upn.value = veld('upn').value.trim();
form.functie.value = veld('functie').value; form.rol.value = veld('rol').value;
form.start.value = {{ periode[0].isoformat()|tojson }}; form.einde.value = {{ periode[1].isoformat()|tojson }};
form.instellingsnummer.value = d.instelling; form.telefoon.value = d.telefoon;
document.getElementById('vpTitel').textContent = `${d.voornaam} ${d.achternaam} naar de verzameltabel`;
fout.style.display = 'none'; bewaar.disabled = false;
venster.style.display = 'block';
form.voornaam.focus();
}));
document.getElementById('vpSluit').addEventListener('click', sluit);
document.getElementById('vpAnnuleer').addEventListener('click', sluit);
venster.addEventListener('click', e => { if (e.target === venster) sluit(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && venster.style.display === 'block') sluit(); });
form.addEventListener('submit', e => {
e.preventDefault();
const gegevens = Object.fromEntries(new FormData(form).entries());
bewaar.disabled = true; fout.style.display = 'none';
fetch("{{ url_for('gopro.verplaats') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(gegevens)
}).then(r => r.json()).then(res => {
if (!res.ok) { fout.textContent = res.fout || 'Mislukt.'; fout.style.display = 'block'; bewaar.disabled = false; return; }
const tr = huidigeRij;
sluit();
if (tr) tr.remove();
const t = document.getElementById('groenTeller'); if (t) t.textContent = `📚 Verzameltabel (${res.totaal})`;
telLegende(); hertoon();
}).catch(() => { fout.textContent = 'Mislukt: geen verbinding met PIVOT.'; fout.style.display = 'block'; bewaar.disabled = false; });
});
})();
document.querySelectorAll('button.negeer').forEach(knop => knop.addEventListener('click', () => {
const tr = knop.closest('tr');
tr.classList.add('weg');
fetch("{{ url_for('gopro.negeer') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: knop.dataset.person})
}).then(r => r.json()).then(res => {
if (!res.ok) { tr.classList.remove('weg'); alert(res.fout || 'Mislukt.'); return; }
tr.remove();
const t = document.getElementById('roodTeller'); if (t) t.textContent = `🚫 Rode lijst (${res.totaal})`;
telLegende(); hertoon();
}).catch(() => { tr.classList.remove('weg'); alert('Mislukt: geen verbinding met PIVOT.'); });
}));
const gk = document.getElementById('googleKnop');
if (gk) gk.addEventListener('click', () => controleerGoogle(true));
// E-mail of UPN wijzigen: bewaren bij het verlaten van het veld (of Enter)
document.querySelectorAll('input.adres').forEach(inp => {
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); inp.blur(); } });
inp.addEventListener('change', () => {
const status = inp.nextElementSibling;
status.textContent = '…';
fetch("{{ url_for('gopro.keuze') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: inp.dataset.person, veld: inp.dataset.veld, waarde: inp.value})
}).then(r => r.json()).then(res => {
if (!res.ok) {
status.textContent = '⚠️'; status.title = res.fout || 'Niet opgeslagen';
alert(res.fout || 'Niet opgeslagen'); inp.value = inp.dataset.oud; return;
}
const leeg = !inp.value.trim();
if (res.waarde !== undefined && res.waarde !== null) inp.value = res.waarde;
inp.dataset.oud = inp.value;
inp.classList.toggle('aangepast', !leeg);
inp.closest('td').dataset.sort = inp.value;
status.textContent = '✓'; status.title = 'Opgeslagen';
setTimeout(() => { status.textContent = ''; }, 2000);
if (googleAdressen) controleerGoogle(false); // ook het nieuwe adres laten nakijken
}).catch(() => { status.textContent = '⚠️'; status.title = 'Niet opgeslagen'; inp.value = inp.dataset.oud; });
});
});
controleerGoogle(false);
// Functie of rol wijzigen: meteen bewaren
document.querySelectorAll('select.rol').forEach(sel => sel.addEventListener('change', () => {
const status = sel.nextElementSibling;
status.textContent = '…';
fetch("{{ url_for('gopro.keuze') }}", {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({person_id: sel.dataset.person, veld: sel.dataset.veld, waarde: sel.value})
}).then(r => r.json()).then(res => {
status.textContent = res.ok ? '✓' : '⚠️';
status.title = res.ok ? 'Opgeslagen' : (res.fout || 'Niet opgeslagen');
sel.classList.toggle('anders', sel.value !== 'Leerkracht');
sel.closest('td').dataset.sort = sel.value;
if (res.ok) setTimeout(() => { status.textContent = ''; }, 2000);
}).catch(() => { status.textContent = '⚠️'; status.title = 'Niet opgeslagen'; });
}));
// Zoeken, sorteren en pagineren in de browser
(function () {
const body = document.getElementById('personeelBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('gopro');
let pagina = 1, kol = null, op = true, zoek = '';
const tekst = tr => Array.from(tr.cells).map(td => td.dataset.sort || td.textContent).join(' ').toLowerCase();
function zichtbaar() {
return Array.from(body.rows).filter(tr => (!zoek || tekst(tr).includes(zoek))
&& (!kleurFilter || tr.classList.contains('g-' + kleurFilter)));
}
function toon() {
const rijen = zichtbaar();
const grootte = PER_PAGINA || Infinity;
const 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} rij(en)` : 'Geen resultaten';
const nav = document.getElementById('paginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); body.closest('.table-wrap').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : true; 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', n => { PER_PAGINA = n; pagina = 1; toon(); });
herteken = () => { pagina = 1; toon(); };
hertoon = () => toon();
document.querySelectorAll('button.kleurfilter').forEach(b => b.addEventListener('click', () => {
kleurFilter = (b.dataset.kleur && kleurFilter !== b.dataset.kleur) ? b.dataset.kleur : '';
const exp = document.getElementById('exportKnop'); // export volgt de kleurkeuze
if (exp) exp.href = "{{ url_for('gopro.export') }}" + (kleurFilter ? '?kleur=' + kleurFilter : '');
document.querySelectorAll('button.kleurfilter').forEach(x => {
const aan = x.dataset.kleur === kleurFilter;
x.classList.toggle('actief', aan); x.setAttribute('aria-pressed', String(aan));
});
herteken();
}));
toon();
})();
</script>
{% endblock %}
+291
View File
@@ -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=&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 %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}CO2-verloop {{ lokaal }}{% endblock %}
{% block page_title %}CO2-verloop: {{ lokaal }}{% endblock %}
{% block main_class %}medium{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>📊 {{ lokaal }}</h2>
<div class="actions">
<a href="{{ url_for('co2_sensor.dashboard') }}" class="btn btn-light">⬅ Terug</a>
<button type="button" onclick="downloadChart()" class="btn-success">💾 Download als JPEG</button>
</div>
</div>
<div id="chart-wrapper" style="width: 100%; height: 420px; background: #fff;">
<canvas id="co2Chart"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ctx = document.getElementById('co2Chart').getContext('2d');
// Witte achtergrond zodat de JPEG-export niet zwart wordt
const whiteBackgroundPlugin = {
id: 'custom_canvas_background_color',
beforeDraw: (chart) => {
const c = chart.canvas.getContext('2d');
c.save();
c.globalCompositeOperation = 'destination-over';
c.fillStyle = 'white';
c.fillRect(0, 0, chart.width, chart.height);
c.restore();
}
};
const co2Chart = new Chart(ctx, {
type: 'line',
data: {
labels: {{ labels|tojson }},
datasets: [{
label: 'CO2-gehalte (ppm)',
data: {{ data|tojson }},
borderColor: '#1e6fa5',
backgroundColor: 'rgba(30, 111, 165, 0.1)',
borderWidth: 2,
pointRadius: 2,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: false, suggestedMin: 400, title: { display: true, text: 'ppm' }, grid: { color: '#eef2f6' } },
x: { title: { display: true, text: 'Tijdstip' }, grid: { display: false } }
}
},
plugins: [whiteBackgroundPlugin]
});
function downloadChart() {
const canvas = document.getElementById('co2Chart');
const link = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 10);
link.download = `CO2_Grafiek_{{ lokaal }}_${timestamp}.jpg`;
link.href = canvas.toDataURL('image/jpeg', 0.9);
link.click();
}
</script>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{{ h.Titel }}{% endblock %}
{% block page_title %}Handleidingen{% endblock %}
{% block extra_css %}
{% include "_handleiding_stijl.html" %}
<style>
.hl-layout { display: grid; grid-template-columns: 1fr 230px; gap: 28px; align-items: start; }
.hl-toc { position: sticky; top: 14px; border-left: 2px solid var(--border); padding-left: 14px; font-size: .9rem; }
.hl-toc strong { display: block; margin-bottom: 8px; font-size: .78rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: .05em; }
.hl-toc a { display: block; padding: 4px 0; color: var(--text-muted); text-decoration: none; }
.hl-toc a:hover { color: var(--primary-color); }
.hl-toc a.n3 { padding-left: 12px; font-size: .85rem; }
.hl-meta { color: var(--text-muted); font-size: .85rem; margin: -6px 0 18px; }
.terug { margin-bottom: 14px; display: inline-block; }
@media (max-width: 900px) { .hl-layout { grid-template-columns: 1fr; } .hl-toc { display: none; } }
@media print {
.app-header, .app-footer, .terug, .hl-toc, .page-head .actions, .flash-container, .alert { display: none !important; }
.card { box-shadow: none; border: 0; }
.hl-layout { display: block; }
}
</style>
{% endblock %}
{% block content %}
<a class="terug" href="{{ url_for('handleidingen.overzicht') }}">← Alle handleidingen</a>
<div class="card">
{% if versie %}
<div class="alert alert-warning" style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<span>Je bekijkt een <strong>oudere versie</strong> van {{ versie.Opgeslagen_op.strftime('%d-%m-%Y %H:%M') }}{% if versie.Door %} ({{ versie.Door }}){% endif %}.</span>
<span style="display:flex;gap:8px">
<form method="POST" action="{{ url_for('handleidingen.terugzetten', slug=h.Slug, vid=versie.Versie_ID) }}" style="margin:0"
onsubmit="return confirm('Deze versie terugzetten? De huidige tekst blijft bewaard bij de versies.')">
<button type="submit" class="btn-sm btn-warning">↩️ Deze versie terugzetten</button>
</form>
<a class="btn btn-sm btn-light" href="{{ url_for('handleidingen.bekijk', slug=h.Slug) }}">Huidige versie</a>
</span>
</div>
{% endif %}
<div class="page-head">
<h2>{{ versie.Titel if versie else h.Titel }}</h2>
<div class="actions">
<button type="button" class="btn-sm btn-light" onclick="window.print()">🖨️ Afdrukken / PDF</button>
{% if is_admin and not versie %}
<a class="btn btn-sm btn-warning" href="{{ url_for('handleidingen.bewerken', slug=h.Slug) }}">✏️ Bewerken</a>
{% endif %}
</div>
</div>
<div class="hl-meta">
{% if h.Categorie %}{{ h.Categorie }} · {% endif %}
Bijgewerkt {{ h.Gewijzigd_op.strftime('%d-%m-%Y %H:%M') }}{% if h.Gewijzigd_door %} door {{ h.Gewijzigd_door }}{% endif %}
{% if h.Zichtbaar == 'admins' %} · <span class="badge badge-warning">🔒 enkel admins</span>{% endif %}
</div>
<div class="hl-layout">
<article class="hl-inhoud">
{{ inhoud|safe }}
{% if not inhoud %}<p class="muted">Deze handleiding is nog leeg.</p>{% endif %}
</article>
{% if toc|length > 1 %}
<nav class="hl-toc" aria-label="Inhoud">
<strong>Inhoud</strong>
{% for hid, tekst, niveau in toc %}<a class="n{{ niveau }}" href="#{{ hid }}">{{ tekst }}</a>{% endfor %}
</nav>
{% endif %}
</div>
</div>
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}{{ 'Nieuwe handleiding' if nieuw else 'Bewerken: ' ~ h.Titel }}{% endblock %}
{% block page_title %}Handleidingen{% endblock %}
{% block extra_css %}
{% include "_handleiding_stijl.html" %}
<style>
.terug { margin-bottom: 14px; display: inline-block; }
.hl-velden { display: grid; grid-template-columns: 2fr 1fr 1fr 110px; gap: 0 14px; }
@media (max-width: 800px) { .hl-velden { grid-template-columns: 1fr; } }
.editor-wrap { border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--surface); }
.toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; border-bottom: 1px solid var(--border);
background: var(--surface-muted); border-radius: var(--radius) var(--radius) 0 0; position: sticky; top: 0; z-index: 5; }
.toolbar button, .toolbar select { padding: 4px 9px; font-size: 13px; min-width: 32px; background: var(--surface);
color: var(--text); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; width: auto; }
.toolbar button:hover { border-color: var(--primary-color); color: var(--primary-color); }
.toolbar button.aan { background: var(--primary-light); border-color: var(--primary-color); }
.toolbar .sep { width: 1px; background: var(--border-strong); margin: 2px 4px; }
#editor { min-height: 420px; max-height: 70vh; overflow-y: auto; padding: 16px 20px; outline: none; max-width: none; }
#editor:empty::before { content: "Begin hier te typen… Je kan ook schermafbeeldingen plakken (Ctrl+V) of slepen."; color: var(--text-muted); }
#editor img { cursor: pointer; }
#editor img.gekozen { outline: 3px solid var(--primary-color); }
#bron { display: none; width: 100%; min-height: 420px; font-family: Consolas, monospace; font-size: 13px; border: 0;
padding: 12px; border-radius: 0 0 var(--radius) var(--radius); }
.knoppen-onder { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
.versies { margin-top: 8px; }
.versies li { margin: 4px 0; }
</style>
{% endblock %}
{% block content %}
<a class="terug" href="{{ url_for('handleidingen.overzicht') if nieuw else url_for('handleidingen.bekijk', slug=h.Slug) }}">← Terug</a>
<div class="card">
<div class="page-head"><h2>{{ '➕ Nieuwe handleiding' if nieuw else '✏️ Handleiding bewerken' }}</h2></div>
<form method="POST" id="hlForm" action="{{ url_for('handleidingen.nieuw') if nieuw else url_for('handleidingen.bewerken', slug=h.Slug) }}">
<div class="hl-velden">
<div class="field">
<label for="titel">Titel</label>
<input type="text" id="titel" name="titel" maxlength="200" required value="{{ h.Titel or '' }}">
</div>
<div class="field">
<label for="categorie">Categorie</label>
<input type="text" id="categorie" name="categorie" maxlength="100" list="cats" value="{{ h.Categorie or '' }}" placeholder="bv. Google Workspace">
<datalist id="cats">{% for c in categorieen %}<option value="{{ c }}">{% endfor %}</datalist>
</div>
<div class="field">
<label for="zichtbaar">Zichtbaar voor</label>
<select id="zichtbaar" name="zichtbaar">
{% for k, v in ZICHTBAAR.items() %}<option value="{{ k }}" {% if h.Zichtbaar == k %}selected{% endif %}>{{ v }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="volgorde">Volgorde</label>
<input type="number" id="volgorde" name="volgorde" value="{{ h.Volgorde or 0 }}">
</div>
</div>
<div class="field">
<label for="samenvatting">Korte samenvatting <span class="muted small">(toont in de lijst)</span></label>
<input type="text" id="samenvatting" name="samenvatting" maxlength="300" value="{{ h.Samenvatting or '' }}">
</div>
<label>Inhoud</label>
<div class="editor-wrap">
<div class="toolbar" role="toolbar" aria-label="Opmaak">
<select id="blokstijl" title="Stijl" onchange="blok(this.value); this.value=''">
<option value="">Stijl…</option>
<option value="p">Gewone tekst</option>
<option value="h2">Kop (stap)</option>
<option value="h3">Tussenkop</option>
<option value="pre">Codeblok</option>
</select>
<span class="sep"></span>
<button type="button" data-cmd="bold" title="Vet (Ctrl+B)"><b>B</b></button>
<button type="button" data-cmd="italic" title="Cursief (Ctrl+I)"><i>I</i></button>
<button type="button" data-cmd="underline" title="Onderlijnen (Ctrl+U)"><u>U</u></button>
<button type="button" onclick="omring('code')" title="Code / knopnaam">&lt;/&gt;</button>
<button type="button" onclick="omring('mark')" title="Markeren">🖍️</button>
<span class="sep"></span>
<button type="button" data-cmd="insertOrderedList" title="Genummerde stappen">1.</button>
<button type="button" data-cmd="insertUnorderedList" title="Opsomming">•</button>
<button type="button" data-cmd="outdent" title="Minder inspringen">⇤</button>
<button type="button" data-cmd="indent" title="Meer inspringen">⇥</button>
<span class="sep"></span>
<button type="button" onclick="kader('tip')" title="Tip-kader (groen)">💡 Tip</button>
<button type="button" onclick="kader('let-op')" title="Let op-kader (geel)">⚠️ Let op</button>
<button type="button" onclick="kader('info')" title="Info-kader (blauw)">ℹ️ Info</button>
<span class="sep"></span>
<button type="button" onclick="link()" title="Link">🔗</button>
<button type="button" data-cmd="unlink" title="Link weghalen">⛓️‍💥</button>
<button type="button" onclick="document.getElementById('beeld').click()" title="Afbeelding / schermafbeelding">🖼️</button>
<button type="button" onclick="tabel()" title="Tabel">▦</button>
<button type="button" data-cmd="insertHorizontalRule" title="Scheidingslijn">―</button>
<span class="sep"></span>
<button type="button" data-cmd="removeFormat" title="Opmaak wissen">🧹</button>
<button type="button" data-cmd="undo" title="Ongedaan maken (Ctrl+Z)">↶</button>
<button type="button" data-cmd="redo" title="Opnieuw (Ctrl+Y)">↷</button>
<span class="sep"></span>
<button type="button" id="bronKnop" onclick="wisselBron()" title="HTML bekijken/bewerken">HTML</button>
</div>
<div id="editor" class="hl-inhoud" contenteditable="true" spellcheck="true">{{ h.Inhoud|safe if h.Inhoud else '' }}</div>
<textarea id="bron" spellcheck="false" aria-label="HTML"></textarea>
</div>
<input type="file" id="beeld" accept="image/*" multiple hidden onchange="bestanden(this.files); this.value=''">
<input type="hidden" name="inhoud" id="inhoud">
<p class="field-hint">Tip: plak een schermafbeelding rechtstreeks met Ctrl+V. Klik op een afbeelding en druk Delete om ze te verwijderen.
Grote afbeeldingen worden automatisch verkleind.</p>
<div class="knoppen-onder">
<button type="submit" class="btn-success">💾 Opslaan</button>
{% if not nieuw %}<button type="submit" name="verder" value="1" class="btn-light">💾 Opslaan en verder bewerken</button>{% endif %}
<a class="btn btn-light" href="{{ url_for('handleidingen.overzicht') if nieuw else url_for('handleidingen.bekijk', slug=h.Slug) }}"
onclick="gewijzigd=false">Annuleren</a>
</div>
</form>
{% if not nieuw %}
<form method="POST" action="{{ url_for('handleidingen.verwijderen', slug=h.Slug) }}" style="margin-top:18px"
onsubmit="gewijzigd=false; return confirm('Handleiding \'{{ h.Titel|e }}\' verwijderen?')">
<button type="submit" class="btn-danger btn-sm">🗑️ Handleiding verwijderen</button>
</form>
{% endif %}
</div>
{% if versies %}
<div class="card">
<h3 class="card-title">🕘 Vorige versies</h3>
<p class="muted small">Bij elke opslag wordt de vorige tekst bewaard (de laatste 30). Open een versie om ze te bekijken of terug te zetten.</p>
<ul class="versies">
{% for v in versies %}
<li><a href="{{ url_for('handleidingen.versie', slug=h.Slug, vid=v.Versie_ID) }}">{{ v.Opgeslagen_op.strftime('%d-%m-%Y %H:%M') }}</a>
<span class="muted small">{{ v.Door or '' }}{% if v.Titel != h.Titel %} · titel: {{ v.Titel }}{% endif %}</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function () {
var ed = document.getElementById('editor'), bron = document.getElementById('bron');
var form = document.getElementById('hlForm');
var bronAan = false;
window.gewijzigd = false;
var MAX_BREEDTE = 1400;
try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch (e) {}
ed.addEventListener('input', function () { gewijzigd = true; });
['titel', 'categorie', 'samenvatting', 'zichtbaar', 'volgorde'].forEach(function (id) {
document.getElementById(id).addEventListener('input', function () { gewijzigd = true; });
});
window.addEventListener('beforeunload', function (e) { if (gewijzigd) { e.preventDefault(); e.returnValue = ''; } });
function cmd(c, v) { ed.focus(); document.execCommand(c, false, v || null); gewijzigd = true; }
document.querySelectorAll('.toolbar [data-cmd]').forEach(function (b) {
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function () { cmd(b.dataset.cmd); });
});
window.blok = function (tag) { if (tag) cmd('formatBlock', '<' + tag + '>'); };
function selectieHtml() {
var sel = window.getSelection();
if (!sel.rangeCount) return '';
var div = document.createElement('div');
div.appendChild(sel.getRangeAt(0).cloneContents());
return div.innerHTML;
}
window.omring = function (tag) {
var h = selectieHtml() || 'tekst';
cmd('insertHTML', '<' + tag + '>' + h + '</' + tag + '>&#8203;');
};
window.kader = function (soort) {
var h = selectieHtml();
var titel = {tip: 'Tip:', 'let-op': 'Let op:', info: 'Info:'}[soort];
if (!h) h = '<strong>' + titel + '</strong> ';
cmd('insertHTML', '<div class="' + soort + '">' + h + '</div><p><br></p>');
};
window.link = function () {
var url = prompt('Link (bv. https://admin.google.com):', 'https://');
if (!url || url === 'https://') return;
if (!/^(https?:|mailto:|\/|#)/i.test(url)) url = 'https://' + url;
if (window.getSelection().isCollapsed) {
cmd('insertHTML', '<a href="' + url.replace(/"/g, '&quot;') + '" target="_blank">' + url.replace(/</g, '&lt;') + '</a>&nbsp;');
} else {
cmd('createLink', url);
ed.querySelectorAll('a[href="' + CSS.escape(url) + '"]').forEach(function (a) { a.target = '_blank'; });
}
};
window.tabel = function () {
var k = parseInt(prompt('Aantal kolommen?', '3'), 10), r = parseInt(prompt('Aantal rijen (zonder kop)?', '3'), 10);
if (!(k > 0 && r > 0)) return;
var h = '<table><thead><tr>';
for (var i = 0; i < k; i++) h += '<th>Kop ' + (i + 1) + '</th>';
h += '</tr></thead><tbody>';
for (var j = 0; j < r; j++) { h += '<tr>'; for (i = 0; i < k; i++) h += '<td>&nbsp;</td>'; h += '</tr>'; }
cmd('insertHTML', h + '</tbody></table><p><br></p>');
};
// --- afbeeldingen: knop, plakken, slepen → verkleinen → data-URL ---
function voegBeeldIn(file) {
if (!file || !/^image\//.test(file.type) || /svg/.test(file.type)) return;
var r = new FileReader();
r.onload = function () {
var img = new Image();
img.onload = function () {
var schaal = Math.min(1, MAX_BREEDTE / img.width);
var c = document.createElement('canvas');
c.width = Math.round(img.width * schaal); c.height = Math.round(img.height * schaal);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
var png = c.toDataURL('image/png'), jpg = c.toDataURL('image/jpeg', 0.85);
var url = (file.type === 'image/png' && png.length < jpg.length * 1.6) ? png : jpg;
cmd('insertHTML', '<img src="' + url + '" alt="' + (file.name || 'schermafbeelding').replace(/"/g, '') + '"><p><br></p>');
};
img.src = r.result;
};
r.readAsDataURL(file);
}
window.bestanden = function (files) { Array.prototype.forEach.call(files, voegBeeldIn); };
ed.addEventListener('paste', function (e) {
var items = (e.clipboardData || {}).items || [], beeld = false;
Array.prototype.forEach.call(items, function (it) {
if (it.kind === 'file' && /^image\//.test(it.type)) { voegBeeldIn(it.getAsFile()); beeld = true; }
});
if (beeld) e.preventDefault();
});
ed.addEventListener('dragover', function (e) { e.preventDefault(); });
ed.addEventListener('drop', function (e) {
if (e.dataTransfer && e.dataTransfer.files.length) { e.preventDefault(); bestanden(e.dataTransfer.files); }
});
ed.addEventListener('click', function (e) {
ed.querySelectorAll('img.gekozen').forEach(function (i) { i.classList.remove('gekozen'); });
if (e.target.tagName === 'IMG') e.target.classList.add('gekozen');
});
ed.addEventListener('keydown', function (e) {
var g = ed.querySelector('img.gekozen');
if (g && (e.key === 'Delete' || e.key === 'Backspace')) { e.preventDefault(); g.remove(); gewijzigd = true; }
});
// --- HTML-weergave ---
window.wisselBron = function () {
bronAan = !bronAan;
if (bronAan) { bron.value = ed.innerHTML; } else { ed.innerHTML = bron.value; }
ed.style.display = bronAan ? 'none' : '';
bron.style.display = bronAan ? 'block' : 'none';
document.getElementById('bronKnop').classList.toggle('aan', bronAan);
document.querySelectorAll('.toolbar button:not(#bronKnop), .toolbar select').forEach(function (b) { b.disabled = bronAan; });
};
bron.addEventListener('input', function () { gewijzigd = true; });
// Ctrl+S = opslaan en verder bewerken
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
var verder = form.querySelector('[name=verder]');
if (verder) verder.click(); else form.requestSubmit();
}
});
form.addEventListener('submit', function () {
ed.querySelectorAll('img.gekozen').forEach(function (i) { i.classList.remove('gekozen'); });
document.getElementById('inhoud').value = bronAan ? bron.value : ed.innerHTML;
gewijzigd = false;
if (typeof showLoader === 'function') showLoader();
});
})();
</script>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Handleidingen{% endblock %}
{% block page_title %}Handleidingen{% endblock %}
{% block extra_css %}
<style>
.hl-lijst { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 22px; }
.hl-kaart { display: block; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px;
text-decoration: none; color: inherit; background: var(--surface); transition: border-color .12s, box-shadow .12s; }
.hl-kaart:hover { border-color: var(--primary-color); box-shadow: var(--shadow); }
.hl-kaart h3 { margin: 0 0 6px; font-size: 1rem; color: var(--primary-color); }
.hl-kaart p { margin: 0 0 8px; color: var(--text-muted); font-size: .9rem; }
.hl-kaart .meta { font-size: .78rem; color: var(--text-muted); }
.hl-cat { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin: 6px 0 10px; font-weight: 700; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>📘 Handleidingen</h2>
{% if is_admin %}
<a class="btn btn-sm btn-success" href="{{ url_for('handleidingen.nieuw') }}">➕ Nieuwe handleiding</a>
{% endif %}
</div>
<form method="GET" class="search-bar">
<input type="search" name="q" value="{{ q }}" placeholder="Zoek in de handleidingen…">
<button type="submit">🔍 Zoeken</button>
{% if q %}<a class="btn btn-light" href="{{ url_for('handleidingen.overzicht') }}">✕ Wissen</a>{% endif %}
</form>
{% for cat, items in per_cat.items() %}
<div class="hl-cat">{{ cat }}</div>
<div class="hl-lijst">
{% for h in items %}
<a class="hl-kaart" href="{{ url_for('handleidingen.bekijk', slug=h.Slug) }}">
<h3>{{ h.Titel }}</h3>
{% if h.Samenvatting %}<p>{{ h.Samenvatting }}</p>{% endif %}
<div class="meta">
{% if h.Zichtbaar == 'admins' %}<span class="badge badge-warning">🔒 enkel admins</span> {% endif %}
Bijgewerkt {{ h.Gewijzigd_op.strftime('%d-%m-%Y') }}{% if h.Gewijzigd_door %} door {{ h.Gewijzigd_door }}{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">{% if q %}Geen handleidingen gevonden voor "{{ q }}".{% else %}Er zijn nog geen handleidingen.{% endif %}</p>
{% endfor %}
</div>
{% endblock %}
+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 %}
+235
View File
@@ -0,0 +1,235 @@
{% extends "base.html" %}
{% block title %}Herstellingen{% endblock %}
{% block page_title %}Herstellingen &amp; Signpost{% endblock %}
{% block loader_text %}Signpost data ophalen... even geduld.{% endblock %}
{% block content %}
<div class="page-head">
<div class="tabs">
<button class="tab-link {{ 'active' if active_tab != 'signpost-tab' else '' }}" onclick="openTab(event, 'herstellingen-tab')">🛠️ Herstellingen</button>
<button class="tab-link {{ 'active' if active_tab == 'signpost-tab' else '' }}" onclick="openTab(event, 'signpost-tab')">🚩 Signpost</button>
</div>
<div class="actions">
{% if active_tab == 'signpost-tab' %}
<button type="button" class="add-btn" id="mainAddBtn" onclick="openSignpostModal()">🚩 Signpost-herstelling toevoegen</button>
{% else %}
<button type="button" class="add-btn" id="mainAddBtn" onclick="openAddModal()">➕ Herstelling toevoegen</button>
{% endif %}
</div>
</div>
<section id="herstellingen-tab" class="tab-content card {{ 'active' if active_tab != 'signpost-tab' else '' }}" style="display: {{ 'none' if active_tab == 'signpost-tab' else 'block' }};">
<h2>🛠️ Overzicht herstellingen</h2>
<form class="search-bar" method="POST" action="{{ url_for('repairs.herstellingen') }}">
<input type="text" name="query" placeholder="Zoek op serienummer, incidentnr. of omschrijving..." value="{{ query or '' }}">
<button type="submit">🔍 Zoeken</button>
</form>
{% if results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Incident nr</th>
<th>Serienummer</th>
<th>Soort herstelling</th>
<th>Status</th>
<th>Datum</th>
<th class="actions">Acties</th>
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td><strong>{{ row['Incident_nr'] or '' }}</strong></td>
<td>{{ row['KAMXM_nummer'] }}</td>
<td>{{ row['Soort_herstelling'] or '' }}</td>
<td>
{% if row['Status'] == 'In afwachting' %}<span class="badge badge-warning">⏳ In afwachting</span>
{% elif row['Status'] == 'In herstelling' %}<span class="badge badge-info">🔧 In herstelling</span>
{% elif row['Status'] == 'Voltooid' %}<span class="badge badge-success">✅ Voltooid</span>
{% else %}<span class="badge">{{ row['Status'] }}</span>
{% endif %}
</td>
<td class="nowrap">{{ row['Datum'] or '' }}</td>
<td class="actions">
<button type="button" class="btn-sm" onclick="openStatusModal('{{ row['KAMXM_nummer'] }}', '{{ row['Incident_nr'] or '' }}')">Status</button>
<button type="button" class="btn-bewerk btn-sm"
data-kamxm="{{ row['KAMXM_nummer'] }}"
data-incident="{{ row['Incident_nr'] or '' }}"
data-soort="{{ row['Soort_herstelling'] or '' }}"
data-datum="{{ row['Datum'] or '' }}"
onclick="openEditModal(this)">✏️ Bewerk</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif query %}
<p class="muted">Geen herstellingen gevonden voor "{{ query }}".</p>
{% endif %}
</section>
<section id="signpost-tab" class="tab-content card {{ 'active' if active_tab == 'signpost-tab' else '' }}" style="display: {{ 'block' if active_tab == 'signpost-tab' else 'none' }};">
<h2>🚩 Signpost schademeldingen</h2>
<form id="signpostSearchForm" class="search-bar" method="POST" action="{{ url_for('repairs.signpost_search') }}">
<input type="text" name="serial_number" placeholder="Zoek op serienummer..." value="{{ searched_serial or '' }}">
<button type="submit">🔍 Zoek in Signpost</button>
</form>
{% if signpost_results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Referentie</th>
<th>Status</th>
<th>Omschrijving</th>
<th>Datum</th>
<th class="text-right">Prijs</th>
</tr>
</thead>
<tbody>
{% for case in signpost_results %}
<tr>
<td><strong>{{ case.get('ourReference', 'N/A') }}</strong></td>
<td><span class="badge">{{ case.get('status', 'Onbekend') }}</span></td>
<td>{{ case.get('description', 'Geen omschrijving') }}</td>
<td class="nowrap">{{ case.get('createdAt', '')[:10] }}</td>
<td class="text-right nowrap">€ {{ case.get('totalPrice', '0.00') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
{% endblock %}
{% block modals %}
<div id="addModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('addModal')">&times;</span>
<h3>Nieuwe herstelling toevoegen</h3>
<form method="POST" action="{{ url_for('repairs.add_herstelling') }}">
<div class="field"><label>Incident nr</label><input type="text" name="incident_nr" placeholder="Optioneel"></div>
<div class="field"><label>Serienummer</label><input type="text" name="kamxm_nummer" required placeholder="Bijv. 12345"></div>
<div class="field"><label>Soort herstelling</label><input type="text" name="soort_herstelling" required placeholder="Omschrijving..."></div>
<button type="submit" class="btn-block btn-success">➕ Toevoegen</button>
</form>
</div>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('editModal')">&times;</span>
<h3>Herstelling bewerken</h3>
<form method="POST" action="{{ url_for('repairs.bewerk_herstelling') }}">
<input type="hidden" name="kamxm_nummer_original" id="edit_kamxm_orig">
<div class="field"><label>Incident nr</label><input type="text" name="incident_nr" id="edit_incident"></div>
<div class="field"><label>Serienummer</label><input type="text" name="kamxm_nummer" id="edit_kamxm"></div>
<div class="field"><label>Soort herstelling</label><input type="text" name="soort_herstelling" id="edit_soort"></div>
<div class="field"><label>Datum</label><input type="date" name="datum" id="edit_datum"></div>
<button type="submit" class="btn-block">💾 Opslaan</button>
</form>
</div>
</div>
<div id="statusModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('statusModal')">&times;</span>
<h3>Wijzig status: <span id="display_kamxm"></span></h3>
<form id="statusForm" method="POST" action="{{ url_for('repairs.wijzig_status_herstelling') }}">
<input type="hidden" name="kamxm_nummer" id="input_status_kamxm">
<input type="hidden" name="incident_nr" id="input_status_incident">
<input type="hidden" name="new_status" id="input_status_value">
<button type="button" class="status-button status-warning" id="btn-wacht" onclick="submitStatus('In afwachting')">⏳ In afwachting</button>
<button type="button" class="status-button status-info" id="btn-bezig" onclick="submitStatus('In herstelling')">🔧 In herstelling</button>
<button type="button" class="status-button status-success" id="btn-klaar" onclick="submitStatus('Voltooid')">✅ Voltooid</button>
</form>
</div>
</div>
<div id="signpostModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('signpostModal')">&times;</span>
<h3>🚩 Signpost-schade melden</h3>
<form id="signpostSubmitForm" action="#" method="POST">
<div class="field"><label>Serienummer</label><input type="text" name="serial" id="sp_serial_input" required placeholder="Voer serienummer in"></div>
<div class="field"><label>Omschrijving</label><textarea name="omschrijving" placeholder="Wat is er defect?"></textarea></div>
<div class="switch-row"><span>💧 Waterschade</span><label class="switch"><input type="checkbox" name="water"><span class="slider"></span></label></div>
<div class="switch-row"><span>🔨 Valschade</span><label class="switch"><input type="checkbox" name="val"><span class="slider"></span></label></div>
<div class="switch-row"><span>💻 Cosmetische schade onderkant</span><label class="switch"><input type="checkbox" name="cosm_onder"><span class="slider"></span></label></div>
<div class="switch-row"><span>⌨️ Cosmetische schade toetsenbord</span><label class="switch"><input type="checkbox" name="cosm_kb"><span class="slider"></span></label></div>
<div class="switch-row"><span>📱 Cosmetische schade bovenkant</span><label class="switch"><input type="checkbox" name="cosm_boven"><span class="slider"></span></label></div>
<button type="submit" class="btn-block btn-success" style="margin-top:16px">Verstuur naar Signpost</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(t => { t.style.display = 'none'; t.classList.remove('active'); });
document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
document.getElementById(tabName).style.display = 'block';
document.getElementById(tabName).classList.add('active');
evt.currentTarget.classList.add('active');
const addBtn = document.getElementById('mainAddBtn');
if (tabName === 'signpost-tab') {
addBtn.innerHTML = "🚩 Signpost-herstelling toevoegen";
addBtn.onclick = openSignpostModal;
} else {
addBtn.innerHTML = "➕ Herstelling toevoegen";
addBtn.onclick = openAddModal;
}
}
// Laadscherm bij zoeken in en melden aan Signpost
['signpostSearchForm', 'signpostSubmitForm'].forEach(id => {
const f = document.getElementById(id);
if (f) f.addEventListener('submit', () => showLoader());
});
function openAddModal() { document.getElementById('addModal').style.display = 'block'; }
function openSignpostModal() {
document.getElementById('signpostModal').style.display = 'block';
const currentSearch = document.getElementsByName('serial_number')[0].value;
if (currentSearch) document.getElementById('sp_serial_input').value = currentSearch;
}
function openEditModal(btn) {
document.getElementById('edit_kamxm_orig').value = btn.getAttribute('data-kamxm');
document.getElementById('edit_incident').value = btn.getAttribute('data-incident');
document.getElementById('edit_kamxm').value = btn.getAttribute('data-kamxm');
document.getElementById('edit_soort').value = btn.getAttribute('data-soort');
document.getElementById('edit_datum').value = btn.getAttribute('data-datum');
document.getElementById('editModal').style.display = 'block';
}
function openStatusModal(kamxm, incident) {
document.getElementById('display_kamxm').textContent = kamxm;
document.getElementById('input_status_kamxm').value = kamxm;
document.getElementById('input_status_incident').value = incident;
document.getElementById('statusModal').style.display = 'block';
}
function submitStatus(waarde) {
document.getElementById('input_status_value').value = waarde;
document.getElementById('statusForm').submit();
}
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
window.onclick = function (event) {
if (event.target.classList.contains('modal')) event.target.style.display = 'none';
};
</script>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Informat-velden{% endblock %}
{% block page_title %}Informat-velden{% endblock %}
{% block loader_text %}Leerlingenlijst ophalen bij Informat...{% endblock %}
{% block content %}
<a href="{{ url_for('leerlingenaantal.leerlingenaantal_page') }}" style="display:inline-block;margin-bottom:14px">← Leerlingenaantallen</a>
<div class="card">
<div class="page-head"><h2>🔎 Welke velden geeft Informat terug?</h2></div>
<p class="muted">Haalt de leerlingenlijst op zoals bij het tellen en toont per veld hoeveel leerlingen een waarde hebben.
Bij velden met weinig verschillende waarden zie je die waarden en hun aantal: zo vind je het veld dat de campus aangeeft.
Namen en andere persoonsgegevens worden niet getoond.</p>
<form method="GET" class="search-bar" onsubmit="showLoader()">
<select name="school" required>
<option value="">Kies een school…</option>
{% for s in scholen %}<option value="{{ s.code }}" {% if school and s.code == school.code %}selected{% endif %}>{{ s.naam }}</option>{% endfor %}
</select>
<button type="submit">🔎 Velden ophalen</button>
</form>
{% if fout %}<div class="alert alert-danger">{{ fout }}</div>{% endif %}
{% if velden is not none %}
<p><strong>{{ totaal }}</strong> leerlingen ontvangen van Informat voor {{ school.naam }}.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Veld</th><th class="text-right">Gevuld bij</th><th class="text-right">Verschillende waarden</th><th>Waarden (aantal leerlingen)</th></tr></thead>
<tbody>
{% for v in velden %}
<tr {% if v.waarden|length > 1 %}style="background:var(--primary-light)"{% endif %}>
<td class="mono">{{ v.pad }}</td>
<td class="text-right">{{ v.gevuld }}</td>
<td class="text-right">{{ v.verschillend }}</td>
<td class="small">
{% if v.gevoelig %}<span class="muted">persoonsgegeven – niet getoond</span>
{% elif v.waarden %}{% for w, n in v.waarden %}<code>{{ w }}</code>&nbsp;{{ n }}{% if not loop.last %} · {% endif %}{% endfor %}
{% else %}<span class="muted">te veel verschillende waarden</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-hint">Gevonden? Vul het veld in bij 🔌 Koppelingen › Informat › <em>Campusveld in de leerlingenlijst</em>
en bij <em>Campussen</em> de waarden met een naam, bv. <code>1=Campus Kapellen, 3=Campus Stabroek</code>.</p>
{% endif %}
</div>
{% endblock %}
+190
View File
@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}Inleveren{% endblock %}
{% block page_title %}Toestel inleveren{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Inlevering verwerken...{% endblock %}
{% block head %}
<script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
{% endblock %}
{% block extra_css %}
<style>
#reader { width: 100%; margin-top: 14px; border: 3px solid var(--primary-color); border-radius: var(--radius); overflow: hidden; display: none; background: #000; }
#reader video { width: 100% !important; height: auto !important; object-fit: cover !important; }
#scan-result-container { display: none; margin-top: 14px; padding: 14px; border: 2px dashed var(--primary-color); border-radius: var(--radius); background: var(--primary-light); }
#scannedText { font-size: 1.2em; color: var(--primary-color); word-break: break-all; }
.scan-actions { display: flex; gap: 8px; margin-top: 10px; }
.scan-actions button { flex: 1; }
</style>
{% endblock %}
{% block content %}
<div class="split">
<div class="card">
<h2 class="card-title">Toestel inleveren</h2>
{% if message %}
{% set ok = 'succesvol' in message or 'ingegeven' in message %}
<div class="alert {{ 'alert-success' if ok else 'alert-danger' }}">{{ message }}</div>
{% endif %}
<form method="POST" action="{{ url_for('devices.inleveren_submit') }}" onsubmit="showLoader()">
<input type="hidden" id="action_type_hidden" name="action" value="Geen_schade">
<div class="field zoek-wrap">
<label for="internnummer">Internnummer</label>
<input type="text" id="internnummer" name="internnummer" required autocomplete="off"
placeholder="Zoek op naam of scan/typ internnummer..." role="combobox"
value="{{ request.args.get('internnummer', '') }}"
aria-autocomplete="list" aria-controls="leerlingSuggesties" aria-expanded="false">
<ul class="suggesties" id="leerlingSuggesties" role="listbox"></ul>
{% if request.args.get('naam') %}<p class="field-hint">👤 {{ request.args.get('naam') }}</p>{% endif %}
</div>
<div class="field zoek-wrap">
<label for="serienummer">Toestelnummer</label>
<input type="text" id="serienummer" name="serienummer" required autocomplete="off"
placeholder="Zoek of scan toestelnummer (of serienummer)..." role="combobox"
value="{{ request.args.get('toestel', '') }}"
aria-autocomplete="list" aria-controls="toestelSuggesties" aria-expanded="false">
<ul class="suggesties" id="toestelSuggesties" role="listbox"></ul>
</div>
<label class="check-box">
<input type="checkbox" id="schade_aanwezig" onchange="updateActionType(this)">
<span>Chromebook heeft schade of opmerkingen</span>
</label>
<div class="field">
<label for="schade_type">Schadedetail</label>
<input type="text" id="schade_type" name="schade_type" placeholder="Beschrijf de schade..." disabled>
</div>
<button class="btn-block btn-lg" type="submit">📥 Toestel inleveren</button>
</form>
</div>
<div class="card order-first-mobile text-center">
<h2 class="card-title">📷 Scanner</h2>
<button type="button" class="btn-block btn-secondary" id="btn-toggle-scan">📷 Open camera-scanner</button>
{# Zonder https geeft de browser geen live camera: dan een foto nemen met de camera-app en de code daaruit lezen #}
<label class="btn btn-block btn-secondary" id="btn-foto" style="display:none">
📷 Foto van de code nemen
<input type="file" id="foto-input" accept="image/*" capture="environment" hidden>
</label>
<p class="muted small" id="scan-hint" style="display:none;margin:8px 0 0"></p>
<div id="reader"></div>
<div id="scan-result-container">
<div class="muted small">Gescande waarde</div>
<strong id="scannedText">-</strong>
<div class="scan-actions">
<button type="button" class="btn-sm" onclick="copyToField('internnummer')">→ Internnr.</button>
<button type="button" class="btn-sm" onclick="copyToField('serienummer')">→ Toestelnr.</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{% set toestel_voorkeur = 'uitgeleend' %}{% set toestel_vul_intern = true %}{% include '_toestel_zoekveld.html' %}
<script>
let html5QrCode;
const scanBtn = document.getElementById('btn-toggle-scan');
const readerDiv = document.getElementById('reader');
const resultContainer = document.getElementById('scan-result-container');
const scannedTextElement = document.getElementById('scannedText');
scanBtn.addEventListener('click', () => {
if (!html5QrCode || !html5QrCode.isScanning) startScanner(); else stopScanner();
});
// Live camera kan enkel via https (of localhost). Anders: foto nemen en de code uit de foto lezen.
const fotoBtn = document.getElementById('btn-foto');
const fotoInput = document.getElementById('foto-input');
const scanHint = document.getElementById('scan-hint');
function fotoModus(reden) {
scanBtn.style.display = 'none';
fotoBtn.style.display = '';
scanHint.textContent = reden;
scanHint.style.display = '';
}
if (!window.isSecureContext || !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
fotoModus('Neem een scherpe foto van de streepjes- of QR-code, van dichtbij en recht ervoor.');
}
fotoInput.addEventListener('change', () => {
const bestand = fotoInput.files && fotoInput.files[0];
if (!bestand) return;
scanHint.textContent = 'Code zoeken in de foto...';
scanHint.style.display = '';
const lezer = new Html5Qrcode("reader");
lezer.scanFile(bestand, false)
.then(tekst => {
scannedTextElement.textContent = tekst;
resultContainer.style.display = 'block';
scanHint.textContent = 'Code gevonden. Kies hieronder in welk veld ze moet komen.';
})
.catch(() => { scanHint.textContent = 'Geen code gevonden in de foto. Probeer opnieuw, van dichterbij en zonder schittering.'; })
.finally(() => { fotoInput.value = ''; lezer.clear(); });
});
function startScanner() {
readerDiv.style.display = 'block';
scanBtn.textContent = '❌ Stop scanner';
scanBtn.classList.replace('btn-secondary', 'btn-danger');
html5QrCode = new Html5Qrcode("reader");
const config = { fps: 20, qrbox: { width: 300, height: 150 }, aspectRatio: 1.777778 };
html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText) => {
scannedTextElement.textContent = decodedText;
resultContainer.style.display = 'block';
},
() => {}
).catch((err) => {
console.error(err);
readerDiv.style.display = 'none';
scanBtn.textContent = '📷 Open camera-scanner';
scanBtn.classList.replace('btn-danger', 'btn-secondary');
fotoModus('De live camera kon niet starten. Neem in de plaats een foto van de code.');
});
}
function stopScanner() {
if (html5QrCode) {
html5QrCode.stop().then(() => {
readerDiv.style.display = 'none';
scanBtn.textContent = '📷 Open camera-scanner';
scanBtn.classList.replace('btn-danger', 'btn-secondary');
}).catch(err => console.error(err));
}
}
function copyToField(fieldId) {
const input = document.getElementById(fieldId);
input.value = scannedTextElement.textContent;
input.style.backgroundColor = '#d1fae5';
setTimeout(() => input.style.backgroundColor = '', 500);
}
function updateActionType(checkbox) {
const schadeInput = document.getElementById('schade_type');
const hiddenActionField = document.getElementById('action_type_hidden');
if (checkbox.checked) {
schadeInput.disabled = false;
schadeInput.focus();
hiddenActionField.value = "Schade";
} else {
schadeInput.disabled = true;
schadeInput.value = '';
hiddenActionField.value = "Geen_schade";
}
}
</script>
{% endblock %}
+217
View File
@@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Internen{% endblock %}
{% block page_title %}Internen{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Internen ophalen uit Informat...{% endblock %}
{% block extra_css %}
<style>
.keuze { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; }
.keuze { flex-wrap: nowrap; }
.keuze select, .keuze input { width: auto; flex: 0 0 auto; }
.keuze input[type=date] { max-width: 165px; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.filter-bar input[type=search] { flex: 0 1 320px; }
.filter-bar select { width: auto; min-width: 0; }
.tellers { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 6px; }
.tellers span { padding: 4px 12px; border-radius: 999px; background: var(--surface-muted); border: 1px solid var(--border); font-size: .88em; }
.tellers b { margin-left: 4px; }
td.nowrap { white-space: nowrap; }
td.ss { text-align: center; }
.ss-teken { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; font-weight: 700; }
.ss-teken.ja { background: #dcfce7; color: #15803d; }
.ss-teken.nee { background: #fee2e2; color: #b91c1c; }
.ss-teken.bezig { color: var(--text-muted, #64748b); }
.ouder { display: block; white-space: nowrap; font-size: .9em; }
.ouder .muted { font-size: .92em; }
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; }
.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 %}
<div class="card">
<div class="page-head">
<h2>🛏️ Internen · {{ school_naam }}</h2>
<div class="actions">
<form method="GET" action="{{ url_for('internaat.internen') }}" class="keuze" onchange="showLoader(); this.submit()">
<select name="schooljaar" aria-label="Schooljaar">
{% for j in jaren %}<option value="{{ j }}" {{ 'selected' if j == schooljaar }}>Schooljaar {{ j }}</option>{% endfor %}
</select>
<label class="muted small" for="peildatum">op</label>
<input type="date" id="peildatum" name="peildatum" value="{{ peildatum.isoformat() }}" aria-label="Peildatum">
</form>
{% if rijen %}<a class="btn btn-light btn-sm" id="exportKnop" href="{{ url_for('internaat.export', schooljaar=schooljaar, peildatum=peildatum.isoformat()) }}">⬇️ CSV</a>{% endif %}
<a class="btn btn-secondary btn-sm" href="{{ url_for('internaat.internen', schooljaar=schooljaar, peildatum=peildatum.isoformat(), vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not rijen %}
<p class="muted">Geen internen ingeschreven op {{ peildatum.strftime('%d-%m-%Y') }} (schooljaar {{ schooljaar }}).</p>
{% else %}
<div class="tellers">
<span>Internen <b id="tAantal">{{ rijen|length }}</b></span>
<span>Jongens <b id="tM">{{ rijen|selectattr('geslacht', 'equalto', 'M')|list|length }}</b></span>
<span>Meisjes <b id="tV">{{ rijen|selectattr('geslacht', 'equalto', 'V')|list|length }}</b></span>
<span id="ssTeller" title="Internen waarvoor geen Smartschool-gebruiker gevonden werd">Niet in Smartschool <b id="tSS">…</b></span>
<span id="ssFout" class="muted small" hidden></span>
</div>
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek op naam, kamer, stamnummer of woonplaats..." aria-label="Zoeken">
{% if internaten|length > 1 %}
<select id="internaat" aria-label="Internaat">
<option value="">Alle internaten</option>
{% for i in internaten %}<option>{{ i }}</option>{% endfor %}
</select>
{% endif %}
<span id="perPagina"></span>
<span class="muted small">Opgehaald uit Informat op {{ opgehaald }}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>Naam <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Voornaam <span class="pijl"></span></th>
<th class="sorteer" data-tekst title="Bestaat de intern als gebruiker in Smartschool (gebruikersnaam: voornaam achternaam)?">Smartschool <span class="pijl"></span></th>
<th class="sorteer">Geboren <span class="pijl"></span></th>
<th class="sorteer" data-tekst>M/V <span class="pijl"></span></th>
{% if internaten|length > 1 %}<th class="sorteer" data-tekst>Internaat <span class="pijl"></span></th>{% endif %}
<th class="sorteer" data-tekst>Vestiging <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Kamer <span class="pijl"></span></th>
<th class="sorteer">Ingeschreven <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Woonplaats <span class="pijl"></span></th>
<th>Contactpersonen</th>
</tr>
</thead>
<tbody id="body">
{% for r in rijen %}
<tr data-internaat="{{ r.internaat }}" data-geslacht="{{ r.geslacht }}">
<td data-sort="{{ r.naam }}"><strong>{{ r.naam }}</strong>{% if r.stamnummer %}<br><span class="muted small" style="white-space:nowrap">stamnr. {{ r.stamnummer }}</span>{% endif %}</td>
<td data-sort="{{ r.voornaam }}">{{ r.voornaam }}</td>
<td class="ss" data-sort="" data-pid="{{ r.id }}" data-voornaam="{{ r.voornaam }}" data-naam="{{ r.naam }}"><span class="ss-teken bezig" title="Controleren in Smartschool…">…</span></td>
<td class="nowrap" data-sort="{{ r.geboortedatum.isoformat() if r.geboortedatum else '' }}">{{ r.geboortedatum.strftime('%d-%m-%Y') if r.geboortedatum else '' }}</td>
<td data-sort="{{ r.geslacht }}">{{ r.geslacht }}</td>
{% if internaten|length > 1 %}<td data-sort="{{ r.internaat }}">{{ r.internaat }}</td>{% endif %}
<td data-sort="{{ r.vestiging }}">{{ r.vestiging }}</td>
<td class="nowrap" data-sort="{{ r.kamer_sort }}">{{ r.kamer or '–' }}</td>
<td class="nowrap" data-sort="{{ r.begin.isoformat() if r.begin else '' }}">{{ r.begin.strftime('%d-%m-%Y') if r.begin else '' }}{% if r.einde %}<br><span class="muted small">tot {{ r.einde.strftime('%d-%m-%Y') }}</span>{% endif %}
{% if r.financiering %}<br><span class="muted small">{{ r.financiering }}</span>{% endif %}</td>
<td data-sort="{{ r.gemeente }}">{{ r.gemeente }}</td>
<td>
{% for o in r.ouders[:3] %}
<span class="ouder">{{ o.naam }}{% if o.type %} <span class="muted">({{ o.type|lower }})</span>{% endif %}
{% if o.gsm %}· <a href="tel:{{ o.gsm|replace(' ', '') }}">{{ o.gsm }}</a>{% endif %}
{% if o.email %}· <a href="mailto:{{ o.email }}" title="{{ o.email }}">✉️</a>{% endif %}</span>
{% endfor %}
</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>
{% endblock %}
{% block scripts %}
<script>
(function () {
const body = document.getElementById('body');
if (!body) return;
const zoekveld = document.getElementById('zoek'), internaatSel = document.getElementById('internaat');
const exp = document.getElementById('exportKnop'), basisExport = exp ? exp.href : '';
let PER_PAGINA = perPagina('internen'), pagina = 1, zoek = '', internaat = '';
let ssKlaar = false;
function toon() {
const rijen = Array.from(body.rows).filter(tr => (!internaat || tr.dataset.internaat === internaat)
&& (!zoek || tr.textContent.toLowerCase().includes(zoek)));
document.getElementById('tAantal').textContent = rijen.length;
document.getElementById('tM').textContent = rijen.filter(tr => tr.dataset.geslacht === 'M').length;
document.getElementById('tV').textContent = rijen.filter(tr => tr.dataset.geslacht === 'V').length;
if (ssKlaar) document.getElementById('tSS').textContent = rijen.filter(tr => tr.querySelector('td.ss').dataset.sort === '0').length;
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';
if (exp) {
const u = new URL(basisExport);
if (internaat) u.searchParams.set('internaat', internaat); else u.searchParams.delete('internaat');
if (zoek) u.searchParams.set('zoek', zoek); else u.searchParams.delete('zoek');
exp.href = u.toString();
}
const nav = document.getElementById('paginering');
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); }
}
zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
if (internaatSel) internaatSel.addEventListener('change', () => { internaat = internaatSel.value; pagina = 1; toon(); });
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'), 'internen', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
// Smartschool: bestaat er een gebruiker "voornaam achternaam"? (op de achtergrond, 1 uur bewaard)
const cellen = Array.from(body.querySelectorAll('td.ss'));
fetch({{ url_for('internaat.internen_smartschool')|tojson }}, {
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({personen: cellen.map(td => ({pid: td.dataset.pid, voornaam: td.dataset.voornaam, naam: td.dataset.naam}))})
}).then(r => r.json()).then(res => {
if (!res.ok) {
cellen.forEach(td => td.innerHTML = '<span class="muted">–</span>');
const f = document.getElementById('ssFout'); f.textContent = '⚠️ ' + res.fout; f.hidden = false;
document.getElementById('ssTeller').hidden = true;
return;
}
let nee = 0;
cellen.forEach(td => {
const gebruiker = res.status[td.dataset.pid];
td.dataset.sort = gebruiker ? '1' : '0';
td.innerHTML = gebruiker
? `<span class="ss-teken ja" title="Smartschool-gebruiker: ${gebruiker}">✓</span>`
: '<span class="ss-teken nee" title="Geen Smartschool-gebruiker gevonden">✕</span>';
if (!gebruiker) nee++;
});
ssKlaar = true; toon();
}).catch(() => cellen.forEach(td => td.innerHTML = '<span class="muted">?</span>'));
})();
</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Internetstatus{% endblock %}
{% block page_title %}Internetstatus{% endblock %}
{% block main_class %}narrow{% endblock %}
{% block extra_css %}
<style>
.status-display {
display: flex; align-items: center; justify-content: center; gap: 14px;
font-size: 2.2em; font-weight: 800;
padding: 28px 20px; border-radius: var(--radius-lg); margin-bottom: 20px;
border: 2px solid;
}
.status-dot { width: 22px; height: 22px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 6px rgba(0,0,0,.06); }
.online { background: var(--success-bg); color: var(--success-text); border-color: var(--success); }
.offline { background: var(--danger-bg); color: var(--danger-text); border-color: var(--danger); }
.note-display { font-size: 1.6em; font-weight: 700; text-align: center; margin-bottom: 8px; word-wrap: break-word; line-height: 1.25; }
.admin-block { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
</style>
{% endblock %}
{% block content %}
<div class="card">
{% set status_text = 'Online' if current_status == 1 else 'Offline' %}
{% set status_class = 'online' if current_status == 1 else 'offline' %}
<div class="status-display {{ status_class }}">
<span class="status-dot"></span> Internet: {{ status_text }}
</div>
{% if current_note %}
<div class="note-display">{{ current_note }}</div>
{% endif %}
{% if session.get('security_group')|int == 1 %}
<div class="admin-block">
<form method="POST" action="{{ url_for('tools.toggle_internet') }}">
{% if current_status == 1 %}
<button type="submit" class="btn-block btn-lg btn-danger" name="new_status" value="0">Zet status op OFFLINE</button>
{% else %}
<button type="submit" class="btn-block btn-lg btn-success" name="new_status" value="1">Zet status op ONLINE</button>
{% endif %}
</form>
<form method="POST" action="{{ url_for('tools.save_note') }}" style="margin-top: 20px;">
<div class="field">
<label for="status_note">Tekst bij de status</label>
<textarea id="status_note" name="status_note" class="note-textarea" placeholder="Typ hier een korte tekst..." style="min-height: 80px;">{{ current_note }}</textarea>
</div>
<button type="submit" class="btn-secondary">💾 Notitie opslaan</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
+310
View File
@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block title %}Eigendom{% endblock %}
{% block page_title %}Eigendom{% endblock %}
{% block main_class %}wide{% endblock %}
{% block extra_css %}
<style>
.col-gebruikersnaam, .col-ssnummer { display: none; }
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; }
.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; }
/* Actieknoppen in een raster van 2 kolommen, zodat de hele regel zonder horizontaal schuiven zichtbaar is */
td.actions { width: 1%; }
.knoppen { display: grid; grid-template-columns: repeat(2, auto); gap: 6px; justify-content: end; }
.knoppen > *, .knoppen form button { white-space: nowrap; margin: 0; width: 100%; text-align: center; }
.knoppen form { margin: 0; }
td.col-naam { min-width: 120px; }
.toestelfilter { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; margin: 4px 0 10px; }
.toestelfilter button { border: 0; border-radius: 0; margin: 0; width: auto; padding: 6px 14px; font-size: .9em;
background: var(--surface, #fff); color: inherit; font-weight: 500; cursor: pointer; }
.toestelfilter button + button { border-left: 1px solid var(--border); }
.toestelfilter button[aria-pressed="true"] { background: var(--primary-color); color: #fff; }
.toestelfilter b { margin-left: 4px; }
tr.geen-toestel td.col-kamxm { color: var(--text-muted, #64748b); font-style: italic; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head"><h2>🏷️ Toestellen bij leerlingen</h2></div>
<form id="search-form" class="search-bar" method="POST" action="{{ url_for('devices.inventaris_eigendom') }}">
<input type="text" name="query" placeholder="Zoek op naam, internnummer, toestelnummer of serienummer..." value="{{ query or '' }}">
<button type="submit">🔍 Zoeken</button>
</form>
{% if message %}
<div class="alert">{{ message }}</div>
{% endif %}
{% if results %}
{% set met = results|selectattr('heeft_toestel')|list|length %}
<div class="toestelfilter" role="group" aria-label="Filter op toestel">
<button type="button" data-filter="" aria-pressed="true">Alle <b>{{ results|length }}</b></button>
<button type="button" data-filter="1" aria-pressed="false">✅ Met toestel <b>{{ met }}</b></button>
<button type="button" data-filter="0" aria-pressed="false">⛔ Zonder toestel <b>{{ results|length - met }}</b></button>
</div>
<div class="column-toggles" id="column-toggles">
<label><input type="checkbox" id="toggle-internnummer" checked onchange="toggleColumn('col-internnummer')"> Internnummer</label>
<label><input type="checkbox" id="toggle-naam" checked onchange="toggleColumn('col-naam')"> Naam</label>
<label><input type="checkbox" id="toggle-gebruikersnaam" onchange="toggleColumn('col-gebruikersnaam')"> Gebruikersnaam</label>
<label><input type="checkbox" id="toggle-ssnummer" onchange="toggleColumn('col-ssnummer')"> SS_nummer</label>
<label><input type="checkbox" id="toggle-kamxm" checked onchange="toggleColumn('col-kamxm')"> Toestelnummer</label>
<label><input type="checkbox" id="toggle-serie" checked onchange="toggleColumn('col-serie')"> Serienummer</label>
<label><input type="checkbox" id="toggle-sinds" checked onchange="toggleColumn('col-sinds')"> Sinds</label>
<label><input type="checkbox" id="toggle-status" checked onchange="toggleColumn('col-status')"> Status</label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-internnummer sorteer" data-kol="0">Internnummer <span class="pijl"></span></th>
<th class="col-naam sorteer" data-kol="1">Naam <span class="pijl"></span></th>
<th class="col-gebruikersnaam sorteer" data-kol="2">Gebruikersnaam <span class="pijl"></span></th>
<th class="col-ssnummer sorteer" data-kol="3">SS_nummer <span class="pijl"></span></th>
<th class="col-kamxm sorteer" data-kol="4">Toestelnummer <span class="pijl"></span></th>
<th class="col-serie sorteer" data-kol="5">Serienummer <span class="pijl"></span></th>
<th class="col-sinds sorteer" data-kol="6">Sinds <span class="pijl"></span></th>
<th class="col-status sorteer" data-kol="7">Status <span class="pijl"></span></th>
<th class="actions" aria-label="Acties"></th>
</tr>
</thead>
<tbody id="eigendomBody">
{% for row in results %}
<tr data-toestel="{{ 1 if row['heeft_toestel'] else 0 }}" class="{{ '' if row['heeft_toestel'] else 'geen-toestel' }}">
<td class="col-internnummer" data-sort="{{ row['internnummer'] }}"><strong>{{ row['internnummer'] }}</strong></td>
<td class="col-naam" data-sort="{{ row['Naam'] or '' }} {{ row['Voornaam'] or '' }}">{{ row['Voornaam'] or '' }} {{ row['Naam'] or '' }}</td>
<td class="col-gebruikersnaam" data-sort="{{ row['Gebruikersnaam'] or '' }}">{{ row['Gebruikersnaam'] or '' }}</td>
<td class="col-ssnummer" data-sort="{{ row['SS_nummer'] or '' }}">{{ row['SS_nummer'] or '' }}</td>
<td class="col-kamxm" data-sort="{{ row['kamxm_nummer'] or '' }}">{{ row['kamxm_nummer'] or ('' if row['heeft_toestel'] else 'geen toestel') }}</td>
<td class="col-serie" data-sort="{{ row['Serienummer'] or '' }}">{{ row['Serienummer'] or '' }}</td>
<td class="col-sinds nowrap" data-sort="{{ row['Datum_toekenning'] or '' }}">{{ row['Datum_toekenning'].strftime('%d-%m-%Y') if row['Datum_toekenning'] and row['Datum_toekenning'].strftime is defined else (row['Datum_toekenning'] or '') }}</td>
<td class="col-status" data-sort="{{ row['locked'] | int }}">
{% if not row['heeft_toestel'] %}<span class="badge">—</span>
{% elif row['locked'] | int == 1 %}<span class="badge badge-danger">🔒 Geblokkeerd</span>
{% else %}<span class="badge badge-success">🔓 Actief</span>
{% endif %}
</td>
<td class="actions">
<span class="knoppen">
{% if mag('toestel_blokkeren') and row['heeft_toestel'] %}
<form class="action-form" method="POST" action="{{ url_for('devices.toggle_lock') }}" style="margin:0">
<input type="hidden" name="internnummer" value="{{ row['internnummer'] }}">
<input type="hidden" name="query" value="{{ query }}">
{% if row['locked'] | int == 1 %}
<button type="submit" class="btn-sm btn-success">🔓 Deblokkeren</button>
{% else %}
<button type="submit" class="btn-sm btn-danger">🔒 Blokkeren</button>
{% endif %}
</form>
{% endif %}
{% if mag('toestellen_uitdelen') and row['heeft_toestel'] %}
<button type="button" class="btn-sm btn-warning"
data-id="{{ row['Eigendom_ID'] }}" data-intern="{{ row['internnummer'] }}"
data-toestel="{{ row['kamxm_nummer'] or row['Serienummer'] or '' }}"
data-naam="{{ row['Voornaam'] or '' }} {{ row['Naam'] or '' }}"
onclick="openEigendomBewerken(this)">✏️ Bewerken</button>
{% endif %}
{% if mag('toestellen_uitdelen') %}
{% if row['heeft_toestel'] %}
<a class="btn btn-sm btn-light" title="Naar het inleverformulier, al ingevuld"
href="{{ url_for('devices.inleveren_page', internnummer=row['internnummer'], toestel=row['kamxm_nummer'] or row['Serienummer'] or '', naam=((row['Voornaam'] or '') ~ ' ' ~ (row['Naam'] or ''))|trim) }}">📥 Inleveren</a>
{% else %}
<a class="btn btn-sm btn-success" title="Naar het uitdeelformulier met deze leerling ingevuld"
href="{{ url_for('devices.uitdelen_page', internnummer=row['internnummer'], naam=((row['Voornaam'] or '') ~ ' ' ~ (row['Naam'] or ''))|trim) }}">📤 Uitdelen</a>
{% endif %}
{% endif %}
<button type="button" class="btn-sm btn-light" data-intern="{{ row['internnummer'] }}"
data-naam="{{ row['Voornaam'] or '' }} {{ row['Naam'] or '' }}"
onclick="openHistorie(this)" title="Alle toestellen die deze leerling gehad heeft">🕑 Historie</button>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="eigendomTeller"></div>
<span id="eigendomPerPagina"></span>
</div>
<nav class="paginering" id="eigendomPaginering" aria-label="Pagina's"></nav>
</div>
{% elif query %}
<p class="muted">Geen resultaten gevonden voor "{{ query }}".</p>
{% else %}
<p class="muted">Er zijn nog geen leerlingen.</p>
{% endif %}
</div>
{% endblock %}
{% block modals %}
<div id="historieModal" class="modal">
<div class="modal-content" style="max-width:900px">
<span class="close" onclick="document.getElementById('historieModal').style.display='none'">&times;</span>
<h3>🕑 Toestellen van <span id="hNaam"></span></h3>
<p class="muted small" id="hSub" style="margin-top:-8px"></p>
<div id="hInhoud"><p class="muted">Laden...</p></div>
</div>
</div>
<div id="eigendomModal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('eigendomModal').style.display='none'">&times;</span>
<h3>✏️ Eigendom aanpassen</h3>
<p class="muted small" id="emHuidig" style="margin-top:-8px"></p>
<form method="POST" action="{{ url_for('devices.eigendom_bewerken') }}" id="eigendomForm" autocomplete="off">
<input type="hidden" name="eigendom_id" id="emId">
<input type="hidden" name="query" value="{{ query or '' }}">
<div class="field zoek-wrap">
<label for="internnummer">Internnummer</label>
<input type="text" id="internnummer" name="internnummer" required autocomplete="off"
placeholder="Zoek op naam of typ internnummer..." role="combobox"
aria-autocomplete="list" aria-controls="leerlingSuggesties" aria-expanded="false">
<ul class="suggesties" id="leerlingSuggesties" role="listbox"></ul>
</div>
<div class="field zoek-wrap">
<label for="serienummer">Toestelnummer</label>
<input type="text" id="serienummer" name="toestelnummer" required autocomplete="off"
placeholder="Zoek toestelnummer (of serienummer)..." role="combobox"
aria-autocomplete="list" aria-controls="toestelSuggesties" aria-expanded="false">
<ul class="suggesties" id="toestelSuggesties" role="listbox"></ul>
</div>
<p class="field-hint">Kies je een ander toestel, dan komt het vorige toestel weer op <em>Beschikbaar</em>.
Een toestel dat al bij een andere leerling is, kan je hier niet kiezen: lever het eerst in.</p>
<button type="submit" class="btn-block">💾 Opslaan</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
{% if mag('toestellen_uitdelen') %}
{% set toestel_voorkeur = 'beschikbaar' %}{% include '_toestel_zoekveld.html' %}
<script>
function openEigendomBewerken(knop) {
document.getElementById('emId').value = knop.dataset.id;
document.getElementById('internnummer').value = knop.dataset.intern;
document.getElementById('serienummer').value = knop.dataset.toestel;
document.getElementById('emHuidig').textContent =
`Nu: ${knop.dataset.toestel} bij ${knop.dataset.naam.trim() || knop.dataset.intern} (${knop.dataset.intern})`;
document.getElementById('eigendomModal').style.display = 'block';
document.getElementById('internnummer').focus();
}
window.addEventListener('click', e => { if (e.target.id === 'eigendomModal') e.target.style.display = 'none'; });
</script>
{% endif %}
<script>
const HISTORIE_URL = {{ url_for('devices.eigendom_historie', internnummer='__I__')|tojson }};
const esc = t => String(t ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
function duur(dagen) {
if (dagen == null) return '';
if (dagen < 31) return dagen + ' dag' + (dagen === 1 ? '' : 'en');
const m = Math.round(dagen / 30.44);
return m < 12 ? m + ' maand' + (m === 1 ? '' : 'en') : (dagen / 365.25).toFixed(1).replace('.', ',') + ' jaar';
}
function openHistorie(knop) {
const venster = document.getElementById('historieModal'), inhoud = document.getElementById('hInhoud');
document.getElementById('hNaam').textContent = knop.dataset.naam.trim() || knop.dataset.intern;
document.getElementById('hSub').textContent = 'Internnummer ' + knop.dataset.intern;
inhoud.innerHTML = '<p class="muted">Laden...</p>';
venster.style.display = 'block';
fetch(HISTORIE_URL.replace('__I__', encodeURIComponent(knop.dataset.intern)), { credentials: 'same-origin' })
.then(r => r.json()).then(res => {
const t = res.toestellen || [];
if (res.gebruikersnaam) document.getElementById('hSub').textContent += ' · ' + res.gebruikersnaam;
if (!t.length) { inhoud.innerHTML = '<p class="muted">Geen toestellen gevonden.</p>'; return; }
inhoud.innerHTML = `<p class="muted small">${t.length} toestel(len) in totaal</p>
<div class="table-wrap"><table><thead><tr><th>Toestelnummer</th><th>Serienummer</th><th>Type</th>
<th>Gekregen</th><th>Ingeleverd</th><th>Duur</th><th>Opmerkingen</th></tr></thead><tbody>`
+ t.map(x => `<tr${x.huidig ? ' style="background:var(--primary-light)"' : ''}>
<td><strong>${esc(x.toestel)}</strong></td><td>${esc(x.serienummer)}</td><td>${esc(x.type)}</td>
<td class="nowrap">${esc(x.van)}</td>
<td class="nowrap">${x.huidig ? '<span class="badge badge-success">nog in gebruik</span>' : esc(x.tot)}${x.geblokkeerd && x.huidig ? ' <span class="badge badge-danger">🔒</span>' : ''}</td>
<td class="nowrap">${duur(x.dagen)}</td><td class="small">${esc(x.opmerkingen)}</td></tr>`).join('')
+ '</tbody></table></div>';
}).catch(() => { inhoud.innerHTML = '<div class="alert alert-danger">Kon de historie niet ophalen.</div>'; });
}
window.addEventListener('click', e => { if (e.target.id === 'historieModal') e.target.style.display = 'none'; });
// Sorteren (klik op kolomkop) en 10 per pagina — in de browser
(function () {
const body = document.getElementById('eigendomBody');
if (!body) return;
let PER_PAGINA = perPagina('eigendom');
let pagina = 1, kol = null, op = true, filter = '';
// Filter: alle leerlingen / met toestel / zonder toestel
document.querySelectorAll('.toestelfilter button').forEach(b => b.addEventListener('click', () => {
filter = b.dataset.filter;
document.querySelectorAll('.toestelfilter button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
pagina = 1; toon();
}));
function toon() {
const zichtbaar = Array.from(body.rows).filter(tr => !filter || tr.dataset.toestel === filter);
const totaal = zichtbaar.length;
const grootte = PER_PAGINA || Infinity;
const paginas = Math.max(1, Math.ceil(totaal / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte; // 0 = alles
Array.from(body.rows).forEach(tr => tr.style.display = 'none');
zichtbaar.forEach((tr, i) => tr.style.display = (i >= van && i < tot) ? '' : 'none');
document.getElementById('eigendomTeller').textContent = totaal
? `${van + 1}–${Math.min(tot, totaal)} van ${totaal} leerling(en)` : 'Geen leerlingen';
const nav = document.getElementById('eigendomPaginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : true; kol = k;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
const rijen = Array.from(body.rows);
rijen.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;
});
rijen.forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(document.getElementById('eigendomPerPagina'), 'eigendom', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
function toggleColumn(columnClass) {
const checkbox = document.getElementById('toggle-' + columnClass.replace('col-', ''));
if (!checkbox) return;
const displayStyle = checkbox.checked ? 'table-cell' : 'none';
document.querySelectorAll('.' + columnClass).forEach(cell => { cell.style.display = displayStyle; });
}
// Laadscherm tonen en dubbelklikken voorkomen bij elk formulier
document.addEventListener('submit', function (e) {
if (e.target.closest('.app-header')) return; // uitloggen niet blokkeren
showLoader();
document.querySelectorAll('button[type="submit"]').forEach(btn => { btn.disabled = true; });
});
</script>
{% endblock %}
+365
View File
@@ -0,0 +1,365 @@
{% extends "base.html" %}
{% block title %}IP-blokkades{% endblock %}
{% block page_title %}IP-blokkades{% endblock %}
{% block extra_css %}
<style>
.ip-form { display: grid; grid-template-columns: 1.2fr 1fr 2fr auto; gap: 10px; align-items: end; }
.ip-form.met-school { grid-template-columns: 1.2fr 1fr 1.4fr 2fr auto; }
.ip-form .field { margin: 0; }
.ip-form .school-veld[hidden] { display: none; }
/* Keuzelijst met vinkjes voor één of meer scholen */
.schoolkiezer { position: relative; }
.schoolkiezer summary { list-style: none; cursor: pointer; padding: 9px 30px 9px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--surface, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
position: relative; min-height: 20px; }
.schoolkiezer summary::-webkit-details-marker { display: none; }
.schoolkiezer summary::after { content: "▾"; position: absolute; right: 11px; top: 50%; transform: translateY(-50%); opacity: .6; }
.schoolkiezer[open] summary { border-color: var(--primary-color); }
.schoolkiezer .sk-lijst { position: absolute; z-index: 30; left: 0; top: calc(100% + 4px); min-width: 100%; width: max-content;
max-width: 380px; max-height: 300px; overflow: auto; background: var(--surface, #fff); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 8px 24px rgba(15,23,42,.14); padding: 6px; }
.schoolkiezer .sk-zoek { width: 100%; margin-bottom: 6px; }
.schoolkiezer label { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 6px; cursor: pointer;
font-weight: 400; margin: 0; white-space: nowrap; }
.schoolkiezer label:hover { background: var(--surface-muted); }
.schoolkiezer label input { width: auto; margin: 0; }
.schoolkiezer .sk-acties { display: flex; justify-content: space-between; gap: 8px; padding: 6px 4px 2px; border-top: 1px solid var(--border); margin-top: 4px; }
.schoolkiezer .sk-acties button { all: unset; cursor: pointer; color: var(--primary-color); font-size: .88em; }
.chip { display: inline-block; padding: 2px 9px; margin: 2px 4px 2px 0; border-radius: 999px; background: var(--surface-muted);
border: 1px solid var(--border); font-size: .88em; white-space: nowrap; }
/* Bewerken in de tabel "Altijd toestaan" */
tr .bew { display: none; }
tr.bewerken .bew { display: block; }
tr.bewerken .toon { display: none; }
tr.bewerken td { background: var(--surface-muted); vertical-align: top; }
tr.bewerken td.actions .bew { display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
tr.bewerken td.actions .bew button { width: 100%; white-space: nowrap; }
tr .bew input[type=text] { width: 100%; min-width: 120px; }
tr.bewerken .schoolkiezer { min-width: 200px; max-width: 280px; }
tr.bewerken .schoolkiezer label { white-space: normal; }
tr.bewerken .schoolkiezer .sk-lijst { position: static; box-shadow: none; max-width: none; width: auto; margin-top: 4px; }
.ip-tabel[data-sleutel="toe"] tbody tr:not(.bewerken):not(.empty-row) td:not(.actions) { cursor: text; }
.acties-rij { display: inline-flex; gap: 6px; }
@media (max-width: 760px) { .ip-form { grid-template-columns: 1fr; } }
.mijn-ip { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: 999px;
background: var(--surface-muted); border: 1px solid var(--border); font-size: .92em; }
td.mono, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.test-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.test-form input { flex: 1 1 220px; max-width: 320px; }
.modus { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: space-between;
padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); margin-top: 16px; }
.modus.aan { border-color: var(--danger-text); background: var(--danger-bg); }
.modus strong { display: block; }
.schakelaar { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
.schakelaar .spoor { width: 44px; height: 24px; border-radius: 999px; background: #cbd5e1; position: relative; transition: background .15s; flex-shrink: 0; }
.schakelaar .spoor::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.schakelaar.aan .spoor { background: var(--danger-text); }
.schakelaar.aan .spoor::after { transform: translateX(20px); }
.schakelaar button { all: unset; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.schakelaar button:focus-visible .spoor { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.tabel-zoek { margin-bottom: 10px; max-width: 360px; }
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; }
.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 %}
{% macro schoolkiezer(gekozen=[], formid='') %}
<details class="schoolkiezer">
<summary class="sk-tekst" data-leeg="— geen school —">— geen school —</summary>
<div class="sk-lijst">
{% if scholen|length > 8 %}<input type="search" class="sk-zoek" placeholder="Zoek een school..." aria-label="Zoek een school">{% endif %}
{% for s in scholen %}
<label><input type="checkbox" name="scholen" value="{{ s.code }}" data-naam="{{ s.naam }}"{% if formid %} form="{{ formid }}"{% endif %}{{ ' checked' if s.code in gekozen }}>
{{ s.naam }}{% if not s.actief %} <span class="muted small">(inactief)</span>{% endif %}</label>
{% else %}
<div class="muted small" style="padding:6px 8px">Er zijn nog geen scholen.</div>
{% endfor %}
{% if scholen %}<div class="sk-acties"><button type="button" class="sk-alles">Alles aanvinken</button><button type="button" class="sk-geen">Niets</button></div>{% endif %}
</div>
</details>
{% endmacro %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🚫 IP-blokkades</h2>
<span class="mijn-ip">Jouw IP-adres: <strong class="mono">{{ mijn_ip }}</strong></span>
</div>
<p class="field-hint" style="margin-top:-6px">Geldt voor alle scholen. Een geblokkeerd adres krijgt bij elke aanvraag
"Toegang geweigerd", ook op de loginpagina. Een adres bij <em>Altijd toestaan</em> mag altijd, ook als het in een
geblokkeerde reeks valt.</p>
<form method="POST" action="{{ url_for('ipblokkade.toevoegen') }}" class="ip-form">
<div class="field">
<label for="reeks">IP-adres of reeks</label>
<input type="text" id="reeks" name="reeks" required class="mono" placeholder="bv. 85. of 85.12.3.4 of 85.0.0.0/8">
</div>
<div class="field">
<label for="soort">Soort</label>
<select id="soort" name="soort">
<option value="blokkeren">🚫 Blokkeren</option>
<option value="toestaan">✅ Altijd toestaan</option>
</select>
</div>
<div class="field school-veld" hidden>
<label>School(s)</label>
{{ schoolkiezer() }}
</div>
<div class="field">
<label for="opmerking">Opmerking</label>
<input type="text" id="opmerking" name="opmerking" maxlength="255" placeholder="Waarom? (optioneel)">
</div>
<button type="submit">➕ Toevoegen</button>
</form>
{% if lokaal %}
<div class="alert alert-warning" style="margin:16px 0 0">PIVOT ziet jouw adres als <strong class="mono">{{ mijn_ip }}</strong>.
Waarschijnlijk draait PIVOT achter een reverse proxy, en dan lijkt elke bezoeker van dat adres te komen. Zet in
<code>config.ini › [algemeen] proxy_aantal = 1</code> en herstart PIVOT, anders werken de IP-regels niet zoals bedoeld.</div>
{% endif %}
<div class="modus {{ 'aan' if enkel_toegestaan }}">
<div>
<strong>Enkel "Altijd toestaan" binnenlaten</strong>
<span class="muted small">
{% if enkel_toegestaan %}Staat <b>aan</b>: elk adres dat niet bij Altijd toestaan staat, wordt geweigerd
({{ modus_geweigerd }} keer sinds de teller begon){% if modus and modus.Door %} · aangezet door {{ modus.Door }}{% endif %}.
{% else %}Staat uit: iedereen mag binnen, behalve de geblokkeerde adressen.{% endif %}
</span>
</div>
<form method="POST" action="{{ url_for('ipblokkade.modus') }}" style="margin:0"
onsubmit="return confirm({{ ("Iedereen weer binnenlaten (behalve de geblokkeerde adressen)?" if enkel_toegestaan else "Enkel nog adressen uit 'Altijd toestaan' binnenlaten? Iedereen anders krijgt 'Toegang geweigerd', ook op de loginpagina.")|tojson|forceescape }})">
<input type="hidden" name="aan" value="{{ '0' if enkel_toegestaan else '1' }}">
<span class="schakelaar {{ 'aan' if enkel_toegestaan }}">
<button type="submit" role="switch" aria-checked="{{ 'true' if enkel_toegestaan else 'false' }}">
<span class="spoor"></span> {{ 'Aan' if enkel_toegestaan else 'Uit' }}
</button>
</span>
</form>
</div>
</div>
{% for titel, lijst, leeg, sleutel in (('🚫 Geblokkeerd', blokkeren, 'Er is nog niets geblokkeerd.', 'blok'),
('✅ Altijd toestaan', toestaan, 'Geen uitzonderingen.', 'toe')) %}
<div class="card ip-tabel" data-sleutel="{{ sleutel }}">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<h2 class="card-title">{{ titel }} <span class="muted small">({{ lijst|length }})</span></h2>
{% if sleutel == 'blok' and lijst %}
<form method="post" action="{{ url_for('ipblokkade.leegmaken') }}" style="margin:0;"
onsubmit="return confirm({{ ('Alle ' ~ (lijst|length) ~ ' geblokkeerde regel' ~ ('s' if lijst|length != 1 else '') ~ ' verwijderen? De lijst \'Altijd toestaan\' blijft ongewijzigd.')|tojson|forceescape }})">
<button type="submit" class="btn-sm btn-danger">🗑️ Bloklijst leegmaken</button>
</form>
{% endif %}
</div>
{% if lijst|length > 10 %}<input type="search" class="tabel-zoek" placeholder="Zoek een reeks{{ ', school' if sleutel == 'toe' }} of opmerking..." aria-label="Zoeken in {{ titel }}">{% endif %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>Reeks <span class="pijl"></span></th>
{% if sleutel == 'toe' %}<th class="sorteer" data-tekst>School <span class="pijl"></span></th>{% endif %}
<th class="sorteer" data-tekst>Opmerking <span class="pijl"></span></th>
<th class="sorteer">Toegevoegd <span class="pijl"></span></th>
{% if lijst is sameas blokkeren %}<th class="sorteer text-right">Geweigerd <span class="pijl"></span></th>
<th class="sorteer">Laatst geweigerd <span class="pijl"></span></th>{% endif %}
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for r in lijst %}
{% set bf = 'bew-' ~ r.ID %}
<tr data-id="{{ r.ID }}">
{% if sleutel == 'toe' %}
<td class="mono" data-sort="{{ r.Sorteer }}"><span class="toon"><strong>{{ r.Reeks }}</strong></span>
<span class="bew"><input type="text" name="reeks" form="{{ bf }}" value="{{ r.Reeks }}" required class="mono" aria-label="Reeks"></span></td>
<td data-sort="{{ r.Scholen_namen|join(', ') }}"><span class="toon">{% for n in r.Scholen_namen %}<span class="chip">{{ n }}</span>{% else %}<span class="muted">–</span>{% endfor %}</span>
<span class="bew">{{ schoolkiezer(r.Scholen_codes, bf) }}</span></td>
<td data-sort="{{ r.Opmerking or '' }}"><span class="toon">{{ r.Opmerking or '' }}</span>
<span class="bew"><input type="text" name="opmerking" form="{{ bf }}" value="{{ r.Opmerking or '' }}" maxlength="255" placeholder="Opmerking" aria-label="Opmerking"></span></td>
{% else %}
<td class="mono" data-sort="{{ r.Sorteer }}"><strong>{{ r.Reeks }}</strong></td>
<td data-sort="{{ r.Opmerking or '' }}">{{ r.Opmerking or '' }}</td>
{% endif %}
<td class="muted small" data-sort="{{ r.Aangemaakt.isoformat() }}">{{ r.Aangemaakt.strftime('%d-%m-%Y %H:%M') }}{% if r.Door %} · {{ r.Door }}{% endif %}</td>
{% if lijst is sameas blokkeren %}
<td class="text-right" data-sort="{{ '%010d'|format(r.Aantal_geweigerd) }}">{{ r.Aantal_geweigerd }}</td>
<td class="muted small" data-sort="{{ r.Laatst_geweigerd.isoformat() if r.Laatst_geweigerd else '' }}">{{ r.Laatst_geweigerd.strftime('%d-%m-%Y %H:%M') if r.Laatst_geweigerd else '–' }}</td>
{% endif %}
<td class="actions">
<span class="toon acties-rij">
{% if sleutel == 'toe' %}<button type="button" class="btn-sm btn-ghost start-bew">✏️ Bewerken</button>{% endif %}
<form method="POST" action="{{ url_for('ipblokkade.verwijderen', regel_id=r.ID) }}" style="margin:0"
onsubmit="return confirm({{ (r.Reeks ~ ' uit de lijst verwijderen?')|tojson|forceescape }})">
<button type="submit" class="btn-sm btn-ghost">🗑️ Verwijderen</button>
</form>
</span>
{% if sleutel == 'toe' %}
<span class="bew">
<form id="{{ bf }}" method="POST" action="{{ url_for('ipblokkade.bewerken', regel_id=r.ID) }}" style="margin:0">
<button type="submit" class="btn-sm">💾 Opslaan</button>
</form>
<button type="button" class="btn-sm btn-light stop-bew">Annuleren</button>
</span>
{% endif %}
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">{{ leeg }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if lijst %}
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small teller"></div>
<span class="per-pagina-plek"></span>
</div>
<nav class="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
{% endfor %}
<div class="card">
<h2 class="card-title">🔎 Een adres testen</h2>
<form method="GET" action="{{ url_for('ipblokkade.overzicht') }}" class="test-form">
<input type="text" name="test" class="mono" placeholder="bv. 85.12.3.4" value="{{ test.ip if test else '' }}">
<button type="submit" class="btn-secondary">Testen</button>
{% if test %}
{% if test.geweigerd %}
<span class="badge badge-danger">🚫 Geweigerd door {{ test.regel.Reeks if test.regel else 'een regel' }}</span>
{% else %}
<span class="badge badge-success">✅ Toegelaten</span>
{% endif %}
{% endif %}
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
// Keuzelijst met vinkjes: tekst bovenaan = gekozen scholen
function skTekst(sk) {
const namen = Array.from(sk.querySelectorAll('input[type=checkbox]:checked')).map(c => c.dataset.naam);
const t = sk.querySelector('.sk-tekst');
t.textContent = !namen.length ? t.dataset.leeg : namen.length <= 2 ? namen.join(', ') : `${namen.length} scholen`;
t.title = namen.join('\n');
}
document.querySelectorAll('.schoolkiezer').forEach(sk => {
skTekst(sk);
sk.addEventListener('change', () => skTekst(sk));
const zoek = sk.querySelector('.sk-zoek');
if (zoek) zoek.addEventListener('keydown', e => { if (e.key === 'Enter') e.preventDefault(); });
if (zoek) zoek.addEventListener('input', () => {
const q = zoek.value.trim().toLowerCase();
sk.querySelectorAll('label').forEach(l => l.style.display = l.textContent.toLowerCase().includes(q) ? '' : 'none');
});
sk.querySelector('.sk-alles')?.addEventListener('click', () => {
sk.querySelectorAll('label').forEach(l => { if (l.style.display !== 'none') l.querySelector('input').checked = true; });
skTekst(sk);
});
sk.querySelector('.sk-geen')?.addEventListener('click', () => {
sk.querySelectorAll('input[type=checkbox]').forEach(c => c.checked = false); skTekst(sk);
});
sk.addEventListener('toggle', () => { if (sk.open && zoek) zoek.focus(); });
});
// Klik buiten een open keuzelijst = dichtklappen
document.addEventListener('click', e => {
document.querySelectorAll('.schoolkiezer[open]').forEach(sk => { if (!sk.contains(e.target)) sk.open = false; });
});
// Tabel "Altijd toestaan" bewerken: ✏️ of dubbelklik op een rij; Esc = annuleren, Enter = opslaan
function stopBewerken(tr) {
const f = document.getElementById('bew-' + tr.dataset.id);
if (f) f.reset();
tr.classList.remove('bewerken');
tr.querySelectorAll('.schoolkiezer').forEach(sk => { sk.open = false; skTekst(sk); });
}
function startBewerken(tr) {
document.querySelectorAll('tr.bewerken').forEach(stopBewerken);
tr.classList.add('bewerken');
const veld = tr.querySelector('.bew input[name=reeks]');
if (veld) { veld.focus(); veld.select(); }
}
document.querySelectorAll('.ip-tabel[data-sleutel="toe"] tbody tr[data-id]').forEach(tr => {
tr.querySelector('.start-bew')?.addEventListener('click', () => startBewerken(tr));
tr.querySelector('.stop-bew')?.addEventListener('click', () => stopBewerken(tr));
tr.addEventListener('dblclick', e => {
if (!tr.classList.contains('bewerken') && !e.target.closest('td.actions')) startBewerken(tr);
});
tr.addEventListener('keydown', e => { if (e.key === 'Escape') stopBewerken(tr); });
});
// Schoolkeuze enkel tonen bij "Altijd toestaan"
(function () {
const soort = document.getElementById('soort'), veld = document.querySelector('.school-veld');
if (!soort || !veld) return;
const pas = () => {
const toe = soort.value === 'toestaan';
veld.hidden = !toe;
veld.closest('form').classList.toggle('met-school', toe);
if (!toe) veld.querySelectorAll('input[type=checkbox]').forEach(c => c.checked = false);
veld.querySelectorAll('.schoolkiezer').forEach(skTekst);
};
soort.addEventListener('change', pas); pas();
})();
// Zoeken en pagineren per tabel (Geblokkeerd / Altijd toestaan)
document.querySelectorAll('.ip-tabel').forEach(kaart => {
const body = kaart.querySelector('tbody');
if (!body || body.querySelector('.empty-row')) return;
const sleutel = 'ip-' + kaart.dataset.sleutel;
let PER_PAGINA = perPagina(sleutel), pagina = 1, zoek = '';
const zoekveld = kaart.querySelector('.tabel-zoek');
function toon() {
const tekst = tr => Array.from(tr.cells).map(td => (td.querySelector('.toon') || td).textContent).join(' ').toLowerCase();
const rijen = Array.from(body.rows).filter(tr => !zoek || tekst(tr).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');
kaart.querySelector('.teller').textContent = rijen.length
? `${van + 1}–${Math.min(tot, rijen.length)} van ${rijen.length}` : 'Niets gevonden';
const nav = kaart.querySelector('.paginering');
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); }
}
if (zoekveld) zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
// Sorteren: klik op een kolomkop (nog eens klikken = omgekeerd). Aantallen en datums: eerst hoog/nieuw.
let kol = null, op = true;
kaart.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : 'tekst' in th.dataset;
kol = k;
kaart.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; // lege waarden altijd onderaan
const c = x.localeCompare(y, 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(kaart.querySelector('.per-pagina-plek'), sleutel, n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
});
</script>
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Toegestane IP-adressen{% endblock %}
{% block page_title %}Toegestane IP-adressen{% endblock %}
{% block extra_css %}
<style>
.ip-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
.ip-form .field { margin: 0; flex: 0 1 340px; }
.mijn-ip { display: inline-flex; gap: 8px; align-items: center; padding: 6px 12px; border-radius: 999px;
background: var(--surface-muted); border: 1px solid var(--border); font-size: .92em; }
.mijn-ip button { all: unset; cursor: pointer; color: var(--primary-color); font-size: .9em; }
.mono, td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.tabel-zoek { margin-bottom: 10px; max-width: 360px; }
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; }
.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 %}
<div class="card">
<div class="page-head">
<h2>✅ Toegestane IP-adressen · {{ school_naam }}</h2>
<span class="mijn-ip">Jouw IP-adres: <strong class="mono">{{ mijn_ip }}</strong>
{% if not lokaal %}<button type="button" id="gebruikMijnIp" title="Invullen in het veld hieronder">invullen</button>{% endif %}</span>
</div>
<p class="field-hint" style="margin-top:-6px">Een adres in deze lijst mag PIVOT altijd openen. Je voegt het toe voor
{{ school_naam }}, met jouw naam ({{ naam }}) als opmerking. Je kan enkel de adressen verwijderen die je zelf toegevoegd hebt.</p>
<form method="POST" action="{{ url_for('ipblokkade.toevoegen') }}" class="ip-form">
<div class="field">
<label for="reeks">IP-adres</label>
<input type="text" id="reeks" name="reeks" required class="mono" placeholder="bv. 85.12.3.4" autocomplete="off">
</div>
<button type="submit">➕ Toevoegen</button>
</form>
</div>
<div class="card ip-tabel" data-sleutel="school">
<h2 class="card-title">Lijst <span class="muted small">({{ rijen|length }})</span></h2>
{% if rijen|length > 10 %}<input type="search" class="tabel-zoek" placeholder="Zoek een adres of naam..." aria-label="Zoeken">{% endif %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-tekst>IP-adres <span class="pijl"></span></th>
<th class="sorteer" data-tekst>Opmerking <span class="pijl"></span></th>
<th class="sorteer">Toegevoegd <span class="pijl"></span></th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for r in rijen %}
<tr>
<td class="mono" data-sort="{{ r.Sorteer }}"><strong>{{ r.Reeks }}</strong></td>
<td data-sort="{{ r.Opmerking or '' }}">{{ r.Opmerking or '' }}</td>
<td class="muted small" data-sort="{{ r.Aangemaakt.isoformat() }}">{{ r.Aangemaakt.strftime('%d-%m-%Y %H:%M') }}</td>
<td class="actions">
{% if r.Eigen %}
<form method="POST" action="{{ url_for('ipblokkade.verwijderen', regel_id=r.ID) }}" style="margin:0"
onsubmit="return confirm({{ (r.Reeks ~ ' uit de lijst verwijderen?')|tojson|forceescape }})">
<button type="submit" class="btn-sm btn-ghost">🗑️ Verwijderen</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4">Er zijn nog geen toegestane adressen voor {{ school_naam }}.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if rijen %}
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small teller"></div>
<span class="per-pagina-plek"></span>
</div>
<nav class="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('gebruikMijnIp')?.addEventListener('click', () => {
const v = document.getElementById('reeks'); v.value = {{ mijn_ip|tojson }}; v.focus();
});
document.querySelectorAll('.ip-tabel').forEach(kaart => {
const body = kaart.querySelector('tbody');
if (!body || body.querySelector('.empty-row')) return;
const sleutel = 'ip-' + kaart.dataset.sleutel;
let PER_PAGINA = perPagina(sleutel), pagina = 1, zoek = '';
const zoekveld = kaart.querySelector('.tabel-zoek');
function toon() {
const rijen = Array.from(body.rows).filter(tr => !zoek || tr.textContent.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');
kaart.querySelector('.teller').textContent = rijen.length
? `${van + 1}–${Math.min(tot, rijen.length)} van ${rijen.length}` : 'Niets gevonden';
const nav = kaart.querySelector('.paginering');
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); }
}
if (zoekveld) zoekveld.addEventListener('input', () => { zoek = zoekveld.value.trim().toLowerCase(); pagina = 1; toon(); });
let kol = null, op = true;
kaart.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = th.cellIndex;
op = kol === k ? !op : 'tekst' in th.dataset;
kol = k;
kaart.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(kaart.querySelector('.per-pagina-plek'), sleutel, n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
});
</script>
{% endblock %}
+231
View File
@@ -0,0 +1,231 @@
{% extends "base.html" %}
{% block title %}Laatkomers{% endblock %}
{% block page_title %}Laatkomers{% endblock %}
{% block extra_css %}
<style>
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat { padding: 18px 20px; }
.stat + .stat { margin-top: 0; }
.stat .val { font-size: 28px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .lbl { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; margin-top: 4px; }
.stat .sub { font-size: 13px; color: var(--text-muted); }
.sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.progress { flex: 1 1 200px; height: 8px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
.periode { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.periode .field { margin: 0; }
.zoekbalk { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.zoekbalk input[type=search] { flex: 1 1 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; }
.count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700;
background: var(--warning-bg); color: var(--warning-text); font-variant-numeric: tabular-nums; }
.count-badge.veel { background: var(--danger-bg); color: var(--danger-text); }
details.data summary { cursor: pointer; color: var(--primary-color); font-size: .9em; }
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; max-width: 520px; }
.day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 3px 9px; border-radius: 999px; background: var(--surface);
border: 1px solid var(--border); font-size: 12.5px; white-space: nowrap; }
.day-chip .dd { font-weight: 700; color: var(--warning-text); }
.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; }
td.nowrap { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
{# ---------- Kerncijfers ---------- #}
<div class="stats">
<div class="card stat">
<div class="val">{{ rows|length }}</div>
<div class="lbl">Leerlingen te laat</div>
</div>
<div class="card stat">
<div class="val">{{ totaal }}</div>
<div class="lbl">Keer te laat</div>
{% if rows %}<div class="sub">gem. {{ '%.1f'|format(totaal / rows|length) }} per leerling</div>{% endif %}
</div>
<div class="card stat">
<div class="val" style="font-size:18px; padding-top:6px">
{% if laatste.m %}{{ laatste.m.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}
</div>
<div class="lbl">Laatst gesynchroniseerd</div>
<div class="sub">Automatisch elke ochtend</div>
</div>
</div>
{% if fout %}<div class="flash-message error"><span>{{ fout }}</span></div>{% endif %}
{% if not codes %}
<div class="alert alert-warning">Er zijn geen codes voor "te laat" ingesteld. Vul ze in bij 🔌 Koppelingen › Smartschool › <em>Codes voor te laat</em>.</div>
{% endif %}
{# ---------- Synchronisatie en periode ---------- #}
<div class="card">
<div class="sync-bar">
<form method="POST" action="{{ url_for('afwezigheden.sync', van=f.van.isoformat(), tot=f.tot.isoformat()) }}" style="margin:0">
<input type="hidden" name="terug" value="laatkomers">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig %}disabled{% endif %}>🔄 Nu synchroniseren</button>
</form>
<form method="POST" action="{{ url_for('afwezigheden.sync', van=f.van.isoformat(), tot=f.tot.isoformat()) }}" style="margin:0"
onsubmit="return confirm('Alle schooldagen van dit schooljaar opnieuw ophalen uit Smartschool? Dit duurt enkele minuten.')">
<input type="hidden" name="terug" value="laatkomers">
<input type="hidden" name="volledig" value="1">
<button type="submit" class="btn-light" {% if status.bezig %}disabled{% endif %}
title="Nodig de eerste keer: vroeger opgehaalde dagen bevatten de codes voor te laat nog niet">⏮ Heel schooljaar opnieuw ophalen</button>
</form>
<div class="progress {% if not status.bezig %}hidden{% endif %}" id="syncProgress"><span id="syncProgressBar"></span></div>
<span class="muted small" id="syncText">
{% if status.bezig %}Bezig…{% elif status.laatste_resultaat %}{{ status.laatste_resultaat }}{% endif %}
</span>
</div>
<form method="GET" action="{{ url_for('afwezigheden.laatkomers') }}" class="periode" style="margin-top:14px">
<div class="field">
<label for="van">Van</label>
<input type="date" id="van" name="van" value="{{ f.van.isoformat() }}">
</div>
<div class="field">
<label for="tot">Tot en met</label>
<input type="date" id="tot" name="tot" value="{{ f.tot.isoformat() }}">
</div>
<button type="submit">Toon periode</button>
</form>
</div>
{# ---------- Tabel ---------- #}
<div class="card">
<div class="zoekbalk">
<input type="search" id="zoek" placeholder="Zoek een leerling op naam, gebruikersnaam of internnummer..." aria-label="Zoek een leerling">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Leerling <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Internnummer <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">Aantal keer te laat <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">Laatst te laat <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">Eerst te laat <span class="pijl"></span></th>
<th>Data</th>
</tr>
</thead>
<tbody id="laatBody">
{% for r in rows %}
<tr data-zoek="{{ [r.Voornaam, r.Naam, r.Gebruikersnaam, r.Internnummer]|select|join(' ')|lower }}">
<td data-sort="{{ (r.Naam or '') ~ ' ' ~ (r.Voornaam or '') }}">
{% if r.Naam or r.Voornaam %}<strong>{{ r.Naam }}</strong> {{ r.Voornaam }}{% else %}<span class="muted">Onbekende leerling</span>{% endif %}
</td>
<td class="mono" data-sort="{{ r.Internnummer }}">{{ r.Internnummer }}</td>
<td data-sort="{{ '%05d'|format(r.Aantal) }}"><span class="count-badge {{ 'veel' if r.Aantal >= 5 }}">{{ r.Aantal }}</span></td>
<td class="nowrap" data-sort="{{ r.Laatste.isoformat() }}">{{ r.Laatste.strftime('%d-%m-%Y') }}</td>
<td class="nowrap" data-sort="{{ r.Eerste.isoformat() }}">{{ r.Eerste.strftime('%d-%m-%Y') }}</td>
<td>
<details class="data"><summary>Toon {{ r.Data|length }}</summary>
<div class="day-chips">
{% for d, dd in r.Data %}<span class="day-chip">{{ ['ma','di','wo','do','vr','za','zo'][d.weekday()] }} {{ d.strftime('%d-%m') }} <span class="dd">{{ dd }}</span></span>{% endfor %}
</div>
</details>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Geen laatkomers in deze periode{% if not laatste.m %} (er is nog niets opgehaald uit Smartschool){% endif %}.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="teller"></div>
<span id="perPagina"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Zoeken, sorteren (klik op een kolomkop) en pagineren in de browser
(function () {
const body = document.getElementById('laatBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('laatkomers');
let pagina = 1, kol = 2, op = false, zoek = '';
document.querySelector('th.sorteer[data-kol="2"]').classList.add('af'); // standaard: meest te laat bovenaan
const zichtbaar = () => Array.from(body.rows).filter(tr => !zoek || tr.dataset.zoek.includes(zoek));
function toon() {
const rijen = zichtbaar();
const grootte = PER_PAGINA || Infinity;
const 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} leerling(en)` : 'Geen leerling gevonden';
const nav = document.getElementById('paginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); body.closest('.table-wrap').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
// aantal en datums: eerst hoog/nieuw → laag/oud; namen: eerst A → Z
op = kol === k ? !op : (k < 2);
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 || '';
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'), 'laatkomers', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
// Voortgang van een lopende synchronisatie volgen
(function pollSync() {
const bar = document.getElementById('syncProgressBar');
const box = document.getElementById('syncProgress');
const txt = document.getElementById('syncText');
const btn = document.getElementById('syncBtn');
let wasBusy = {{ 'true' if status.bezig else 'false' }};
function tick() {
fetch("{{ url_for('afwezigheden.status') }}").then(r => r.json()).then(s => {
if (s.bezig) {
wasBusy = true;
box.classList.remove('hidden'); btn.disabled = true;
const pct = s.totaal ? Math.round(s.voortgang / s.totaal * 100) : 0;
bar.style.width = pct + '%';
txt.textContent = `Bezig: dag ${s.voortgang} van ${s.totaal}…`;
setTimeout(tick, 2000);
} else if (wasBusy) {
window.location.reload();
}
}).catch(() => {});
}
if (wasBusy) tick();
})();
</script>
{% endblock %}
+198
View File
@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}{{ titel }}{% endblock %}
{% block page_title %}{{ titel }}{% endblock %}
{% block loader_text %}Bezig met tellen via Informat API...{% endblock %}
{% block content %}
<style>
.peil-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; margin-bottom: 14px; }
.peil-form .field { margin: 0; }
.peil-form input[type=date] { min-width: 180px; }
.peildata { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; padding: 0; list-style: none; }
.peildata a { display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
text-decoration: none; color: inherit; font-size: .9em; background: var(--surface); }
.peildata a.actief { border-color: var(--primary-color); color: var(--primary-color); font-weight: 700; }
.peildata a span { color: var(--text-muted, #64748b); font-weight: 400; margin-left: 4px; }
/* Campussen van een school met meerdere instellingsnummers */
tr.campus-rij td { background: var(--surface-muted); border-top: 0; padding-top: 6px; padding-bottom: 6px; font-size: .92em; }
tr.campus-rij td:first-child { padding-left: 32px; }
tr.campus-rij .campus-naam { color: var(--text-muted, #475569); }
tr.campus-rij td.text-right { color: var(--text-muted, #475569); font-weight: 600; }
/* Onderwijsniveau: kop en subtotaal per groep */
tr.niveau-kop td { background: var(--surface-muted); font-size: .78em; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: var(--text-muted, #475569); padding-top: 10px; padding-bottom: 10px; }
tr.subtotaal-rij td { background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border-top: 2px solid var(--primary-color); font-weight: 700; color: var(--primary-color); }
tr.subtotaal-rij td.text-right { font-size: 1.15em; }
tr.totaal-rij td { background: var(--primary-color); color: #fff; font-weight: 800; font-size: 1.05em;
padding-top: 14px; padding-bottom: 14px; border: 0; }
tr.totaal-rij td.text-right { font-size: 1.3em; }
</style>
{# ---------- Telling op peildatum ---------- #}
<div class="card">
<div class="page-head">
<h2>📅 Telling op peildatum</h2>
{% if peildata %}
<a class="btn btn-light btn-sm" href="{{ url_for(ep ~ '.export_csv', alle=1, query=query or None) }}">⬇️ CSV alle peildata</a>
{% endif %}
</div>
<form method="POST" action="{{ url_for(ep ~ '.tel_peildatum') }}" class="peil-form" onsubmit="showLoader()">
<div class="field">
<label for="peildatum">Peildatum</label>
<input type="date" id="peildatum" name="peildatum" required
value="{{ (peildatum or vandaag).isoformat() }}">
</div>
<button type="submit">🔢 Tellen voor alle scholen</button>
</form>
{% if peildata %}
<ul class="peildata">
{% for p in peildata %}
<li><a href="{{ url_for(ep ~ '.' ~ pagina, peildatum=p.Peildatum.isoformat()) }}"
class="{{ 'actief' if peildatum and p.Peildatum == peildatum else '' }}">
{{ p.Peildatum.strftime('%d-%m-%Y') }}<span>{{ (p.Totaal or 0)|int }} {{ kort }}</span></a></li>
{% endfor %}
</ul>
{% endif %}
{% if telling %}
<div class="page-head" style="margin-bottom:10px">
<h3 style="margin:0">{{ eenheid }} op {{ peildatum.strftime('%d-%m-%Y') }}</h3>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for(ep ~ '.export_csv', peildatum=peildatum.isoformat(), query=query or None) }}">⬇️ CSV-export</a>
<form method="POST" action="{{ url_for(ep ~ '.verwijder_peildatum') }}" style="margin:0"
onsubmit="return confirm('De telling van {{ peildatum.strftime('%d-%m-%Y') }} verwijderen?')">
<input type="hidden" name="peildatum" value="{{ peildatum.isoformat() }}">
<button type="submit" class="btn-sm btn-ghost">🗑️ Verwijderen</button>
</form>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>School</th>
<th class="text-right">{{ eenheid }}</th>
<th>Opgevraagd</th>
<th class="actions">Actie</th>
</tr>
</thead>
{% for g in telling_groepen %}
<tbody>
<tr class="niveau-kop"><td colspan="4">{{ g.label }}</td></tr>
{% for row in g.rijen %}
<tr>
<td><strong>{{ row.Naam_school }}</strong></td>
<td class="text-right"><span style="font-size: 1.1em; color: var(--primary-color); font-weight: 700;">{{ row.Aantal if row.Aantal is not none else '–' }}</span></td>
<td class="muted small">{% if row.Opgevraagd_op %}{{ row.Opgevraagd_op.strftime('%d-%m-%Y %H:%M') }}{% if row.Door %} · {{ row.Door }}{% endif %}{% else %}Niet geteld{% endif %}</td>
<td class="actions">
{% if row.Informat %}
<form method="POST" action="{{ url_for(ep ~ '.tel_peildatum') }}" onsubmit="showLoader()">
<input type="hidden" name="peildatum" value="{{ peildatum.isoformat() }}">
<input type="hidden" name="school_code" value="{{ row.Code }}">
<button type="submit" class="btn-sm btn-secondary">🔄 Opnieuw tellen</button>
</form>
{% else %}
<span class="muted small">Informat niet ingesteld</span>
{% endif %}
</td>
</tr>
{% for c in row.Campussen %}
<tr class="campus-rij">
<td><span class="campus-naam">↳ {{ c.naam }}</span></td>
<td class="text-right">{{ c.aantal }}</td>
<td colspan="2"></td>
</tr>
{% endfor %}
{% endfor %}
{% if g.code %}
<tr class="subtotaal-rij"><td>Subtotaal {{ g.label|lower }}</td><td class="text-right">{{ g.subtotaal }}</td><td colspan="2"></td></tr>
{% endif %}
</tbody>
{% endfor %}
<tfoot>
<tr class="totaal-rij"><td>Totaal scholengroep</td><td class="text-right">{{ telling_totaal }}</td><td colspan="2"></td></tr>
</tfoot>
</table>
</div>
{% endif %}
</div>
{# ---------- Huidig aantal ---------- #}
<div class="card">
<div class="page-head">
<h2>🏢 Scholengroep: huidig aantal</h2>
<div class="actions">
<a class="btn btn-light btn-sm" href="{{ url_for(ep ~ '.export_csv', query=query or None) }}">⬇️ CSV-export</a>
<form method="POST" action="{{ url_for(ep ~ '.sync_aanvragen') }}" onsubmit="showLoader()" style="margin:0">
<input type="hidden" name="alle" value="1">
<button type="submit" class="btn-sm btn-secondary">🔄 Alle scholen synchroniseren</button>
</form>
</div>
</div>
<form method="GET" action="{{ url_for(ep ~ '.' ~ pagina) }}" class="search-bar">
{% if peildatum %}<input type="hidden" name="peildatum" value="{{ peildatum.isoformat() }}">{% endif %}
<input type="text" name="query" placeholder="Zoek op schoolnaam..." value="{{ query or '' }}">
<button type="submit">🔍 Zoeken</button>
{% if query %}
<a href="{{ url_for(ep ~ '.' ~ pagina, peildatum=peildatum.isoformat() if peildatum else None) }}" class="btn btn-light">✕ Wissen</a>
{% endif %}
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>School</th>
<th class="text-right">{{ eenheid }}</th>
<th>Laatste synchronisatie</th>
<th class="actions">Actie</th>
</tr>
</thead>
{% for g in groepen %}
<tbody>
<tr class="niveau-kop"><td colspan="4">{{ g.label }}</td></tr>
{% for row in g.rijen %}
<tr>
<td><strong>{{ row['Naam_school'] }}</strong></td>
<td class="text-right"><span style="font-size: 1.1em; color: var(--primary-color); font-weight: 700;">{{ row['Huidig'] }}</span></td>
<td>{{ row['Laatste_synchronisatie'] or 'Nog nooit' }}</td>
<td class="actions">
{% if row['Informat'] %}
<form method="POST" action="{{ url_for(ep ~ '.sync_aanvragen') }}" onsubmit="showLoader()">
<input type="hidden" name="school_code" value="{{ row['Code'] }}">
<button type="submit" class="btn-sm btn-secondary">🔄 Sync aanvragen</button>
</form>
{% else %}
<span class="muted small">Informat niet ingesteld</span>
{% endif %}
</td>
</tr>
{% for c in row['Campussen'] %}
<tr class="campus-rij">
<td><span class="campus-naam">↳ {{ c.naam }}</span></td>
<td class="text-right">{{ c.aantal }}</td>
<td colspan="2"></td>
</tr>
{% endfor %}
{% endfor %}
{% if g.code %}
<tr class="subtotaal-rij"><td>Subtotaal {{ g.label|lower }}</td><td class="text-right">{{ g.subtotaal }}</td><td colspan="2"></td></tr>
{% endif %}
</tbody>
{% else %}
<tbody><tr class="empty-row"><td colspan="4">Geen scholen gevonden.</td></tr></tbody>
{% endfor %}
{% if results %}
<tfoot>
<tr class="totaal-rij"><td>Totaal scholengroep</td><td class="text-right">{{ totaal }}</td><td colspan="2"></td></tr>
</tfoot>
{% endif %}
</table>
</div>
</div>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}Nieuw schooljaar – accounts{% endblock %}
{% block page_title %}Nieuw schooljaar – leerlingaccounts{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Bezig met verwerken... Even geduld.{% endblock %}
{% block extra_css %}
<style>
.form-section { display: flex; flex-direction: column; gap: 22px; max-width: 640px; margin: 8px auto 0; }
.ou-selector-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 12px; }
.ou-selector-row.single { grid-template-columns: 1fr; }
.ou-selector-row label { display: block; margin-bottom: 6px; }
.arrow { font-size: 22px; color: var(--text-muted); padding-bottom: 8px; }
.radio-group { display: flex; flex-direction: column; gap: 8px; }
.tab-intro { color: var(--text-muted); margin-bottom: 4px; }
@media (max-width: 640px) { .ou-selector-row { grid-template-columns: 1fr; } .arrow { transform: rotate(90deg); justify-self: center; } }
</style>
{% endblock %}
{% block content %}
<div class="tabs">
<button class="tab-link active" onclick="openTab(event, 'verplaatsen-tab')">➡️ Verplaatsen</button>
<button class="tab-link" onclick="openTab(event, 'archiveren-tab')">🗄️ Archiveren</button>
<button class="tab-link" onclick="openTab(event, 'archiveren-inactief-tab')">💤 Inactieven archiveren</button>
</div>
<section id="verplaatsen-tab" class="tab-content active">
<h2>Gebruikers van OU verplaatsen</h2>
<form class="form-section" method="POST" action="{{ url_for('accounts_verplaatsen.accounts_verplaatsen') }}" onsubmit="showLoader()">
<div class="ou-selector-row">
<div>
<label>Huidige OU</label>
<select name="huidige_ou" required>
<option value="" disabled selected>Kies huidige OU</option>
{% for ou in ous %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
<span class="arrow">⟶</span>
<div>
<label>Nieuwe OU</label>
<select name="nieuwe_ou" required>
<option value="" disabled selected>Kies nieuwe OU</option>
{% for ou in ous %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
</div>
<div class="radio-group">
<label class="check-box" style="margin:0"><input type="radio" name="move_type" value="current" checked> Enkel leerlingen die op dit moment ingeschreven zijn</label>
<label class="check-box" style="margin:0"><input type="radio" name="move_type" value="all"> Alle leerlingen in de huidige OU verplaatsen</label>
</div>
<div><button type="submit" class="btn-lg">Uitvoeren</button></div>
</form>
</section>
<section id="archiveren-tab" class="tab-content">
<h2>Gebruikers archiveren</h2>
<form class="form-section" method="POST" action="{{ url_for('accounts_verplaatsen.accounts_verplaatsen') }}" onsubmit="showLoader()">
<div class="ou-selector-row single">
<div>
<label>Bron-OU</label>
<select name="archief_ou" required>
<option value="" disabled selected>Selecteer bron-OU</option>
{% for ou in ous %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
</div>
<div class="radio-group">
<label class="check-box" style="margin:0"><input type="radio" name="archive_type" value="not_enrolled" checked> Enkel leerlingen die op dit moment NIET ingeschreven zijn</label>
<label class="check-box" style="margin:0"><input type="radio" name="archive_type" value="all"> Alle leerlingen in de huidige OU archiveren</label>
</div>
<div><button type="submit" class="btn-lg">Archiveren</button></div>
</form>
</section>
<section id="archiveren-inactief-tab" class="tab-content">
<h2>Inactieve gebruikers archiveren</h2>
<p class="tab-intro">Gebruikers in de geselecteerde OU die langer dan de gekozen periode niet zijn ingelogd, worden gearchiveerd.</p>
<form class="form-section" method="POST" action="{{ url_for('accounts_verplaatsen.archiveren_inactief') }}" onsubmit="showLoader()">
<div class="ou-selector-row">
<div>
<label>Bron-OU</label>
<select name="bron_ou" required>
<option value="" disabled selected>Selecteer bron-OU</option>
{% for ou in ous %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
<span></span>
<div>
<label for="inactief_periode">Niet aangemeld sinds</label>
<select name="inactief_periode" id="inactief_periode" required>
<option value="30">30 dagen</option>
<option value="180">6 maanden</option>
<option value="365">1 jaar</option>
</select>
</div>
</div>
<div><button type="submit" class="btn-lg">Accounts archiveren</button></div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script>
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(t => { t.style.display = 'none'; t.classList.remove('active'); });
document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
document.getElementById(tabName).style.display = 'block';
document.getElementById(tabName).classList.add('active');
evt.currentTarget.classList.add('active');
}
</script>
{% endblock %}
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Nieuw schooljaar – toestellen{% endblock %}
{% block page_title %}Nieuw schooljaar – toestellen{% endblock %}
{% block main_class %}medium{% endblock %}
{% block loader_text %}Bezig met verwerken... Even geduld.{% endblock %}
{% block extra_css %}
<style>
.form-section { display: flex; flex-direction: column; gap: 22px; max-width: 640px; margin: 8px auto 0; }
.ou-selector-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 12px; }
.ou-selector-row.single { grid-template-columns: 1fr; }
.ou-selector-row label { display: block; margin-bottom: 6px; }
.arrow { font-size: 22px; color: var(--text-muted); padding-bottom: 8px; }
.button-row { display: flex; flex-wrap: wrap; gap: 10px; }
.tab-intro { color: var(--text-muted); margin-bottom: 4px; }
@media (max-width: 640px) { .ou-selector-row { grid-template-columns: 1fr; } .arrow { transform: rotate(90deg); justify-self: center; } }
</style>
{% endblock %}
{% block content %}
<div class="tabs">
<button class="tab-link {% if active_tab == 'verplaatsen' %}active{% endif %}" onclick="openTab(event, 'verplaatsen-tab')">➡️ Verplaatsen</button>
<button class="tab-link {% if active_tab == 'blokkeren' %}active{% endif %}" onclick="openTab(event, 'blokkeren-tab')">🔒 Blokkeren</button>
</div>
<section id="verplaatsen-tab" class="tab-content {% if active_tab == 'verplaatsen' %}active{% endif %}" style="display: {% if active_tab == 'verplaatsen' %}block{% else %}none{% endif %};">
<h2>Chromebooks van OU verplaatsen</h2>
<form class="form-section" method="POST" action="{{ url_for('toestellen_verplaatsen.move_toestellen') }}" onsubmit="showLoader('Toestellen verplaatsen...')">
<div class="ou-selector-row">
<div>
<label>Huidige OU (bron)</label>
<select name="huidige_ou" required>
<option value="" disabled selected>Kies bron-OU</option>
{% for ou in org_units %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
<span class="arrow">⟶</span>
<div>
<label>Nieuwe OU (doel)</label>
<select name="nieuwe_ou" required>
<option value="" disabled selected>Kies doel-OU</option>
{% for ou in org_units %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
</div>
<div><button type="submit" class="btn-lg">Uitvoeren</button></div>
</form>
</section>
<section id="blokkeren-tab" class="tab-content {% if active_tab == 'blokkeren' %}active{% endif %}" style="display: {% if active_tab == 'blokkeren' %}block{% else %}none{% endif %};">
<h2>Chromebooks blokkeren of deblokkeren</h2>
<p class="tab-intro">Selecteer een OU om de status van alle aanwezige Chromebooks te wijzigen.</p>
<form class="form-section" method="POST" action="{{ url_for('toestellen_verplaatsen.blokkeer_toestellen') }}" onsubmit="return handleBlokkeerSubmit()">
<div class="ou-selector-row single">
<div>
<label for="ou_select_blokkeer">Doel-OU</label>
<select id="ou_select_blokkeer" name="ou_select_blokkeer" required>
<option value="" disabled selected>Selecteer de doel-OU</option>
{% for ou in org_units %}<option value="{{ ou.orgUnitPath }}">{{ ou.orgUnitPath }}</option>{% endfor %}
</select>
</div>
</div>
<div class="button-row">
<button type="submit" name="actie" value="blokkeer" class="btn-lg btn-danger" onclick="setLastAction('blokkeer')">🔒 Alles blokkeren</button>
<button type="submit" name="actie" value="deblokkeer" class="btn-lg btn-secondary" onclick="setLastAction('deblokkeer')">🔓 Alles deblokkeren</button>
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script>
let lastAction = '';
function setLastAction(action) { lastAction = action; }
function handleBlokkeerSubmit() {
const msg = lastAction === 'blokkeer'
? 'Weet je zeker dat je ALLE toestellen in deze OU wilt BLOKKEREN?'
: 'Weet je zeker dat je ALLE toestellen in deze OU wilt DEBLOKKEREN?';
if (confirm(msg)) { showLoader('Status wijzigen... Even geduld.'); return true; }
return false;
}
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(t => { t.style.display = 'none'; t.classList.remove('active'); });
document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
document.getElementById(tabName).style.display = 'block';
document.getElementById(tabName).classList.add('active');
evt.currentTarget.classList.add('active');
}
</script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Helvetica, Arial, sans-serif;
color: #333;
line-height: 1.15;
padding: 10px;
font-size: 11pt;
}
/* Header met Logo */
.header-table {
width: 100%;
border-bottom: 1px solid #2c3e50;
margin-bottom: 10px;
}
.logo-cell {
width: 20%; /* Ruimte voor het logo */
vertical-align: middle;
text-align: left;
padding-bottom: 5px;
}
.title-cell {
width: 80%;
vertical-align: middle;
text-align: right; /* Titel rechts, logo links */
padding-bottom: 5px;
}
h1 {
font-size: 18pt;
margin: 0;
}
/* Compacte tekst instellingen */
p { margin: 4px 0; }
.account-box {
background: #f2f2f2;
border: 1px solid #ccc;
padding: 8px 12px;
margin: 10px 0;
}
.account-box p { margin: 2px 0; }
.signature-table {
width: 100%;
margin-top: 15px;
border: none;
}
.signature-cell {
width: 50%;
vertical-align: top;
border: none;
font-size: 10pt;
}
.footer-text {
margin-top: 10px;
font-style: italic;
font-size: 10pt;
}
</style>
</head>
<body>
<table class="header-table">
<tr>
<td class="logo-cell">
<img src="https://lh3.googleusercontent.com/VfGv19vzyZXq4dHmz4eUMZ1Pmyz-vgYq4TcIwlh5zYR7_lur1X0T22CaCTb_u1AlJtksVgDZX_b1gXT0Oz4cxCU7Y-HJchZx3vY=l80-w450-e365" style="height: 60px;">
</td>
<td class="title-cell">
<h1>{{ school_name_short|upper }} Google Account</h1>
</td>
</tr>
</table>
<div class="content">
<p>Beste <strong>{{ naam }}</strong>,</p>
<p>Je account voor de schoolomgeving is aangemaakt op {{ datum }}.</p>
<div class="account-box">
<p><strong>E-mailadres:</strong> {{ email }}</p>
<p><strong>Tijdelijk wachtwoord:</strong> {{ wachtwoord }}</p>
</div>
<p>Hiermee kan je gebruikmaken van alle schoolgebonden google toepassingen (Drive, Gmail, Classroom, Agenda...).</p>
<p><strong>Let op:</strong> Je zult gevraagd worden om je wachtwoord te wijzigen bij de eerste keer inloggen.</p>
<p class="footer-text">Heb je nog vragen, spring gerust binnen of mail me je vraag!</p>
</div>
<p style="margin-top: 15px;">Met vriendelijke groeten,</p>
<table class="signature-table">
<tr>
<td class="signature-cell">
<strong>K. Verbruggen</strong><br>
ICT coördinator<br>
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>
</td>
<td class="signature-cell">
<strong>B. Elewaut</strong><br>
Directeur<br>
<a href="mailto:directie@kamkersem.be">directie@kamkersem.be</a>
</td>
</tr>
</table>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 2cm;
}
body {
font-family: Helvetica, Arial, sans-serif;
color: #333;
line-height: 1.2;
font-size: 11pt;
}
.header-table {
width: 100%;
border-bottom: 1px solid #2c3e50;
margin-bottom: 20px;
}
.logo-cell {
width: 30%;
vertical-align: middle;
text-align: left;
padding-bottom: 10px;
}
.logo-cell img {
height: 60px;
}
.title-cell {
width: 70%;
vertical-align: middle;
text-align: right;
padding-bottom: 10px;
}
h1 {
font-size: 18pt;
margin: 0;
color: #2c3e50;
}
.account-box {
background-color: #f2f2f2;
border: 1px solid #ccc;
padding: 15px;
margin: 20px 0;
}
.signature-table {
width: 100%;
margin-top: 30px;
}
.signature-cell {
width: 50%;
vertical-align: top;
font-size: 10pt;
}
.footer-text {
margin-top: 20px;
font-style: italic;
font-size: 10pt;
color: #666;
}
</style>
</head>
<body>
<table class="header-table">
<tr>
<td class="logo-cell">
<img src="https://jouwschool.be/logo.png" alt="Logo">
</td>
<td class="title-cell">
<h1>{{ school_name_short|upper }} Google Account</h1>
</td>
</tr>
</table>
<div class="content">
<p>Beste <strong>{{ naam }}</strong>,</p>
<p>Je account voor de schoolomgeving is aangemaakt/bijgewerkt op {{ datum }}.</p>
<div class="account-box">
<p><strong>E-mailadres:</strong> {{ email }}</p>
<p><strong>Wachtwoord:</strong> {{ wachtwoord }}</p>
</div>
<p>Hiermee kan je aanmelden op je Chromebook en andere schooldiensten.</p>
<p><strong>Let op:</strong> Indien dit een nieuw account is, moet je je wachtwoord wijzigen bij de eerste keer inloggen.</p>
<p class="footer-text">Heb je nog vragen, contacteer dan de ICT-coördinator!</p>
</div>
<p style="margin-top: 25px;">Met vriendelijke groeten,</p>
<table class="signature-table">
<tr>
<td class="signature-cell">
<strong>K. Verbruggen</strong><br>
ICT coördinator<br>
{{ contact_email }}
</td>
<td class="signature-cell">
<strong>B. Elewaut</strong><br>
Directeur<br>
directie@kamkersem.be
</td>
</tr>
</table>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %}
{% block title %}Te beoordelen{% endblock %}
{% block page_title %}Te beoordelen{% endblock %}
{% block main_class %}medium{% endblock %}
{% block extra_css %}
<style>
.req { display: grid; grid-template-columns: 1fr auto; gap: 8px 16px; }
.req h3 { margin: 0; font-size: 1.05rem; }
.req .meta { color: var(--text-muted); font-size: 14px; }
.req .desc { grid-column: 1 / -1; font-size: 14px; white-space: pre-line; }
.quick { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding-top: 12px; border-top: 1px solid var(--border); }
.quick input[type=text] { flex: 1 1 240px; width: auto; }
.chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.chips span { padding: 1px 8px; border-radius: 999px; background: var(--primary-light); color: var(--primary-color); font-size: 12px; font-weight: 600; }
</style>
{% endblock %}
{% block content %}
{{ subnav('beoordelen') }}
{% for i in open_items %}
<div class="card">
<div class="req">
<div>
<h3><a href="{{ url_for('personeel.detail', aid=i.ID) }}">{{ soort_label(i.Soort) }}: {{ i.Titel }}</a></h3>
<div class="meta"><strong>{{ i.Naam }}</strong> · {{ periode(i) }}{% if i.Kost %} · € {{ '%.2f'|format(i.Kost)|replace('.', ',') }}{% endif %}</div>
{% if i.Klassen %}<div class="chips" style="margin-top:6px">{% for k in i.Klassen.split(',') %}<span>{{ k.strip() }}</span>{% endfor %}</div>{% endif %}
</div>
<div>{{ status_badge(i.Status) }}</div>
{% if i.Omschrijving %}<div class="desc">{{ i.Omschrijving }}</div>{% endif %}
<form class="quick" method="POST" action="{{ url_for('personeel.beslissing', aid=i.ID) }}" onsubmit="showLoader()">
<input type="hidden" name="volgende" value="1">
<input type="text" name="tekst" placeholder="Commentaar (verplicht bij weigeren of vraag)">
<button type="submit" name="actie" value="goedgekeurd" class="btn-success btn-sm">✓ Goedkeuren</button>
<button type="submit" name="actie" value="vraag" class="btn-warning btn-sm">❓ Vraag</button>
<button type="submit" name="actie" value="geweigerd" class="btn-danger btn-sm">✕ Weigeren</button>
</form>
</div>
</div>
{% else %}
<div class="card text-center"><p style="margin:0">🎉 Er wacht niets op een beslissing.</p></div>
{% endfor %}
{% if recent %}
<div class="card">
<h3 class="card-title">Recent beslist</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Soort</th><th>Wie</th><th>Titel</th><th>Wanneer</th><th>Beslissing</th></tr></thead>
<tbody>
{% for i in recent %}
<tr>
<td class="nowrap">{{ soort_label(i.Soort) }}</td>
<td>{{ i.Naam }}</td>
<td><a href="{{ url_for('personeel.detail', aid=i.ID) }}">{{ i.Titel }}</a></td>
<td class="nowrap">{{ periode(i) }}</td>
<td>{{ status_badge(i.Status) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
+134
View File
@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% from "_personeel.html" import subnav, status_badge, soort_label with context %}
{% block title %}Dagoverzicht{% endblock %}
{% block page_title %}Dagoverzicht secretariaat{% endblock %}
{% block extra_css %}
<style>
.nav-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 18px; }
.nav-bar .date-title { font-size: 1.25rem; font-weight: 700; margin: 0 8px; }
.nav-bar form { display: flex; gap: 6px; margin: 0; }
.nav-bar input[type=date] { width: auto; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg a { padding: 8px 14px; text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; background: #fff; }
.seg a.on { background: var(--primary-color); color: #fff; }
.day { margin-bottom: 20px; }
.day-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; }
.day-head h3 { margin: 0; text-transform: capitalize; }
.day-head.today h3::after { content: "vandaag"; margin-left: 8px; font-size: 12px; font-weight: 700; color: #fff; background: var(--primary-color); padding: 2px 8px; border-radius: 999px; vertical-align: middle; }
.cols { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; }
@media (max-width: 860px) { .cols { grid-template-columns: 1fr; } }
.cols .card + .card { margin-top: 0; }
.card h4 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.person { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.person:last-child { border-bottom: 0; }
.person .nm { font-weight: 700; }
.person .what { font-size: 13px; color: var(--text-muted); }
.person .right { text-align: right; }
.chips { display: inline-flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.chips span { padding: 1px 8px; border-radius: 999px; background: var(--primary-light); color: var(--primary-color); font-size: 12px; font-weight: 600; }
.klas-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.klas-row:last-child { border-bottom: 0; }
.klas-row .k { font-weight: 800; font-size: 1.05rem; min-width: 60px; }
.empty { color: var(--text-muted); padding: 6px 0; }
.pending { margin-top: 10px; font-size: 13px; color: var(--text-muted); }
.vv-form { display: inline; }
@media print {
.tabs, .nav-bar form, .seg, .vv-form button, .pending { display: none !important; }
.card { break-inside: avoid; }
}
</style>
{% endblock %}
{% block content %}
{{ subnav('dagoverzicht') }}
<div class="nav-bar">
<a class="btn btn-light" href="{{ url_for('personeel.dagoverzicht', datum=vorige.isoformat(), weergave=weergave) }}">‹</a>
<a class="btn btn-light" href="{{ url_for('personeel.dagoverzicht', weergave=weergave) }}">Vandaag</a>
<a class="btn btn-light" href="{{ url_for('personeel.dagoverzicht', datum=volgende.isoformat(), weergave=weergave) }}">›</a>
<span class="date-title">
{% if weergave == 'week' %}Week van {{ van.strftime('%d/%m') }} tot {{ tot.strftime('%d/%m/%Y') }}{% else %}{{ dag.strftime('%d/%m/%Y') }}{% endif %}
</span>
<span class="seg">
<a class="{{ 'on' if weergave == 'dag' }}" href="{{ url_for('personeel.dagoverzicht', datum=dag.isoformat(), weergave='dag') }}">Dag</a>
<a class="{{ 'on' if weergave == 'week' }}" href="{{ url_for('personeel.dagoverzicht', datum=dag.isoformat(), weergave='week') }}">Week</a>
</span>
<form method="GET" action="{{ url_for('personeel.dagoverzicht') }}">
<input type="hidden" name="weergave" value="{{ weergave }}">
<input type="date" name="datum" value="{{ dag.isoformat() }}" onchange="this.form.submit()">
</form>
<span style="flex:1"></span>
<a class="btn btn-light" href="{{ url_for('personeel.nieuw', soort='afwezigheid') }}">➕ Afwezigheid melden</a>
<button type="button" class="btn-light" onclick="window.print()">🖨️ Afdrukken</button>
</div>
{% set weekdagen = ['maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag','zondag'] %}
{% for d in dagen %}
<div class="day">
<div class="day-head {{ 'today' if d.datum == vandaag }}">
<h3>{{ weekdagen[d.datum.weekday()] }} {{ d.datum.strftime('%d/%m') }}</h3>
<span class="muted small">{{ d.personen|length }} afwezig · {{ d.klassen|length }} klas(sen) weg
{% if d.open_vervanging %} · <strong style="color:var(--warning-text)">{{ d.open_vervanging }} vervanging(en) te regelen</strong>{% endif %}</span>
</div>
<div class="cols">
<div class="card">
<h4>👤 Afwezige personeelsleden</h4>
{% for p in d.personen %}
{% set i = p.item %}
<div class="person">
<div>
<div class="nm">{{ p.naam }}</div>
<div class="what">
{% if p.rol == 'begeleider' %}🚌 begeleider uitstap "{{ i.Titel }}" ({{ i.Naam }})
{% else %}{{ soort_label(i.Soort) }}{% if i.Soort != 'afwezigheid' %}: {{ i.Titel }}{% endif %}{% endif %}
· {{ DAGDELEN.get(i.Dagdeel) }}{% if i.Dagdeel == 'uren' and i.Lesuren %} ({{ i.Lesuren }}){% endif %}
</div>
{% if p.rol != 'begeleider' and i.Soort != 'uitstap' and i.Klassen %}
<div class="chips">{% for k in i.Klassen.split(',') %}<span>{{ k.strip() }}</span>{% endfor %}</div>
{% endif %}
{% if p.rol != 'begeleider' and i.Soort == 'afwezigheid' and i.Omschrijving %}<div class="what" style="margin-top:4px">💬 {{ i.Omschrijving }}</div>{% endif %}
</div>
<div class="right">
{% if p.rol != 'begeleider' and i.Soort != 'uitstap' and i.Klassen %}
<form class="vv-form" method="POST" action="{{ url_for('personeel.vervanging', aid=i.ID) }}">
<input type="hidden" name="terug" value="{{ request.full_path }}">
<input type="hidden" name="info" value="{{ i.Vervanging_info or '' }}">
{% if i.Vervanging_ok %}
<span class="badge badge-success">✓ Vervanging</span>
<button type="submit" name="ok" value="0" class="btn-ghost btn-sm" title="Terug op 'te regelen' zetten">↺</button>
{% else %}
<span class="badge badge-warning">Vervanging te regelen</span>
<button type="submit" name="ok" value="1" class="btn-light btn-sm" title="Markeer als geregeld">✓ Geregeld</button>
{% endif %}
</form>
{% endif %}
<div><a class="small" href="{{ url_for('personeel.detail', aid=i.ID) }}">details</a></div>
</div>
</div>
{% else %}
<div class="empty">Niemand afwezig. 🎉</div>
{% endfor %}
</div>
<div class="card">
<h4>🚌 Klassen weg</h4>
{% for k in d.klassen %}
<div class="klas-row">
<span class="k">{{ k.klas }}</span>
<span class="muted small" style="flex:1">{{ k.item.Titel }}{% if k.item.Locatie %} · {{ k.item.Locatie }}{% endif %}<br>
{{ DAGDELEN.get(k.item.Dagdeel) }}{% if k.item.Dagdeel == 'uren' and k.item.Lesuren %} ({{ k.item.Lesuren }}){% endif %} · {{ k.item.Naam }}</span>
</div>
{% else %}
<div class="empty">Geen klassen weg.</div>
{% endfor %}
{% if d.wachtend %}
<div class="pending">⏳ Nog niet goedgekeurd:
{% for w in d.wachtend %}<a href="{{ url_for('personeel.detail', aid=w.ID) }}">{{ SOORTEN[w.Soort].icoon }} {{ w.Naam }} – {{ w.Titel }}</a>{{ ', ' if not loop.last }}{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endblock %}
+134
View File
@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %}
{% block title %}{{ SOORTEN[item.Soort].label }} – {{ item.Naam }}{% endblock %}
{% block page_title %}{{ SOORTEN[item.Soort].icoon }} {{ SOORTEN[item.Soort].label }}{% endblock %}
{% block main_class %}medium{% endblock %}
{% block extra_css %}
<style>
.detail-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.detail-head h2 { margin: 0 0 4px; }
dl.facts { display: grid; grid-template-columns: 160px 1fr; gap: 8px 16px; margin: 0; }
dl.facts dt { color: var(--text-muted); font-size: 14px; }
dl.facts dd { margin: 0; font-weight: 500; }
@media (max-width: 560px) { dl.facts { grid-template-columns: 1fr; gap: 2px; } dl.facts dd { margin-bottom: 8px; } }
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
.chips span { padding: 2px 10px; border-radius: 999px; background: var(--primary-light); color: var(--primary-color); font-size: 13px; font-weight: 600; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { position: relative; padding: 0 0 18px 28px; }
.timeline li::before { content: ""; position: absolute; left: 7px; top: 18px; bottom: 0; width: 2px; background: var(--border); }
.timeline li:last-child::before { display: none; }
.timeline .dot { position: absolute; left: 0; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--border-strong); border: 3px solid #fff; box-shadow: 0 0 0 1px var(--border); }
.timeline .dot.goedgekeurd { background: var(--success); } .timeline .dot.geweigerd { background: var(--danger); }
.timeline .dot.vraag { background: #f59e0b; } .timeline .dot.antwoord, .timeline .dot.commentaar { background: var(--primary-color); }
.timeline .who { font-weight: 600; } .timeline .when { color: var(--text-muted); font-size: 13px; }
.timeline .txt { margin-top: 4px; padding: 8px 12px; background: var(--surface-muted); border-radius: var(--radius-sm); white-space: pre-line; }
.decision-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.panel { border: 2px solid var(--border); border-radius: var(--radius-lg); padding: 18px; }
.panel.directie { border-color: #fde68a; background: #fffdf3; }
.panel.vraag { border-color: #fde68a; background: #fffbeb; }
</style>
{% endblock %}
{% block content %}
{{ subnav('') }}
<div class="card">
<div class="detail-head">
<div>
<h2>{{ item.Titel }}</h2>
<div class="muted">{{ item.Naam or item.Email }}{% if item.Ingediend_door %} · gemeld door {{ item.Ingediend_door }}{% endif %}</div>
</div>
<div>{{ status_badge(item.Status) }}</div>
</div>
<dl class="facts">
<dt>Soort</dt><dd>{{ soort_label(item.Soort) }}</dd>
<dt>Wanneer</dt><dd>{{ periode(item) }}</dd>
{% if item.Locatie %}<dt>Locatie</dt><dd>{{ item.Locatie }}</dd>{% endif %}
{% if item.Klassen %}<dt>{{ 'Klassen mee' if item.Soort == 'uitstap' else 'Klassen' }}</dt>
<dd><div class="chips">{% for k in item.Klassen.split(',') %}<span>{{ k.strip() }}</span>{% endfor %}</div></dd>{% endif %}
{% if item.Begeleiders %}<dt>Begeleiders</dt><dd>{{ item.Begeleiders }}</dd>{% endif %}
{% if item.Kost %}<dt>Kost</dt><dd>€ {{ '%.2f'|format(item.Kost)|replace('.', ',') }}</dd>{% endif %}
{% if item.Omschrijving %}<dt>{{ 'Opmerking' if item.Soort == 'afwezigheid' else 'Toelichting' }}</dt><dd style="white-space:pre-line">{{ item.Omschrijving }}</dd>{% endif %}
{% if 'secretariaat' in p_rollen and item.Klassen and item.Soort != 'uitstap' %}
<dt>Vervanging</dt>
<dd>{% if item.Vervanging_ok %}<span class="badge badge-success">✓ Geregeld</span>{% else %}<span class="badge badge-warning">Nog te regelen</span>{% endif %}
{% if item.Vervanging_info %}<span class="muted"> · {{ item.Vervanging_info }}</span>{% endif %}</dd>
{% endif %}
</dl>
</div>
{# ---------- Directie: beslissen ---------- #}
{% if 'directie' in p_rollen and SOORTEN[item.Soort].goedkeuring and item.Status in ('ingediend', 'vraag') %}
<div class="card panel directie">
<h3 class="card-title">Beslissing directie</h3>
{% if item.Status == 'vraag' %}<p class="muted small">Je hebt een vraag gesteld; de leerkracht heeft nog niet geantwoord. Je kan toch al beslissen.</p>{% endif %}
<form method="POST" action="{{ url_for('personeel.beslissing', aid=item.ID) }}" onsubmit="showLoader()">
<div class="field">
<label for="tekst">Commentaar <span class="muted">(verplicht bij weigeren of vraag)</span></label>
<textarea id="tekst" name="tekst" placeholder="bv. Akkoord, graag verslag op de personeelsvergadering."></textarea>
</div>
<div class="decision-buttons">
<button type="submit" name="actie" value="goedgekeurd" class="btn-success">✓ Goedkeuren</button>
<button type="submit" name="actie" value="vraag" class="btn-warning">❓ Vraag stellen</button>
<button type="submit" name="actie" value="geweigerd" class="btn-danger">✕ Weigeren</button>
</div>
</form>
</div>
{% endif %}
{# ---------- Leerkracht: antwoorden op vraag ---------- #}
{% if is_eigenaar and item.Status == 'vraag' %}
<div class="card panel vraag">
<h3 class="card-title">❓ De directie heeft een vraag</h3>
<form method="POST" action="{{ url_for('personeel.antwoord', aid=item.ID) }}" onsubmit="showLoader()">
<div class="field"><textarea name="tekst" required placeholder="Je antwoord..."></textarea></div>
<button type="submit">Antwoord versturen</button>
</form>
</div>
{% endif %}
{# ---------- Secretariaat: vervanging ---------- #}
{% if 'secretariaat' in p_rollen and item.Status in ('gemeld', 'goedgekeurd') and item.Soort != 'uitstap' %}
<div class="card">
<h3 class="card-title">🔁 Vervanging</h3>
<form method="POST" action="{{ url_for('personeel.vervanging', aid=item.ID) }}" class="search-bar" style="margin:0">
<input type="text" name="info" value="{{ item.Vervanging_info or '' }}" placeholder="bv. 3A studie, 4B les door Jan">
<button type="submit" name="ok" value="1" class="btn-success">✓ Geregeld</button>
{% if item.Vervanging_ok %}<button type="submit" name="ok" value="0" class="btn-light">Nog niet geregeld</button>{% endif %}
</form>
</div>
{% endif %}
{# ---------- Verloop + commentaar ---------- #}
<div class="card">
<h3 class="card-title">Verloop</h3>
<ul class="timeline">
{% set labels = {'ingediend': 'diende de aanvraag in', 'gemeld': 'meldde de afwezigheid', 'goedgekeurd': 'keurde goed',
'geweigerd': 'weigerde', 'vraag': 'stelde een vraag', 'antwoord': 'antwoordde',
'commentaar': 'schreef', 'geannuleerd': 'annuleerde', 'vervanging': 'vervanging'} %}
{% for r in reacties %}
<li>
<span class="dot {{ r.Actie }}"></span>
<span class="who">{{ r.Naam or r.Email or '?' }}</span> {{ labels.get(r.Actie, r.Actie) }}
<span class="when"> · {{ r.Tijdstip.strftime('%d/%m %H:%M') }}</span>
{% if r.Tekst %}<div class="txt">{{ r.Tekst }}</div>{% endif %}
</li>
{% endfor %}
</ul>
{% if item.Status not in ('geannuleerd',) %}
<form method="POST" action="{{ url_for('personeel.commentaar', aid=item.ID) }}" class="search-bar" style="margin: 6px 0 0">
<input type="text" name="tekst" placeholder="Opmerking toevoegen..." required>
<button type="submit" class="btn-light">Toevoegen</button>
</form>
{% endif %}
</div>
{% if (is_eigenaar or 'secretariaat' in p_rollen) and item.Status not in ('geannuleerd', 'geweigerd') and item.Tot_datum >= vandaag %}
<form method="POST" action="{{ url_for('personeel.annuleren', aid=item.ID) }}" style="text-align:right"
onsubmit="return confirm('Deze {{ SOORTEN[item.Soort].label|lower }} annuleren?')">
<button type="submit" class="btn-link">{{ 'Afwezigheid intrekken' if item.Soort == 'afwezigheid' else 'Aanvraag annuleren' }}</button>
</form>
{% endif %}
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% from "_personeel.html" import subnav, status_badge, soort_label, periode with context %}
{% block title %}Afwezigheden & activiteiten{% endblock %}
{% block page_title %}Afwezigheden &amp; activiteiten{% endblock %}
{% block main_class %}medium{% endblock %}
{% block extra_css %}
<style>
.new-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 20px; }
@media (max-width: 760px) { .new-grid { grid-template-columns: 1fr; } }
.new-card { display: flex; gap: 14px; align-items: center; padding: 18px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow);
text-decoration: none; color: var(--text); transition: transform .12s, box-shadow .12s, border-color .12s; }
.new-card:hover { transform: translateY(-2px); border-color: var(--primary-color); }
.new-card .ic { font-size: 28px; width: 50px; height: 50px; border-radius: 12px; display: grid; place-items: center; background: var(--primary-light); flex-shrink: 0; }
.new-card strong { display: block; }
.new-card span.sub { font-size: 13px; color: var(--text-muted); }
tr.past td { opacity: .6; }
tr.click { cursor: pointer; }
</style>
{% endblock %}
{% block content %}
{{ subnav('mijn') }}
<div class="new-grid">
<a class="new-card" href="{{ url_for('personeel.nieuw', soort='afwezigheid') }}">
<span class="ic">🤒</span><span><strong>Afwezigheid melden</strong><span class="sub">Ziek of onverwacht afwezig</span></span></a>
<a class="new-card" href="{{ url_for('personeel.nieuw', soort='nascholing') }}">
<span class="ic">🎓</span><span><strong>Nascholing aanvragen</strong><span class="sub">Goedkeuring door de directie</span></span></a>
<a class="new-card" href="{{ url_for('personeel.nieuw', soort='uitstap') }}">
<span class="ic">🚌</span><span><strong>Uitstap aanvragen</strong><span class="sub">Met klassen en begeleiders</span></span></a>
</div>
<div class="card">
<h2 class="card-title">Mijn meldingen en aanvragen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Soort</th><th>Titel</th><th>Wanneer</th><th>Status</th></tr></thead>
<tbody>
{% for i in items %}
<tr class="click {{ 'past' if i.Tot_datum < vandaag }}" onclick="location.href='{{ url_for('personeel.detail', aid=i.ID) }}'">
<td class="nowrap">{{ soort_label(i.Soort) }}</td>
<td><a href="{{ url_for('personeel.detail', aid=i.ID) }}"><strong>{{ i.Titel }}</strong></a>
{% if i.Ingediend_door %}<div class="muted small">gemeld door {{ i.Ingediend_door }}</div>{% endif %}</td>
<td class="nowrap">{{ periode(i) }}</td>
<td>{{ status_badge(i.Status) }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4">Je hebt nog geen meldingen of aanvragen.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+172
View File
@@ -0,0 +1,172 @@
{% extends "base.html" %}
{% from "_personeel.html" import subnav with context %}
{% block title %}{{ SOORTEN[soort].label }}{% endblock %}
{% block page_title %}{{ SOORTEN[soort].icoon }} {{ 'Afwezigheid melden' if soort == 'afwezigheid' else SOORTEN[soort].label ~ ' aanvragen' }}{% endblock %}
{% block main_class %}medium{% endblock %}
{% block extra_css %}
<style>
.soort-switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
.soort-switch a { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: #fff; text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; }
.soort-switch a.on { background: var(--primary-color); border-color: var(--primary-color); color: #fff; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 16px; }
@media (max-width: 640px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
.dagdeel { display: flex; gap: 6px; flex-wrap: wrap; }
.dagdeel label { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; cursor: pointer; font-weight: 500; }
.dagdeel input:checked + span { font-weight: 700; color: var(--primary-color); }
.klassen-box { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; background: var(--surface-muted); }
.klassen-box input[type=search] { margin-bottom: 8px; }
.klassen { display: flex; flex-wrap: wrap; gap: 6px; max-height: 210px; overflow-y: auto; }
.klas { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; background: #fff; font-size: 13px; font-weight: 600; cursor: pointer; user-select: none; }
.klas input { width: 14px; height: 14px; }
.klas.on { background: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
.gekozen { font-size: 13px; color: var(--text-muted); margin-top: 6px; }
.info-note { font-size: 13px; color: var(--text-muted); background: var(--surface-muted); border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 16px; }
</style>
{% endblock %}
{% block content %}
{{ subnav('') }}
<div class="soort-switch">
{% for key, s in SOORTEN.items() %}
<a class="{{ 'on' if key == soort }}" href="{{ url_for('personeel.nieuw', soort=key) }}">{{ s.icoon }} {{ s.label }}</a>
{% endfor %}
</div>
<form class="card" method="POST" action="{{ url_for('personeel.nieuw') }}" onsubmit="showLoader('Versturen...')">
<input type="hidden" name="soort" value="{{ soort }}">
<div class="info-note">
{% if soort == 'afwezigheid' %}Het secretariaat ziet je afwezigheid meteen en krijgt een mail. Geen goedkeuring nodig.
{% elif soort == 'nascholing' %}De directie krijgt je aanvraag en kan goedkeuren, weigeren of een vraag stellen. Je krijgt een mail bij elke beslissing.
{% else %}De directie keurt de uitstap goed. Daarna ziet het secretariaat welke klassen en begeleiders weg zijn.{% endif %}
</div>
{% if is_secr %}
<div class="field">
<label for="namens">Namens leerkracht <span class="muted">(leeg = jezelf)</span></label>
<input type="email" id="namens" name="namens" value="{{ form.get('namens', '') }}" placeholder="voornaam.naam@school.be">
</div>
{% endif %}
{% if soort != 'afwezigheid' %}
<div class="field">
<label for="titel">{{ 'Naam van de nascholing' if soort == 'nascholing' else 'Bestemming / activiteit' }}</label>
<input type="text" id="titel" name="titel" required maxlength="255" value="{{ form.get('titel', '') }}"
placeholder="{{ 'bv. Differentiëren in de klas' if soort == 'nascholing' else 'bv. Bezoek Technopolis' }}">
</div>
{% else %}
<div class="field">
<label for="titel">Reden <span class="muted">(optioneel, enkel zichtbaar voor secretariaat en directie)</span></label>
<select id="titel" name="titel">
{% for r in ['Ziek', 'Familiale omstandigheden', 'Dokter / ziekenhuis', 'Overmacht', 'Andere'] %}
<option {{ 'selected' if form.get('titel') == r }}>{{ r }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="grid2">
<div class="field">
<label for="van">{{ 'Datum' if soort != 'afwezigheid' else 'Van' }}</label>
<input type="date" id="van" name="van" required value="{{ form.get('van', vandaag.isoformat()) }}" onchange="syncTot()">
</div>
<div class="field">
<label for="tot">Tot en met</label>
<input type="date" id="tot" name="tot" value="{{ form.get('tot', '') }}">
</div>
</div>
<div class="field">
<label>Wanneer</label>
<div class="dagdeel">
{% for key, label in DAGDELEN.items() %}
<label><input type="radio" name="dagdeel" value="{{ key }}" {{ 'checked' if form.get('dagdeel', 'dag') == key }} onchange="toggleUren()"><span>{{ label }}</span></label>
{% endfor %}
</div>
</div>
<div class="field" id="urenField" style="display:none">
<label for="lesuren">Lesuren</label>
<input type="text" id="lesuren" name="lesuren" maxlength="50" value="{{ form.get('lesuren', '') }}" placeholder="bv. 3-5 of 1, 2, 7">
</div>
{% if soort != 'afwezigheid' %}
<div class="grid2">
<div class="field">
<label for="locatie">Locatie</label>
<input type="text" id="locatie" name="locatie" maxlength="255" value="{{ form.get('locatie', '') }}">
</div>
<div class="field">
<label for="kost">Kost <span class="muted">(€, optioneel)</span></label>
<input type="text" id="kost" name="kost" inputmode="decimal" value="{{ form.get('kost', '') }}" placeholder="0,00">
</div>
</div>
{% endif %}
<div class="field">
<label>{{ 'Klassen die mee op uitstap gaan' if soort == 'uitstap' else 'Klassen waar je les hebt (voor vervanging)' }}</label>
<div class="klassen-box">
{% if klassen %}
<input type="search" placeholder="Zoek klas..." oninput="filterKlassen(this.value)">
<div class="klassen" id="klassen">
{% set gekozen = form.getlist('klassen') if form else [] %}
{% for k in klassen %}
<label class="klas {{ 'on' if k in gekozen }}" data-k="{{ k|lower }}">
<input type="checkbox" name="klassen" value="{{ k }}" {{ 'checked' if k in gekozen }} onchange="this.parentElement.classList.toggle('on', this.checked); telKlassen()">{{ k }}</label>
{% endfor %}
</div>
<div class="gekozen" id="gekozen"></div>
<input type="text" name="klassen_extra" value="{{ form.get('klassen_extra', '') }}" placeholder="Andere groepen (optioneel, komma-gescheiden)" style="margin-top:8px">
{% else %}
<input type="text" name="klassen_extra" value="{{ form.get('klassen_extra', '') }}" placeholder="bv. 3A, 3B, 4ECWI">
<div class="gekozen">De klassenlijst uit Smartschool kon niet geladen worden; typ de klassen in.</div>
{% endif %}
</div>
</div>
{% if soort == 'uitstap' %}
<div class="field">
<label for="begeleiders">Andere begeleiders</label>
<input type="text" id="begeleiders" name="begeleiders" value="{{ form.get('begeleiders', '') }}" placeholder="bv. Els Janssens, Tom Vermeulen">
<div class="field-hint">Begeleiders verschijnen ook als afwezig in het dagoverzicht van het secretariaat.</div>
</div>
{% endif %}
<div class="field">
<label for="omschrijving">{{ 'Opmerking voor het secretariaat' if soort == 'afwezigheid' else 'Toelichting' }}</label>
<textarea id="omschrijving" name="omschrijving" placeholder="{{ 'bv. taken voor de klassen staan op Smartschool' if soort == 'afwezigheid' else 'Doel, programma, vervoer, ...' }}">{{ form.get('omschrijving', '') }}</textarea>
</div>
<div class="modal-footer" style="justify-content: space-between">
<a class="btn btn-ghost" href="{{ url_for('personeel.mijn') }}">Annuleren</a>
<button type="submit" class="btn-lg">{{ 'Melden' if soort == 'afwezigheid' else 'Aanvraag indienen' }}</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
function toggleUren() {
const v = document.querySelector('input[name=dagdeel]:checked').value;
document.getElementById('urenField').style.display = v === 'uren' ? '' : 'none';
}
function syncTot() {
const van = document.getElementById('van'), tot = document.getElementById('tot');
if (!tot.value || tot.value < van.value) tot.value = van.value;
tot.min = van.value;
}
function filterKlassen(q) {
q = q.toLowerCase();
document.querySelectorAll('#klassen .klas').forEach(k => { k.style.display = k.dataset.k.includes(q) ? '' : 'none'; });
}
function telKlassen() {
const el = document.getElementById('gekozen'); if (!el) return;
const g = [...document.querySelectorAll('#klassen input:checked')].map(i => i.value);
el.textContent = g.length ? `Gekozen: ${g.join(', ')}` : '';
}
toggleUren(); syncTot(); telKlassen();
</script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Inloggen{% endblock %}
{% block body_class %}auth-page{% endblock %}
{% block header %}{% endblock %}
{% block main_class %}auth-wrap{% endblock %}
{% block flashes %}{% endblock %}
{% block content %}
<img class="auth-logo" src="{{ url_for('static', filename='pivot_logo.png') }}" alt="PIVOT – Platform voor de Inventarisatie en Verbinding tussen Onderwijs Tools">
<form class="card auth-card" method="POST" action="{{ url_for('auth.login') }}">
<h2>🔐 Inloggen</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category if category != 'error' else 'danger' }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% if school_vast %}
<p class="muted" style="text-align:center;margin-top:-6px">🏫 {{ gekozen.naam }}</p>
{% endif %}
<div class="field">
<label for="username">Gebruikersnaam</label>
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
</div>
<div class="field">
<label for="password">Wachtwoord</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-block btn-lg">Aanmelden</button>
{% if google_aan %}
<div class="separator">of</div>
<a href="{{ url_for('auth.google_login', school=gekozen.code if school_vast else None) }}" class="google-btn" id="google-btn">
<img src="https://fonts.gstatic.com/s/i/productlogos/googleg/v6/24px.svg" alt="">
Inloggen met Google
</a>
{% endif %}
</form>
{% endblock %}
{% block footer %}
<footer class="app-footer">
PIVOT – Versie {{ version }} – Kristof Verbruggen
</footer>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}QR-generator{% endblock %}
{% block page_title %}QR-codegenerator{% endblock %}
{% block main_class %}narrow{% endblock %}
{% block extra_css %}
<style>
.steps { counter-reset: step; list-style: none; padding: 0; margin: 0; }
.steps > li { position: relative; padding-left: 44px; padding-bottom: 22px; }
.steps > li::before {
counter-increment: step; content: counter(step);
position: absolute; left: 0; top: 0;
width: 30px; height: 30px; border-radius: 50%;
display: grid; place-items: center;
background: var(--primary-light); color: var(--primary-color); font-weight: 700;
}
.steps h3 { margin: 3px 0 10px; }
.options-box { padding: 6px 14px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); margin: 14px 0 18px; }
.options-box .check { padding: 8px 0; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<div>
<h2>💡 QR-codes maken</h2>
<p class="muted" style="margin:0">Maak eenvoudig QR-codes aan vanuit een Excel-bestand.</p>
</div>
</div>
<ol class="steps">
<li>
<h3>Download het sjabloon</h3>
<a href="{{ url_for('qr_generator.download_template') }}" class="btn btn-light">📥 Sjabloon (.xlsx)</a>
</li>
<li>
<h3>Upload je ingevulde bestand</h3>
<form action="{{ url_for('qr_generator.upload_file') }}" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".xlsx, .xls" required>
<div class="options-box">
<label class="check"><input type="checkbox" id="show_top" name="show_top" checked> Toon tekst boven QR-code</label>
<label class="check"><input type="checkbox" id="show_bottom" name="show_bottom" checked> Toon tekst onder QR-code</label>
</div>
<button type="submit" class="btn-block btn-lg">🚀 Genereer QR-codes (ZIP)</button>
</form>
</li>
</ol>
</div>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}Scholen{% endblock %}
{% block page_title %}Scholen in PIVOT{% endblock %}
{% block extra_css %}
<style>
tr.groep-kop td { background: var(--surface-muted); padding: 0; }
tr.groep-kop button { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 10px; width: 100%;
padding: 12px 14px; cursor: pointer; font-weight: 700; font-size: .82em; letter-spacing: .06em;
text-transform: uppercase; color: var(--text-muted, #475569); }
tr.groep-kop button:hover { color: var(--primary-color); }
tr.groep-kop button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
tr.groep-kop .pijl { display: inline-block; transition: transform .15s; }
tbody.dicht tr.groep-kop .pijl { transform: rotate(-90deg); }
tbody.dicht tr.school-rij { display: none; }
tr.groep-kop .aantal { font-weight: 600; letter-spacing: 0; text-transform: none; background: var(--surface);
border: 1px solid var(--border); border-radius: 999px; padding: 1px 9px; }
td.school-naam { width: 100%; }
td.school-naam .badge { margin-left: 6px; }
td.koppelingen { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🏫 Scholen</h2>
<div class="actions">
<form method="POST" action="{{ url_for('scholen.alles_testen') }}" style="margin:0"
onsubmit="showLoader('Koppelingen van alle scholen testen...')">
<button type="submit" class="btn-light" title="Test de koppelingen van alle actieve scholen">🩺 Alle scholen testen</button>
</form>
<a href="{{ url_for('scholen.nieuw') }}" class="btn btn-success">➕ Nieuwe school</a>
</div>
</div>
<div class="table-wrap">
<table>
{% for g in groepen %}
<tbody class="groep" data-groep="{{ g.code }}">
<tr class="groep-kop"><td colspan="3">
<button type="button" aria-expanded="true"><span class="pijl">▾</span> {{ g.label }}
<span class="aantal">{{ g.scholen|length }}</span></button>
</td></tr>
{% for s in g.scholen %}
<tr class="school-rij">
<td class="school-naam">
<strong>{{ s.naam }}</strong>
{% if not s.actief %}<span class="badge badge-warning">inactief</span>{% endif %}
{% if school and school.code == s.code %}<span class="badge badge-info">huidige</span>{% endif %}
</td>
<td class="koppelingen">
{% for b in badges[s.code] %}
<span class="badge {{ {'ok': 'badge-success', 'fout': 'badge-danger', 'ongetest': 'badge-warning'}.get(b.toestand, '') }}"
title="{{ b.titel }}">{{ {'ok': '✓', 'fout': '✗', 'ongetest': '?'}.get(b.toestand, '–') }} {{ b.label }}</span>
{% endfor %}
</td>
<td class="actions nowrap">
{% if s.actief %}
<a href="{{ url_for('scholen.wissel', code=s.code) }}" class="btn btn-sm btn-secondary" title="Werken in deze school">↪ Openen</a>
{% endif %}
<a href="{{ url_for('scholen.bewerken', code=s.code) }}" class="btn btn-sm btn-light">✏️ Bewerken</a>
<form method="POST" action="{{ url_for('scholen.test', code=s.code) }}" style="display:inline;margin:0" onsubmit="showLoader('Verbindingen testen...')">
<button type="submit" class="btn-sm btn-light">🩺 Testen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
{% else %}
<tbody>
<tr class="empty-row"><td colspan="3">Nog geen scholen. Voeg er een toe, of importeer de oude config.ini met <code>python beheer.py importeer-config</code>.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Groepen in- en uitklappen; de keuze wordt in deze browser onthouden
document.querySelectorAll('tbody.groep').forEach(tb => {
const sleutel = 'pivot.scholen.dicht.' + tb.dataset.groep;
const knop = tb.querySelector('tr.groep-kop button');
const zet = dicht => {
tb.classList.toggle('dicht', dicht);
knop.setAttribute('aria-expanded', String(!dicht));
};
try { zet(localStorage.getItem(sleutel) === '1'); } catch (e) { /* geen opslag */ }
knop.addEventListener('click', () => {
const dicht = !tb.classList.contains('dicht');
zet(dicht);
try { localStorage.setItem(sleutel, dicht ? '1' : '0'); } catch (e) { /* negeren */ }
});
});
</script>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}{{ 'Nieuwe school' if nieuw else school.naam }}{% endblock %}
{% block page_title %}{{ 'Nieuwe school' if nieuw else 'Instellingen school' }}{% endblock %}
{% block extra_css %}
<style>
.veld-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0 20px; }
.veld-grid .field.breed { grid-column: 1 / -1; }
details.groep { margin-top: 20px; }
details.groep > summary { cursor: pointer; list-style: none; }
details.groep > summary::-webkit-details-marker { display: none; }
details.groep > summary h3 { display: inline; font-size: 1.05rem; }
details.groep > summary::after { content: ' ▸'; color: var(--text-muted); }
details.groep[open] > summary::after { content: ' ▾'; }
details.groep > .veld-grid { margin-top: 14px; }
.geheim-status { font-size: 12.5px; }
.wis-check { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--text-muted); margin-top: 4px; }
.wis-check input { width: auto; }
.sticky-acties { position: sticky; bottom: 0; background: var(--surface); padding: 14px 0 4px; border-top: 1px solid var(--border); margin-top: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
textarea.mono { font-size: 12.5px; }
</style>
{% endblock %}
{# Ingevulde waarde; is er nog niets ingevuld, dan staat de standaardwaarde al klaar in het veld. #}
{% macro waarde(key, standaard='') -%}
{%- if form.get('v_' ~ key) is not none -%}{{ form.get('v_' ~ key) }}
{%- elif school and school.waarden.get(key) not in (none, '') -%}{{ school.waarden.get(key) }}
{%- else -%}{{ standaard }}{%- endif -%}
{%- endmacro %}
{% block content %}
<form method="POST" enctype="multipart/form-data" autocomplete="off"
action="{{ url_for('scholen.nieuw') if nieuw else (url_for('scholen.bewerken', code=school.code) if mag_alles and request.endpoint == 'scholen.bewerken' else url_for('scholen.mijn_school')) }}">
<div class="card">
<div class="page-head">
<h2>🏫 {{ 'Nieuwe school' if nieuw else school.naam }}</h2>
{% if not nieuw %}
<div class="actions">
<button type="submit" form="testform" class="btn-sm btn-light">🩺 Verbindingen testen</button>
</div>
{% endif %}
</div>
{% if mag_alles %}
<div class="veld-grid">
<div class="field">
<label for="code">Code</label>
{% if nieuw %}
<input type="text" id="code" name="code" required pattern="[a-z0-9_-]{2,32}" value="{{ form.get('code', '') }}" placeholder="bv. kamxm">
<span class="field-hint">Korte, vaste afkorting (kleine letters). Kan later niet meer wijzigen.</span>
{% else %}
<input type="text" value="{{ school.code }}" readonly class="mono">
{% endif %}
</div>
<div class="field">
<label for="naam">Naam</label>
<input type="text" id="naam" name="naam" required value="{{ form.get('naam', school.naam if school else '') }}" placeholder="Koninklijk Atheneum ...">
</div>
<div class="field">
<label for="domeinen">E-maildomein(en)</label>
<input type="text" id="domeinen" name="domeinen" required value="{{ form.get('domeinen', school.domeinen|join(', ') if school else '') }}" placeholder="school.be, leerling.school.be">
<span class="field-hint">Hiermee wordt de school herkend bij Google-login. Het eerste domein is het hoofddomein (nieuwe accounts).</span>
</div>
<div class="field">
<label for="hostnamen">Eigen webadres(sen) <span class="muted">(optioneel)</span></label>
<input type="text" id="hostnamen" name="hostnamen" value="{{ form.get('hostnamen', school.hostnamen|join(', ') if school else '') }}" placeholder="pivot.school.be">
<span class="field-hint">Wie PIVOT via dit adres opent, komt meteen bij deze school terecht.</span>
</div>
<div class="field">
<label for="db_naam">Database</label>
<input type="text" id="db_naam" name="db_naam" required class="mono" value="{{ form.get('db_naam', school.db_naam if school else '') }}" placeholder="pivot_kamxm">
{% if not nieuw %}
{% if db_status == 'ok' %}
<span class="field-hint">✓ Database bestaat en heeft de PIVOT-tabellen.</span>
{% elif db_status in ('ontbreekt', 'leeg') %}
<span class="field-hint" style="color:var(--danger-text)">⚠️ {{ 'Deze database bestaat nog niet.' if db_status == 'ontbreekt' else 'Deze database heeft (nog) niet de PIVOT-tabellen.' }}</span>
<button type="submit" form="dbform" class="btn-sm btn-warning" style="margin-top:6px"
onclick="return confirm('Database {{ school.db_naam }} aanmaken met de standaardstructuur 2026-27? Bestaande tabellen en gegevens blijven behouden.')">🗄️ Database nu aanmaken</button>
{% endif %}
{% endif %}
</div>
{% if nieuw %}
<div class="field">
<label for="kopieer_schema_van">Database aanmaken?</label>
<select id="kopieer_schema_van" name="kopieer_schema_van">
<option value="">Nee, de database bestaat al</option>
<option value="__standaard__" {% if form.get('kopieer_schema_van') == '__standaard__' %}selected{% endif %}>Ja, nieuwe lege PIVOT-database (standaardstructuur 2026-27)</option>
{% for s in andere_scholen %}
<option value="{{ s.code }}" {% if form.get('kopieer_schema_van') == s.code %}selected{% endif %}>Ja, kopie van {{ s.naam }} (met gegevens)</option>
{% endfor %}
</select>
<div id="kopieOpties" class="field-hint" style="display:none;margin-top:8px">
<label class="check-box" style="margin:0 0 4px"><input type="checkbox" name="kopie_koppelingen" value="1" style="width:auto"
{% if not form or form.get('kopie_koppelingen') %}checked{% endif %}> Ook koppelingen en instellingen overnemen
(Smartschool, Informat, Google-sleutel, …)</label>
<label class="check-box" style="margin:0 0 4px"><input type="checkbox" name="kopie_rechten" value="1" style="width:auto"
{% if not form or form.get('kopie_rechten') %}checked{% endif %}> Ook de gebruikersrechten overnemen</label>
Alle tabellen en gegevens worden gekopieerd naar een <strong>nieuwe</strong> database.
<div class="alert alert-warning" style="margin:8px 0 0;font-size:13px">
💡 <strong>Testkopie?</strong> Vink dan <em>School is actief</em> uit. Een actieve kopie met dezelfde koppelingen
voert de geplande taken (afwezigheden, Skore, 2FA-opruiming) een tweede keer uit, op dezelfde Smartschool en
Google-omgeving. Wordt het een echte nieuwe school, pas dan eerst de koppelingen aan en zet ze pas daarna actief.
</div>
</div>
</div>
<script>
(function () {
var sel = document.getElementById('kopieer_schema_van'), box = document.getElementById('kopieOpties');
function toon() { box.style.display = (sel.value && sel.value !== '__standaard__') ? '' : 'none'; }
sel.addEventListener('change', toon); toon();
})();
</script>
{% endif %}
<div class="field">
<label>&nbsp;</label>
<label class="check-box"><input type="checkbox" name="actief" value="1" style="width:auto" {% if (form.get('actief') if form else (school.actief if school else True)) %}checked{% endif %}> School is actief</label>
</div>
</div>
{% else %}
<p class="muted">Code <span class="mono">{{ school.code }}</span> · domein(en) {{ school.domeinen|join(', ') }} · database <span class="mono">{{ school.db_naam }}</span>.
Deze gegevens wijzigt de superbeheerder van de scholengroep.</p>
{% endif %}
</div>
{% for groep, titel in groepen %}
{% if groep not in enkel_super or mag_alles %}
<div class="card">
<details class="groep" {% if groep in ('algemeen', 'smartschool', 'informat', 'google') or nieuw %}open{% endif %} style="margin-top:0">
<summary><h3>{{ titel }}</h3></summary>
{% if groep == 'google' %}
<div class="field breed" style="margin-top:14px">
<label>Serviceaccount (JSON-sleutel)</label>
{% if school and school.google_sleutel %}
<p class="geheim-status"><span class="badge badge-success">✓ ingesteld</span>
<span class="mono">{{ school.google_sleutel.client_email }}</span></p>
<p class="field-hint">Client-ID voor domeinbrede delegatie (Admin-console › Beveiliging › API-beheer):
<strong class="mono">{{ school.google_sleutel.client_id }}</strong></p>
<label class="wis-check"><input type="checkbox" name="wis_google_sleutel" value="1"> sleutel verwijderen</label>
{% else %}
<p class="geheim-status"><span class="badge badge-warning">nog niet ingesteld</span></p>
{% endif %}
<input type="file" name="google_sleutel" accept=".json,application/json" style="margin-top:8px">
<span class="field-hint">Upload het JSON-bestand van het serviceaccount van deze school. Het wordt versleuteld bewaard en is nooit via de website op te vragen.</span>
</div>
{% endif %}
<div class="veld-grid">
{% for v in velden if v.groep == groep %}
<div class="field {{ 'breed' if v.soort == 'tekstvak' }}">
<label for="v_{{ v.key }}">{{ v.label }}</label>
{% if v.soort == 'geheim' %}
{% set ingesteld = school and school.waarden.get(v.key) %}
<input type="password" id="v_{{ v.key }}" name="v_{{ v.key }}" autocomplete="new-password"
placeholder="{{ '•••••••• ingesteld – leeg laten = ongewijzigd' if ingesteld else 'niet ingesteld' }}">
{% if ingesteld %}
<label class="wis-check"><input type="checkbox" name="wis_{{ v.key }}" value="1"> wissen</label>
{% endif %}
{% elif v.soort == 'janee' %}
{% set aan = (school.get(v.key) if school else v.standaard) %}
<label class="check-box"><input type="checkbox" id="v_{{ v.key }}" name="v_{{ v.key }}" value="1" style="width:auto" {% if aan %}checked{% endif %}> Ja</label>
{% elif v.soort == 'keuze' %}
{% set gekozen = waarde(v.key) %}
<select id="v_{{ v.key }}" name="v_{{ v.key }}">
<option value="">– kies –</option>
{% for w, l in v.keuzes %}<option value="{{ w }}" {% if gekozen == w %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
{% elif v.soort == 'tekstvak' %}
<textarea id="v_{{ v.key }}" name="v_{{ v.key }}" rows="5" class="mono">{{ waarde(v.key, v.standaard) }}</textarea>
{% else %}
<input type="{{ 'number' if v.soort == 'getal' else 'text' }}" id="v_{{ v.key }}" name="v_{{ v.key }}"
value="{{ waarde(v.key, v.standaard) }}">
{% endif %}
{% if v.hulp %}<span class="field-hint">{{ v.hulp }}</span>{% endif %}
</div>
{% endfor %}
</div>
</details>
</div>
{% endif %}
{% endfor %}
<div class="sticky-acties">
<button type="submit" class="btn-success">💾 Opslaan</button>
{% if session.get('superbeheerder') %}
<a href="{{ url_for('scholen.overzicht') }}" class="btn btn-light">Annuleren</a>
{% else %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-light">Annuleren</a>
{% endif %}
</div>
</form>
{% if not nieuw %}
<form id="testform" method="POST" action="{{ url_for('scholen.test', code=school.code) }}" onsubmit="showLoader('Verbindingen testen...')"></form>
{% if mag_alles %}
<form id="dbform" method="POST" action="{{ url_for('scholen.database_aanmaken', code=school.code) }}" onsubmit="showLoader('Database aanmaken...')"></form>
{% endif %}
{% endif %}
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Verbindingstest{% endblock %}
{% block page_title %}Verbindingstest{% endblock %}
{% block content %}
<div class="card">
<div class="page-head"><h2>🩺 {{ school.naam }}</h2></div>
<div class="table-wrap">
<table>
<thead><tr><th>Onderdeel</th><th>Resultaat</th><th>Details</th></tr></thead>
<tbody>
{% for onderdeel, ok, detail in resultaten %}
<tr>
<td><strong>{{ onderdeel }}</strong></td>
<td>
{% if ok is none %}<span class="badge">niet ingesteld</span>
{% elif ok %}<span class="badge badge-success">✓ OK</span>
{% else %}<span class="badge badge-danger">✗ Fout</span>{% endif %}
</td>
<td class="small">{{ detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<form method="POST" action="{{ url_for('scholen.test', code=school.code) }}" style="margin:0;display:inline"
onsubmit="if (typeof showLoader === 'function') showLoader()">
<button type="submit" class="btn-secondary">🔄 Opnieuw testen</button>
</form>
{% if session.get('superbeheerder') %}
<a href="{{ url_for('scholen.bewerken', code=school.code) }}" class="btn btn-light">✏️ Instellingen</a>
<a href="{{ url_for('scholen.overzicht') }}" class="btn btn-light">← Alle scholen</a>
{% else %}
<a href="{{ url_for('scholen.mijn_school') }}" class="btn btn-light">← Instellingen</a>
{% endif %}
</div>
</div>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}SGR groepen{% endblock %}
{% block page_title %}SGR groepen{% endblock %}
{% block main_class %}wide{% endblock %}
{% block loader_text %}Groepen ophalen uit Smartschool...{% endblock %}
{% block extra_css %}
<style>
.keuze { display: flex; gap: 8px; align-items: center; margin: 0; }
.keuze input { width: 240px; }
.tellers { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 6px; }
.tellers span { padding: 4px 12px; border-radius: 999px; background: var(--surface-muted); border: 1px solid var(--border); font-size: .88em; }
.tellers b { margin-left: 4px; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
.filter-bar input[type=search] { flex: 0 1 340px; }
.groepen { display: grid; gap: 8px; }
details.groep { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface, #fff); }
details.groep[open] { border-color: var(--primary-color); }
details.groep > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 14px;
cursor: pointer; list-style: none; }
details.groep > summary::-webkit-details-marker { display: none; }
details.groep > summary::before { content: "▸"; color: var(--text-muted, #64748b); transition: transform .15s; }
details.groep[open] > summary::before { transform: rotate(90deg); }
.groep .gnaam { font-weight: 600; }
.groep .pad { font-size: .85em; color: var(--text-muted, #64748b); }
.groep .aantal { margin-left: auto; padding: 2px 10px; border-radius: 999px; background: var(--primary-light);
color: var(--primary-color); font-weight: 700; font-size: .88em; white-space: nowrap; }
.groep .aantal.leeg { background: var(--surface-muted); color: var(--text-muted, #64748b); font-weight: 400; }
.groep .inhoud { padding: 0 14px 12px; }
.groep table { margin: 0; }
.rol { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .85em; background: var(--surface-muted); }
.rol.Leerling { background: #dbeafe; color: #1d4ed8; }
.rol.Leerkracht { background: #dcfce7; color: #15803d; }
mark { background: #fef08a; padding: 0; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>👥 SGR groep in Smartschool</h2>
<div class="actions">
<form method="GET" action="{{ url_for('sgr_groepen.overzicht') }}" class="keuze" onsubmit="showLoader()">
<input type="text" name="groep" value="{{ groep }}" aria-label="Hoofdgroep" title="Naam of code van de Smartschool-groep">
<button type="submit" class="btn-light btn-sm">Tonen</button>
</form>
{% if groepen %}<a class="btn btn-light btn-sm" href="{{ url_for('sgr_groepen.export', groep=groep if groep != standaard else None) }}">⬇️ CSV</a>{% endif %}
<a class="btn btn-secondary btn-sm" href="{{ url_for('sgr_groepen.overzicht', groep=groep if groep != standaard else None, vernieuw=1) }}" onclick="showLoader()">🔄 Opnieuw ophalen</a>
</div>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% elif not groepen %}
<p class="muted">De groep <strong>{{ hoofd.naam }}</strong> bestaat in Smartschool, maar heeft geen subgroepen.</p>
{% else %}
<div class="tellers">
<span>Subgroepen <b>{{ groepen|length }}</b></span>
<span title="Elk lidmaatschap apart geteld">Lidmaatschappen <b>{{ aantal_leden }}</b></span>
<span title="Wie in meer dan één groep zit, telt één keer">Verschillende personen <b>{{ uniek }}</b></span>
{% set leeg = groepen|selectattr('leden', 'equalto', [])|selectattr('fout', 'none')|list|length %}
{% if leeg %}<span>Zonder leden <b>{{ leeg }}</b></span>{% endif %}
</div>
<div class="filter-bar">
<input type="search" id="zoek" placeholder="Zoek een groep of een lid (naam, gebruikersnaam)..." aria-label="Zoeken">
<button type="button" class="btn-light btn-sm" id="allesOpen">Alles openklappen</button>
<button type="button" class="btn-light btn-sm" id="allesDicht">Alles dichtklappen</button>
<span class="muted small" id="zoekTeller"></span>
<span class="muted small">Opgehaald uit Smartschool op {{ opgehaald }}</span>
</div>
<div class="groepen" id="groepen">
{% for g in groepen %}
<details class="groep" style="margin-left: {{ g.diepte * 24 }}px" data-groep="{{ (g.naam ~ ' ' ~ g.code ~ ' ' ~ g.omschrijving)|lower }}">
<summary>
<span class="gnaam">{{ g.naam }}</span>
{% if g.code and g.code != g.naam %}<span class="muted small">{{ g.code }}</span>{% endif %}
{% if g.pad %}<span class="pad">in {{ g.pad|join(' › ') }}</span>{% endif %}
{% if g.fout %}
<span class="badge badge-danger" title="{{ g.fout }}">leden niet op te halen</span>
{% else %}
<span class="aantal {{ 'leeg' if not g.leden }}">{{ g.leden|length }} {{ 'lid' if g.leden|length == 1 else 'leden' }}{% if g.aantal_sub %} · {{ g.aantal_sub }} subgroep{{ 'en' if g.aantal_sub != 1 }}{% endif %}</span>
{% endif %}
</summary>
<div class="inhoud">
{% if g.omschrijving and g.omschrijving != g.naam %}<p class="muted small" style="margin:0 0 8px">{{ g.omschrijving }}</p>{% endif %}
{% if g.fout %}
<div class="alert alert-danger" style="margin:0">Leden konden niet opgehaald worden: {{ g.fout }}</div>
{% elif not g.leden %}
<p class="muted small" style="margin:0">Deze groep heeft momenteel geen rechtstreekse leden.</p>
{% else %}
<div class="table-wrap">
<table>
<thead><tr><th>Naam</th><th>Voornaam</th><th>Gebruikersnaam</th><th>Rol</th><th>E-mail</th></tr></thead>
<tbody>
{% for l in g.leden %}
<tr class="lid" data-tekst="{{ (l.naam ~ ' ' ~ l.voornaam ~ ' ' ~ l.voornaam ~ ' ' ~ l.naam ~ ' ' ~ l.gebruikersnaam ~ ' ' ~ l.email)|lower }}">
<td><strong>{{ l.naam }}</strong></td>
<td>{{ l.voornaam }}</td>
<td>{{ l.gebruikersnaam }}</td>
<td>{% if l.rol %}<span class="rol {{ l.rol }}">{{ l.rol }}</span>{% endif %}</td>
<td class="small">{{ l.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</details>
{% endfor %}
</div>
{% endif %}
<p class="field-hint" style="margin-top:14px">Toont alle groepen onder <strong>{{ groep }}</strong> in Smartschool met hun huidige,
rechtstreekse leden. De standaard hoofdgroep ({{ standaard }}) stel je in via 🔌 Koppelingen › Smartschool.
Gegevens worden 10 minuten bewaard.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const lijst = document.getElementById('groepen');
if (!lijst) return;
const groepen = Array.from(lijst.querySelectorAll('details.groep'));
const zoekveld = document.getElementById('zoek'), teller = document.getElementById('zoekTeller');
document.getElementById('allesOpen').onclick = () => groepen.forEach(d => { if (d.style.display !== 'none') d.open = true; });
document.getElementById('allesDicht').onclick = () => groepen.forEach(d => d.open = false);
zoekveld.addEventListener('input', () => {
const z = zoekveld.value.trim().toLowerCase();
let treffers = 0, zichtbaar = 0;
groepen.forEach(d => {
const naamTreffer = !z || d.dataset.groep.includes(z);
let lidTreffer = 0;
d.querySelectorAll('tr.lid').forEach(tr => {
const ok = !z || naamTreffer || tr.dataset.tekst.includes(z);
tr.style.display = ok ? '' : 'none';
if (z && tr.dataset.tekst.includes(z)) lidTreffer++;
});
const toon = naamTreffer || lidTreffer > 0;
d.style.display = toon ? '' : 'none';
if (toon) zichtbaar++;
treffers += lidTreffer;
if (z) d.open = lidTreffer > 0;
});
teller.textContent = z ? `${zichtbaar} groep(en)` + (treffers ? `, ${treffers} lid/leden gevonden` : '') : '';
});
})();
</script>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Signpost DB{% endblock %}
{% block page_title %}Signpost-database{% endblock %}
{% block loader_text %}Synchroniseren met Signpost...{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>📋 Signpost-toestellen</h2>
<div class="actions">
<form method="POST" action="{{ url_for('signpost_devices.sync_signpost') }}" style="margin:0" onsubmit="showLoader()">
<button type="submit" class="btn-secondary" title="Synchroniseer de lokale database met de live Signpost M4S-API">🔄 Sync met Signpost</button>
</form>
</div>
</div>
<form method="GET" action="{{ url_for('signpost_devices.signpost_database_overzicht') }}" class="search-bar">
<input type="text" name="query" placeholder="Zoek op Signpostnummer of serienummer..." value="{{ query or '' }}">
<button type="submit">🔍 Zoeken</button>
</form>
{% if message %}
<div class="alert">{{ message }}</div>
{% endif %}
{% if results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Signpostnummer</th>
<th>Serienummer</th>
<th>Model</th>
<th class="text-right">Aankoopbedrag</th>
<th>Aankoopdatum</th>
<th>Garantie</th>
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td><strong>{{ row['Signpostnummer'] if row['Signpostnummer'] is defined else row[0] }}</strong></td>
<td class="mono">{{ row['Device_serienummer'] if row['Device_serienummer'] is defined else row[1] }}</td>
<td>{{ row['Device_model'] if row['Device_model'] is defined else row[2] }}</td>
<td class="text-right nowrap">
{% set bedrag = row['Aankoopbedrag'] if row['Aankoopbedrag'] is defined else row[3] %}
{% if bedrag %}€ {{ bedrag }}{% else %}-{% endif %}
</td>
<td class="nowrap">{{ row['Aankoopdatum'] if row['Aankoopdatum'] is defined else row[4] }}</td>
<td>{{ row['Garantie'] if row['Garantie'] is defined else row[5] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Geen records gevonden in de database.</p>
{% endif %}
</div>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Skore-synchronisatie{% endblock %}
{% block page_title %}Skore-synchronisatie{% endblock %}
{% block main_class %}medium{% endblock %}
{% block head %}{% if loopt %}<meta http-equiv="refresh" content="20">{% endif %}{% endblock %}
{% block extra_css %}
<style>
.planning-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0 16px; }
.status-gestart, .status-bezig { background: var(--info-bg); color: var(--info-text); }
.status-voltooid { background: var(--success-bg); color: var(--success-text); }
.status-mislukt { background: var(--danger-bg); color: var(--danger-text); }
.melding { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
.verborgen { display: none; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>🔁 Skore-synchronisatie</h2>
<div class="actions">
<form method="POST" action="{{ url_for('skore.start') }}" style="margin:0"
onsubmit="return confirm('De Skore-synchronisatie nu starten in Smartschool?')">
<button type="submit" class="btn-success">▶️ Nu synchroniseren</button>
</form>
</div>
</div>
<p class="muted">Synchroniseert de klassen en leerlingen van Smartschool naar Skore (Smartschool-webservice
<span class="mono">startSkoreSync</span>). Smartschool voert de synchronisatie op de achtergrond uit;
PIVOT vraagt de status daarna zelf op tot de synchronisatie klaar is.</p>
{% if loopt %}<div class="alert alert-info" style="margin:12px 0 0">⏳ Er loopt een synchronisatie. Deze pagina ververst elke 20 seconden.</div>{% endif %}
</div>
{% if planning %}
<div class="card">
<h3 class="card-title">🗓️ Automatisch synchroniseren</h3>
<form method="POST" action="{{ url_for('skore.planning') }}">
<div class="planning-grid">
<div class="field">
<label for="frequentie">Hoe vaak</label>
<select id="frequentie" name="frequentie" onchange="toonVelden()">
{% for waarde, label in frequenties %}
<option value="{{ waarde }}" {% if planning.Frequentie == waarde %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field veld-weekdag">
<label for="weekdag">Op</label>
<select id="weekdag" name="weekdag">
{% for d in weekdagen %}<option value="{{ loop.index0 }}" {% if planning.Weekdag == loop.index0 %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
</div>
<div class="field veld-dag">
<label for="dag_van_maand">Dag van de maand</label>
<select id="dag_van_maand" name="dag_van_maand">
{% for d in range(1, 29) %}<option value="{{ d }}" {% if planning.Dag_van_maand == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
</div>
<div class="field veld-tijd">
<label for="uur">Om</label>
<div style="display:flex; gap:6px; align-items:center">
<select id="uur" name="uur" style="width:auto">
{% for u in range(24) %}<option value="{{ u }}" {% if planning.Uur == u %}selected{% endif %}>{{ '%02d' % u }}</option>{% endfor %}
</select> :
<select name="minuut" style="width:auto">
{% for m in range(0, 60, 15) %}<option value="{{ m }}" {% if planning.Minuut == m %}selected{% endif %}>{{ '%02d' % m }}</option>{% endfor %}
</select>
</div>
</div>
</div>
<p class="field-hint" style="margin-top:0">
{% if volgende %}Volgende automatische synchronisatie: <strong>{{ volgende.strftime('%d-%m-%Y om %H:%M') }}</strong>.
{% else %}Er is geen automatische synchronisatie gepland.{% endif %}
PIVOT moet op dat moment draaien; een gemiste synchronisatie wordt nog tot 6 uur later ingehaald.
</p>
<button type="submit" class="btn-warning">💾 Planning opslaan</button>
</form>
</div>
{% endif %}
<div class="card">
<h3 class="card-title">🕑 Laatste synchronisaties</h3>
<div class="table-wrap">
<table>
<thead><tr><th>Gestart</th><th>Door</th><th>Status</th><th>Melding</th><th class="actions"></th></tr></thead>
<tbody>
{% for r in log %}
<tr>
<td class="nowrap">{{ r.Gestart.strftime('%d-%m-%Y %H:%M') }}</td>
<td>{{ r.Door or '' }}{% if r.Manier == 'gepland' %} <span class="badge">gepland</span>{% endif %}</td>
<td><span class="badge status-{{ r.Status|replace(' ', '-') }}">{{ r.Status }}</span></td>
<td class="melding small">{{ r.Melding or '' }}{% if r.Service_ID %}<div class="muted">taak {{ r.Service_ID }}</div>{% endif %}</td>
<td class="actions">
{% if r.Service_ID %}
<form method="POST" action="{{ url_for('skore.status', log_id=r.ID) }}" style="margin:0">
<button type="submit" class="btn-sm btn-light">🔄 Status vernieuwen</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Nog geen synchronisaties.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toonVelden() {
var f = document.getElementById('frequentie').value;
document.querySelectorAll('.veld-weekdag').forEach(function (e) { e.classList.toggle('verborgen', f !== 'wekelijks'); });
document.querySelectorAll('.veld-dag').forEach(function (e) { e.classList.toggle('verborgen', f !== 'maandelijks'); });
document.querySelectorAll('.veld-tijd').forEach(function (e) { e.classList.toggle('verborgen', f === 'uit'); });
}
toonVelden();
</script>
{% endblock %}
+282
View File
@@ -0,0 +1,282 @@
{% extends "base.html" %}
{% block title %}Co-accounts{% endblock %}
{% block page_title %}Co-accounts{% endblock %}
{% block extra_css %}
<style>
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat { padding: 18px 20px; }
.stat + .stat { margin-top: 0; }
.stat .val { font-size: 28px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .lbl { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; font-weight: 600; margin-top: 4px; }
.stat .sub { font-size: 13px; color: var(--text-muted); }
.sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.progress { flex: 1 1 200px; height: 8px; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; width: 0; background: var(--primary-color); transition: width .4s; }
.filterbalk { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
.filterbalk input[type=search] { flex: 1 1 280px; }
.filterbalk label.check-box { margin: 0; }
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; }
.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; }
td.nowrap { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="stats">
<div class="card stat">
<div class="val">{{ nooit }}</div>
<div class="lbl">Nog nooit aangemeld</div>
{% if rows %}<div class="sub">van {{ rows|length }} co-accounts</div>{% endif %}
</div>
<div class="card stat">
<div class="val">{{ leerlingen }}</div>
<div class="lbl">Leerlingen met co-accounts</div>
</div>
<div class="card stat">
<div class="val" style="font-size:18px; padding-top:6px">{% if laatst %}{{ laatst.strftime('%d-%m-%Y %H:%M') }}{% else %}Nog nooit{% endif %}</div>
<div class="lbl">Opgehaald uit Smartschool</div>
<div class="sub">Automatisch één keer per dag</div>
</div>
</div>
{% if fout %}<div class="flash-message error"><span>{{ fout }}</span></div>{% endif %}
{% if not ingesteld %}
<div class="alert alert-warning">Smartschool is nog niet ingesteld voor deze school (🔌 Koppelingen › Smartschool).</div>
{% endif %}
<div class="card">
<div class="sync-bar">
<form method="POST" action="{{ url_for('smartschool.coaccount_ophalen') }}" style="margin:0">
<button type="submit" class="btn-secondary" id="syncBtn" {% if status.bezig or not ingesteld %}disabled{% endif %}>🔄 Nu ophalen uit Smartschool</button>
</form>
{% if rows %}<a class="btn btn-light" id="exportBtn" href="{{ url_for('smartschool.coaccount_export', nooit=1) }}">⬇️ CSV-export</a>{% endif %}
<div class="progress {% if not status.bezig %}hidden{% endif %}" id="syncProgress"><span id="syncProgressBar"></span></div>
<span class="muted small" id="syncText">
{% if status.bezig %}Bezig met ophalen…{% elif status.fout %}⚠️ {{ status.fout }}{% elif status.resultaat %}{{ status.resultaat }}{% endif %}
</span>
</div>
</div>
<div class="card">
<div class="filterbalk">
<input type="search" id="zoek" placeholder="Zoek op leerling, klas of co-account..." aria-label="Zoeken">
<label class="check-box"><input type="checkbox" id="enkelNooit" checked style="width:auto"> <span>Enkel nog nooit aangemeld</span></label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Klas <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">Leerling <span class="pijl"></span></th>
<th class="sorteer" data-kol="2">Co-account <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">Type <span class="pijl"></span></th>
<th class="sorteer" data-kol="4">E-mail <span class="pijl"></span></th>
<th class="sorteer" data-kol="5">Laatste aanmelding <span class="pijl"></span></th>
<th class="actions">Actie</th>
</tr>
</thead>
<tbody id="coBody">
{% for r in rows %}
<tr data-nooit="{{ '1' if not r.Laatste_login else '0' }}"
data-zoek="{{ [r.Klas, r.Leerling_voornaam, r.Leerling_naam, r.Gebruikersnaam, r.Internnummer, r.Voornaam, r.Naam, r.Email]|select|join(' ')|lower }}">
<td class="nowrap" data-sort="{{ r.Klas or '' }}">{{ r.Klas or '–' }}</td>
<td data-sort="{{ (r.Leerling_naam or '') ~ ' ' ~ (r.Leerling_voornaam or '') }}"><strong>{{ r.Leerling_naam }}</strong> {{ r.Leerling_voornaam }}</td>
<td data-sort="{{ (r.Naam or '') ~ ' ' ~ (r.Voornaam or '') }}">{{ r.Voornaam }} {{ r.Naam }}</td>
<td data-sort="{{ r.Type or '' }}">{{ r.Type or '' }}</td>
<td data-sort="{{ r.Email or '' }}">{{ r.Email or '' }}</td>
<td class="nowrap" data-sort="{{ r.Laatste_login.isoformat() if r.Laatste_login else '' }}">
{% if r.Laatste_login %}{{ r.Laatste_login.strftime('%d-%m-%Y %H:%M') }}{% else %}<span class="badge badge-warning">Nooit</span>{% endif %}
</td>
<td class="actions">
<button type="button" class="btn-email btn-sm"
data-voornaam="{{ r.Voornaam or '' }}" data-naam="{{ r.Naam or '' }}" data-email="{{ r.Email or '' }}"
onclick="openEmailModal(this.dataset.voornaam, this.dataset.naam, this.dataset.email)">✉️ Bericht opstellen</button>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="7">{% if status.bezig %}De co-accounts worden opgehaald uit Smartschool…{% else %}Nog geen co-accounts opgehaald.{% endif %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="teller"></div>
<span id="perPagina"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
</div>
{% endblock %}
{% block modals %}
<div id="emailModal" class="modal">
<div class="modal-content wide">
<span class="close" onclick="closeModal()">&times;</span>
<h3>E-mailherinnering bewerken</h3>
<p>Ontvanger: <strong id="displayEmail"></strong></p>
<div class="field">
<label for="emailSubject">Onderwerp</label>
<input type="text" id="emailSubject">
</div>
<div class="field">
<label for="emailBody">Bericht</label>
<textarea id="emailBody" style="min-height: 220px;"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="closeModal()">Annuleren</button>
<button type="button" class="btn-email" id="confirmSendBtn" onclick="executeSend()">✉️ Nu verzenden</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentTargetData = {};
function openEmailModal(voornaam, naam, email) {
if (!email || email.trim() === "" || email === "None") {
alert("Dit co-account heeft geen e-mailadres ingesteld in Smartschool.");
return;
}
currentTargetData = { voornaam, naam, email };
document.getElementById('displayEmail').innerText = email;
document.getElementById('emailSubject').value = "Belangrijk: Uw Smartschool co-account";
document.getElementById('emailBody').value = `Beste ${voornaam} ${naam},\n\nWe merkten dat u al enige tijd niet meer bent ingelogd op uw Smartschool co-account. \n\nVia dit account kunt u de resultaten, agenda en afwezigheden van uw kind op de voet volgen. Wij raden u aan om regelmatig in te loggen om op de hoogte te blijven.\n\nIndien u problemen heeft met inloggen, kunt u contact opnemen met de ICT-dienst van de school.\n\nMet vriendelijke groeten,\nHet schoolteam`;
document.getElementById('emailModal').style.display = 'block';
}
function closeModal() { document.getElementById('emailModal').style.display = 'none'; }
window.onclick = function (event) {
if (event.target == document.getElementById('emailModal')) closeModal();
};
function executeSend() {
const subject = document.getElementById('emailSubject').value;
const message = document.getElementById('emailBody').value;
if (!subject || !message) {
alert("Onderwerp en bericht mogen niet leeg zijn.");
return;
}
showLoader();
fetch("{{ url_for('smartschool.send_reminder') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: currentTargetData.email,
voornaam: currentTargetData.voornaam,
naam: currentTargetData.naam,
subject: subject,
message: message
})
})
.then(async response => {
const isJson = response.headers.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
if (!response.ok) throw new Error((data && data.message) || response.statusText);
return data;
})
.then(() => {
hideLoader();
closeModal();
alert("E-mail succesvol verzonden!");
})
.catch(error => {
hideLoader();
console.error("Fetch error:", error);
alert("Er is een fout opgetreden: " + error.message);
});
}
// Zoeken, filter "nooit aangemeld", sorteren en pagineren in de browser
(function () {
const body = document.getElementById('coBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('coaccounts');
let pagina = 1, kol = null, op = true, zoek = '';
const nooit = document.getElementById('enkelNooit');
const zichtbaar = () => Array.from(body.rows).filter(tr =>
(!nooit.checked || tr.dataset.nooit === '1') && (!zoek || tr.dataset.zoek.includes(zoek)));
function toon() {
const exp = document.getElementById('exportBtn');
if (exp) exp.href = "{{ url_for('smartschool.coaccount_export') }}?" + new URLSearchParams({nooit: nooit.checked ? '1' : '0', zoek});
const rijen = zichtbaar();
const grootte = PER_PAGINA || Infinity;
const 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} co-account(s)` : 'Geen co-accounts gevonden';
const nav = document.getElementById('paginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); body.closest('.table-wrap').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
nooit.addEventListener('change', () => { pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : (k !== 5); // laatste aanmelding: eerst de recentste
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'), 'coaccounts', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
// Voortgang van een lopende ophaling volgen; daarna de pagina vernieuwen
(function () {
const box = document.getElementById('syncProgress'), bar = document.getElementById('syncProgressBar');
const txt = document.getElementById('syncText'), btn = document.getElementById('syncBtn');
let bezig = {{ 'true' if status.bezig else 'false' }};
function tick() {
fetch("{{ url_for('smartschool.coaccount_status') }}").then(r => r.json()).then(s => {
if (s.bezig) {
box.classList.remove('hidden'); btn.disabled = true;
bar.style.width = (s.totaal ? Math.round(s.voortgang / s.totaal * 100) : 0) + '%';
txt.textContent = s.totaal ? `Bezig: ${s.voortgang} van ${s.totaal}…` : 'Bezig met ophalen…';
setTimeout(tick, 2000);
} else {
window.location.reload();
}
}).catch(() => {});
}
if (bezig) tick();
})();
</script>
{% endblock %}
+180
View File
@@ -0,0 +1,180 @@
{% extends "base.html" %}
{% block title %}Smartschool beheer{% endblock %}
{% block page_title %}Smartschool beheer{% endblock %}
{% block main_class %}medium{% endblock %}
{% block extra_css %}
<style>
#coAccountModal { z-index: 3000; }
#passwordModal { z-index: 4000; } /* reset-venster altijd bovenop de co-accountlijst */
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head"><h2>🏫 Wachtwoord &amp; info</h2></div>
<form class="search-bar" method="POST" action="{{ url_for('/ss.smartschool') }}" onsubmit="showLoader()">
<input type="text" name="query" placeholder="Zoek op naam, voornaam, gebruikersnaam of internnummer..." value="{{ query or '' }}">
<button type="submit" class="btn-search">🔍 Zoeken</button>
</form>
{% if results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Internnummer</th>
<th>Naam</th>
<th>Gebruikersnaam</th>
<th class="actions">Acties</th>
</tr>
</thead>
<tbody>
{% for user in results %}
<tr>
<td><strong>{{ user.internnummer }}</strong></td>
<td>{{ user.voornaam }} {{ user.naam }}</td>
<td>{{ user.gebruikersnaam }}</td>
<td class="actions">
<button type="button" class="btn-danger btn-sm" onclick="openResetModal('{{ user.internnummer }}', '{{ user.voornaam }} {{ user.naam }}', 0)">🔑 Reset WW</button>
<button type="button" class="btn-secondary btn-sm" onclick="viewCoAccounts('{{ user.gebruikersnaam }}')">👪 Co-accounts</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif query %}
<p class="muted">Geen resultaten gevonden voor "{{ query }}".</p>
<p class="muted small">Op een deel van een naam zoekt PIVOT in de leerlingenlijst. Staat die nog leeg,
haal dan eerst de leerlingen op via Leerlingen › Informat &amp; SS sync, of zoek op het volledige internnummer of de gebruikersnaam.</p>
{% endif %}
</div>
{% endblock %}
{% block modals %}
<div id="passwordModal" class="modal-overlay">
<div class="modal-content">
<span class="close" onclick="closeModal('passwordModal')">&times;</span>
<h3>Wachtwoord reset</h3>
<p id="displayUser" style="font-weight:600; color: var(--primary-color);"></p>
<div class="field">
<label for="modalPasswordInput">Nieuw wachtwoord</label>
<div class="input-group">
<input type="text" id="modalPasswordInput" placeholder="Nieuw wachtwoord">
<button type="button" class="btn-light" title="Willekeurig genereren" onclick="generateRandomPass()">🎲</button>
</div>
</div>
<label class="check-box">
<input type="checkbox" id="pdfCheckbox"> PDF genereren voor deze persoon
</label>
<div class="modal-footer">
<button type="button" class="btn-ghost" onclick="closeModal('passwordModal')">Annuleren</button>
<button type="button" onclick="submitPasswordReset()">Bevestig reset</button>
</div>
</div>
</div>
<div id="coAccountModal" class="modal-overlay">
<div class="modal-content wide">
<span class="close" onclick="closeModal('coAccountModal')">&times;</span>
<h3>Gekoppelde co-accounts</h3>
<div id="coAccountList"></div>
<div class="modal-footer">
<button type="button" onclick="closeModal('coAccountModal')">Sluiten</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentTargetUser = "";
let currentResetType = 0;
let currentTargetName = "";
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
function openResetModal(identifier, fullname, accType = 0) {
currentTargetUser = identifier;
currentResetType = accType;
currentTargetName = fullname;
document.getElementById('displayUser').innerText = fullname + (accType === 0 ? " (Leerling)" : " (Co-account)");
document.getElementById('modalPasswordInput').value = "";
document.getElementById('pdfCheckbox').checked = false;
document.getElementById('passwordModal').style.display = 'flex';
}
function generateRandomPass() {
const chars = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
let pass = "";
for (let i = 0; i < 10; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById('modalPasswordInput').value = pass;
}
function submitPasswordReset() {
const newPass = document.getElementById('modalPasswordInput').value;
const genPdf = document.getElementById('pdfCheckbox').checked;
if (!newPass) return alert("Geen wachtwoord ingevuld!");
showLoader();
fetch("{{ url_for('/ss.smartschool_reset') }}", {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `username=${encodeURIComponent(currentTargetUser)}&new_password=${encodeURIComponent(newPass)}&account_type=${currentResetType}&generate_pdf=${genPdf}&display_name=${encodeURIComponent(currentTargetName)}`
})
.then(res => {
const contentType = res.headers.get("content-type");
if (contentType && contentType.includes("application/pdf")) {
return res.blob().then(blob => ({ type: 'pdf', data: blob }));
}
return res.json().then(data => ({ type: 'json', data: data }));
})
.then(result => {
hideLoader();
if (result.type === 'pdf') {
const url = window.URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = `Account_${currentTargetName}.pdf`;
document.body.appendChild(a);
a.click();
closeModal('passwordModal');
} else if (result.data.status === "success") {
alert("Wachtwoord succesvol aangepast!");
closeModal('passwordModal');
} else {
alert("Fout: " + result.data.message);
}
}).catch(() => { hideLoader(); alert("Fout bij verbinding."); });
}
function viewCoAccounts(username) {
showLoader();
fetch("{{ url_for('/ss.smartschool_coaccounts') }}", {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `username=${encodeURIComponent(username)}`
})
.then(res => res.json())
.then(data => {
hideLoader();
let html = '<div class="table-wrap"><table><thead><tr><th>Relatie</th><th>Naam</th><th class="actions">Actie</th></tr></thead><tbody>';
data.accounts.forEach(acc => {
const fullName = `${acc.voornaam} ${acc.naam}`;
html += `<tr>
<td>${acc.type}</td>
<td>${fullName}</td>
<td class="actions"><button type="button" class="btn-danger btn-sm" onclick="openResetModal('${acc.parent_internnummer}', '${fullName}', ${acc.index})">🔑 Reset</button></td>
</tr>`;
});
html += '</tbody></table></div>';
document.getElementById('coAccountList').innerHTML = html;
document.getElementById('coAccountModal').style.display = 'flex';
})
.catch(() => { hideLoader(); alert("Fout bij ophalen co-accounts."); });
}
</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Helvetica, Arial, sans-serif; color: #333; line-height: 1.5; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #2c3e50; margin-bottom: 20px; }
.content { margin-bottom: 30px; }
.account-box { background: #f2f2f2; border: 1px solid #ccc; padding: 15px; margin: 20px 0; }
/* Layout voor de handtekeningen */
.signature-table { width: 100%; margin-top: 40px; border: none; }
.signature-cell { width: 50%; vertical-align: top; border: none; }
.footer-text { margin-top: 20px; font-style: italic; }
</style>
</head>
<body>
<div class="header">
<h1>Nieuw Google Account</h1>
</div>
<div class="content">
<p>Beste <strong>{{ naam }}</strong>,</p>
<p>Je account voor de schoolomgeving is aangemaakt op {{ datum }}.</p>
<div class="account-box">
<p><strong>E-mailadres:</strong> {{ email }}</p>
<p><strong>Tijdelijk wachtwoord:</strong> {{ wachtwoord }}</p>
</div>
<p>Hiermee kan je gebruikmaken van alle schoolgebonden google toepassingen (Drive, Gmail, Classroom, Agenda...).</p>
<p><i>Let op: Je zult gevraagd worden om je wachtwoord te wijzigen bij de eerste keer inloggen.</i></p>
<p class="footer-text">Heb je nog vragen, spring gerust binnen of mail me je vraag!</p>
</div>
<p>Met vriendelijke groeten,</p>
<table class="signature-table">
<tr>
<td class="signature-cell">
<strong>K. Verbruggen</strong><br>
ICT coördinator<br>
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>
</td>
<td class="signature-cell">
<strong>B. Elewaut</strong><br>
Directeur<br>
<a href="mailto:directie@kamkersem.be">directie@kamkersem.be</a>
</td>
</tr>
</table>
</body>
</html>
+416
View File
@@ -0,0 +1,416 @@
{% extends "base.html" %}
{% block title %}Chromebooks{% endblock %}
{% block page_title %}Chromebooks{% endblock %}
{% block main_class %}wide{% endblock %}
{% block extra_css %}
<style>
/* Knoppen naast elkaar; enkel op een smal scherm lopen ze door naar een tweede regel */
.knoppen { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; min-width: 200px; }
.knoppen > button { white-space: nowrap; margin: 0; }
/* Garantie en Opmerking zijn standaard verborgen */
.col-garantie, .col-opmerking { display: none; }
.toggle-garantie-label, .toggle-opmerking-label { display: none !important; }
.toggle-garantie-label.shown, .toggle-opmerking-label.shown { display: inline-flex !important; }
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; }
.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; }
[hidden] { display: none !important; }
.blok-badge { margin-left: 4px; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="page-head">
<h2>💻 Alle toestellen</h2>
<form method="POST" action="{{ url_for('devices.google_import') }}" style="margin:0"
onsubmit="return confirm('Alle Chromebooks uit Google Admin ophalen? Nieuwe serienummers worden toegevoegd, ontbrekende modellen aangevuld. Er wordt niets verwijderd of overschreven.') && (showLoader('Chromebooks ophalen bij Google...'), true)">
<button type="submit" class="btn-sm btn-secondary">🔄 Chromebooks ophalen uit Google</button>
</form>
</div>
<form class="search-bar" method="POST" action="{{ url_for('devices.inventaris_toestellen') }}">
<input type="text" name="query" placeholder="Zoek op toestelnummer, serienummer, ..." value="{{ query or '' }}">
<button type="submit">🔍 Zoeken</button>
</form>
{% if message %}
<div class="alert">{{ message }}</div>
{% endif %}
{% if results %}
<div class="column-toggles" id="column-toggles">
<label><input type="checkbox" id="toggle-kamxm-nummer" checked onchange="toggleColumn('col-kamxm-nummer')"> Toestelnummer</label>
<label><input type="checkbox" id="toggle-signpostnummer" checked onchange="toggleColumn('col-signpostnummer')"> Signpostnummer</label>
<label><input type="checkbox" id="toggle-serienummer" checked onchange="toggleColumn('col-serienummer')"> Serienummer</label>
<label><input type="checkbox" id="toggle-type" checked onchange="toggleColumn('col-type')"> Type</label>
<label class="toggle-garantie-label"><input type="checkbox" id="toggle-garantie" onchange="toggleColumn('col-garantie')"> Garantie</label>
<label class="toggle-opmerking-label"><input type="checkbox" id="toggle-opmerking" onchange="toggleColumn('col-opmerking')"> Opmerking</label>
<label><input type="checkbox" id="toggle-status" checked onchange="toggleColumn('col-status')"> Status</label>
<label><input type="checkbox" id="toggle-bewerken" checked onchange="toggleColumn('col-bewerken')"> Bewerken</label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-kamxm-nummer sorteer" data-kol="0">Toestelnummer <span class="pijl"></span></th>
<th class="col-signpostnummer sorteer" data-kol="1">Signpostnummer <span class="pijl"></span></th>
<th class="col-serienummer sorteer" data-kol="2">Serienummer <span class="pijl"></span></th>
<th class="col-type sorteer" data-kol="3">Type <span class="pijl"></span></th>
<th class="col-garantie sorteer" data-kol="4">Garantie <span class="pijl"></span></th>
<th class="col-opmerking sorteer" data-kol="5">Opmerking <span class="pijl"></span></th>
<th class="col-status sorteer" data-kol="6">Status <span class="pijl"></span></th>
<th class="actions" aria-label="Acties"></th>
<th class="col-bewerken actions" aria-label="Bewerken"></th>
</tr>
</thead>
<tbody id="toestelBody">
{% for row in results %}
{% set g = (row['Garantie'] or '').split('-') %}
<tr>
<td class="col-kamxm-nummer" data-sort="{{ row['KAMXM_nummer'] or '' }}"><strong>{{ row['KAMXM_nummer'] or '' }}</strong></td>
<td class="col-signpostnummer" data-sort="{{ row['Signpostnummer'] or '' }}">{{ row['Signpostnummer'] or '' }}</td>
<td class="col-serienummer" data-sort="{{ row['Serienummer'] or '' }}">{{ row['Serienummer'] or '' }}</td>
<td class="col-type" data-sort="{{ row['Type'] or '' }}">{{ row['Type'] or '' }}</td>
<td class="col-garantie" data-sort="{{ (g[2] ~ '-' ~ g[1] ~ '-' ~ g[0]) if g|length == 3 else '' }}">{{ row['Garantie'] or '' }}</td>
<td class="col-opmerking" data-sort="{{ row['Opmerking'] or '' }}">{{ row['Opmerking'] or '' }}</td>
<td class="col-status" data-sort="{{ row['Status'] or 'Beschikbaar' }}">
{% if row['Status'] == 'Defect' %}<span class="badge badge-danger">🛠️ Defect</span>
{% elif row['Status'] == 'Uitgeleend' %}<span class="badge badge-info">🧑‍💻 Uitgeleend</span>
{% else %}<span class="badge badge-success">✅ Beschikbaar</span>
{% endif %}
<span class="badge badge-danger blok-badge" {{ '' if row['Geblokkeerd']|int == 1 else 'hidden' }}>🔒 Geblokkeerd</span>
</td>
<td class="actions">
<span class="knoppen">
<button type="button" class="btn-sm" onclick="openStatusModal('{{ row['Toestel_ID'] }}', '{{ query }}', '{{ row['KAMXM_nummer'] or row['Serienummer'] or '' }}')">Wijzig status</button>
{% if mag('toestel_blokkeren') and row['Serienummer'] %}
{% set blok = row['Geblokkeerd']|int == 1 %}
<button type="button" class="btn-sm blok-knop {{ 'btn-success' if blok else 'btn-danger' }}"
data-id="{{ row['Toestel_ID'] }}" data-label="{{ row['KAMXM_nummer'] or row['Serienummer'] }}"
data-geblokkeerd="{{ 1 if blok else 0 }}" onclick="wisselBlokkering(this)">{{ '🔓 Deblokkeren' if blok else '🔒 Blokkeren' }}</button>
{% endif %}
{% if row['Serienummer'] %}
<button type="button" class="btn-sm btn-light" title="Wie gebruikte dit toestel? (Google Admin)"
data-serie="{{ row['Serienummer'] }}" data-label="{{ row['KAMXM_nummer'] or row['Serienummer'] }}"
onclick="openGoogleInfo(this)">👤 Gebruik</button>
{% endif %}
<button type="button" class="btn-sm btn-light" title="Welke leerlingen hadden dit toestel?"
data-id="{{ row['Toestel_ID'] }}" data-label="{{ row['KAMXM_nummer'] or row['Serienummer'] or '' }}"
onclick="openToestelHistorie(this)">🕑 Historie</button>
</span>
</td>
<td class="col-bewerken actions">
<button type="button" class="btn-bewerk btn-sm"
data-kamxm-key="{{ row['Toestel_ID'] }}"
data-serienummer="{{ row['Serienummer'] or '' }}"
data-kamxm="{{ row['KAMXM_nummer'] or '' }}"
data-signpost="{{ row['Signpostnummer'] or '' }}"
data-type="{{ row['Type'] or '' }}"
data-garantie="{{ row['Garantie'] or '' }}"
data-opmerking="{{ row['Opmerking'] or '' }}"
onclick="openEditModalFromButton(this)">✏️ Bewerken</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="toestelTeller"></div>
<span id="toestellenPerPagina"></span>
</div>
<nav class="paginering" id="toestelPaginering" aria-label="Pagina's"></nav>
</div>
{% elif query %}
<p class="muted">Geen resultaten gevonden voor "{{ query }}".</p>
{% endif %}
</div>
{% endblock %}
{% block modals %}
<div id="statusModal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('statusModal').style.display='none'">&times;</span>
<h3 id="modalTitle">Status wijzigen: <span id="modalkamxm_nummer"></span></h3>
<form id="statusForm" method="POST" action="{{ url_for('devices.wijzig_status') }}">
<input type="hidden" name="toestel_id" id="modalHiddenkamxm_nummer">
<input type="hidden" name="query" id="modalHiddenQuery" value="{{ query or '' }}">
<input type="hidden" name="new_status" id="modalHiddenStatus">
<button type="button" class="status-button status-success" id="btn-beschikbaar" onclick="submitStatus('Beschikbaar')">✅ Beschikbaar</button>
<button type="button" class="status-button status-info" id="btn-uitgeleend" onclick="submitStatus('Uitgeleend')">🧑‍💻 Uitgeleend</button>
<button type="button" class="status-button status-danger" id="btn-defect" onclick="submitStatus('Defect')">🛠️ Defect</button>
</form>
</div>
</div>
<div id="googleInfoModal" class="modal">
<div class="modal-content wide">
<span class="close" onclick="document.getElementById('googleInfoModal').style.display='none'">&times;</span>
<h3>👤 Gebruik van toestel <span id="giTitel"></span></h3>
<div id="giInhoud"><p class="muted">Gegevens ophalen bij Google…</p></div>
</div>
</div>
<div id="historieModal" class="modal">
<div class="modal-content wide">
<span class="close" onclick="document.getElementById('historieModal').style.display='none'">&times;</span>
<h3>🕑 Eigenaars van toestel <span id="thTitel"></span></h3>
<div id="thInhoud"><p class="muted">Laden...</p></div>
</div>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<span class="close" onclick="document.getElementById('editModal').style.display='none'">&times;</span>
<h3>Bewerk toestel: <span id="editModalkamxm_nummerTitle"></span></h3>
<form id="editForm" method="POST" action="{{ url_for('devices.bewerk_toestel') }}">
<input type="hidden" name="toestel_id" id="editHiddenkamxm_nummerKey">
<input type="hidden" name="query" id="editHiddenQuery" value="{{ query or '' }}">
<div class="field"><label>Toestelnummer</label><input type="text" name="schooleigen_id" id="edit_kamxm_nummer_display"></div>
<div class="field"><label>Serienummer</label><input type="text" name="serienummer" id="edit_serienummer"></div>
<div class="field"><label>Signpostnummer</label><input type="text" name="signpostnummer" id="edit_signpostnummer"></div>
<div class="field"><label>Type</label><input type="text" name="type" id="edit_type"></div>
<div class="field"><label>Garantie tot</label><input type="text" name="garantie" id="edit_garantie" placeholder="dd-mm-jjjj"></div>
<div class="field"><label>Opmerking</label><textarea name="opmerking" id="edit_opmerking"></textarea></div>
<button type="submit" class="btn-block">💾 Gegevens opslaan</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
#giInhoud h4 { margin: 16px 0 6px; font-size: .95rem; }
#giInhoud table { width: 100%; border-collapse: collapse; font-size: .92rem; }
#giInhoud td, #giInhoud th { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
.gi-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 8px 16px; font-size: .9rem; }
.gi-meta div span { display: block; color: var(--text-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; }
.gi-balk { display: inline-block; height: 8px; border-radius: 4px; background: var(--primary-color); vertical-align: middle; margin-right: 6px; }
</style>
<script>
function giEsc(t) { return String(t ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function giDatum(iso) {
if (!iso) return 'onbekend';
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString('nl-BE', { dateStyle: 'short', timeStyle: 'short' });
}
function giDuur(min) { return min >= 60 ? `${Math.floor(min / 60)} u ${String(min % 60).padStart(2, '0')}` : `${min} min`; }
const TOESTEL_HISTORIE_URL = {{ url_for('devices.toestel_historie', toestel_id=0)|tojson }}.replace(/0$/, '');
function thDuur(dagen) {
if (dagen == null) return '';
if (dagen < 31) return dagen + ' dag' + (dagen === 1 ? '' : 'en');
const m = Math.round(dagen / 30.44);
return m < 12 ? m + ' maand' + (m === 1 ? '' : 'en') : (dagen / 365.25).toFixed(1).replace('.', ',') + ' jaar';
}
function openToestelHistorie(knop) {
const venster = document.getElementById('historieModal'), inhoud = document.getElementById('thInhoud');
document.getElementById('thTitel').textContent = knop.dataset.label;
inhoud.innerHTML = '<p class="muted">Laden...</p>';
venster.style.display = 'block';
fetch(TOESTEL_HISTORIE_URL + encodeURIComponent(knop.dataset.id), { credentials: 'same-origin' })
.then(r => r.json()).then(res => {
const e = res.eigenaars || [];
if (!e.length) { inhoud.innerHTML = '<p class="muted">Dit toestel werd nog nooit aan een leerling uitgedeeld.</p>'; return; }
inhoud.innerHTML = `<p class="muted small">${e.length} eigenaar(s) in totaal</p>
<div class="table-wrap"><table><thead><tr><th>Internnummer</th><th>Leerling</th><th>Gebruikersnaam</th>
<th>Gekregen</th><th>Ingeleverd</th><th>Duur</th></tr></thead><tbody>`
+ e.map(x => `<tr${x.huidig ? ' style="background:var(--primary-light)"' : ''}>
<td><strong>${giEsc(x.internnummer)}</strong></td><td>${giEsc(x.naam)}</td><td>${giEsc(x.gebruikersnaam)}</td>
<td style="white-space:nowrap">${giEsc(x.van)}</td>
<td style="white-space:nowrap">${x.huidig ? '<span class="badge badge-success">nu in gebruik</span>' : giEsc(x.tot)}${x.geblokkeerd && x.huidig ? ' <span class="badge badge-danger">🔒</span>' : ''}</td>
<td style="white-space:nowrap">${thDuur(x.dagen)}</td></tr>`).join('')
+ '</tbody></table></div>';
}).catch(() => { inhoud.innerHTML = '<div class="alert alert-danger">Kon de historie niet ophalen.</div>'; });
}
window.addEventListener('click', e => { if (e.target.id === 'historieModal') e.target.style.display = 'none'; });
// Blokkeren / deblokkeren in Google zonder de pagina te herladen (zoekopdracht, sortering en pagina blijven)
const BLOKKEER_URL = {{ url_for('devices.toestel_blokkeren')|tojson }};
function wisselBlokkering(knop) {
const blokkeren = knop.dataset.geblokkeerd !== '1';
const vraag = blokkeren
? `Chromebook ${knop.dataset.label} blokkeren in Google Admin?\nDe leerling kan het toestel dan niet meer gebruiken.`
: `Chromebook ${knop.dataset.label} deblokkeren in Google Admin?`;
if (!confirm(vraag)) return;
const tekst = knop.textContent;
knop.disabled = true; knop.textContent = '⏳';
const data = new FormData();
data.append('toestel_id', knop.dataset.id);
data.append('actie', blokkeren ? 'blokkeren' : 'deblokkeren');
fetch(BLOKKEER_URL, { method: 'POST', body: data, credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
.then(r => r.json()).then(res => {
knop.disabled = false;
if (!res.ok) { knop.textContent = tekst; alert(res.melding || 'Mislukt.'); return; }
const nu = !!res.geblokkeerd;
knop.dataset.geblokkeerd = nu ? '1' : '0';
knop.textContent = nu ? '🔓 Deblokkeren' : '🔒 Blokkeren';
knop.classList.toggle('btn-success', nu); knop.classList.toggle('btn-danger', !nu);
const badge = knop.closest('tr').querySelector('.blok-badge');
if (badge) badge.hidden = !nu;
}).catch(() => { knop.disabled = false; knop.textContent = tekst; alert('Er ging iets mis; probeer opnieuw.'); });
}
function openGoogleInfo(knop) {
document.getElementById('giTitel').textContent = knop.dataset.label;
const doel = document.getElementById('giInhoud');
doel.innerHTML = '<p class="muted">Gegevens ophalen bij Google…</p>';
document.getElementById('googleInfoModal').style.display = 'block';
fetch("{{ url_for('devices.google_info') }}?serie=" + encodeURIComponent(knop.dataset.serie), { credentials: 'same-origin' })
.then(r => r.json())
.then(res => {
if (!res.ok) { doel.innerHTML = `<div class="alert alert-danger">${giEsc(res.fout)}</div>`; return; }
const i = res.info;
let h = `<div class="gi-meta">
<div><span>Laatste synchronisatie</span>${giEsc(giDatum(i.laatste_sync))}</div>
<div><span>Model</span>${giEsc(i.model || '–')}</div>
<div><span>Organisatie-eenheid</span>${giEsc(i.ou || '–')}</div>
<div><span>Status in Google</span>${giEsc({ACTIVE: 'Actief', DISABLED: 'Geblokkeerd', DEPROVISIONED: 'Uitgeschreven'}[i.status] || i.status || '–')}</div>
</div>`;
h += '<h4>Recente gebruikers <span class="muted small">(meest recent bovenaan)</span></h4>';
if (i.gebruikers.length) {
h += '<table><tbody>' + i.gebruikers.map((g, n) => `<tr>
<td style="width:28px" class="muted">${n + 1}</td>
<td>${g.beheerd ? giEsc(g.email) : '<span class="muted">niet-beheerd account (gast of extern)</span>'}</td>
<td>${giEsc(g.naam || '')}</td></tr>`).join('') + '</tbody></table>';
} else {
h += '<p class="muted">Google geeft geen gebruikers terug. Staat de gebruikersrapportage aan voor deze OU '
+ '(Google Admin › Apparaten › Chrome › Instellingen › Apparaat)?</p>';
}
h += '<h4>Actieve tijd per dag</h4>';
if (i.actief.length) {
const max = Math.max(...i.actief.map(a => a.minuten), 1);
h += '<table><tbody>' + i.actief.map(a => `<tr>
<td style="width:110px">${giEsc(new Date(a.datum).toLocaleDateString('nl-BE', { weekday: 'short', day: '2-digit', month: '2-digit' }))}</td>
<td><span class="gi-balk" style="width:${Math.round(a.minuten / max * 220)}px"></span>${giDuur(a.minuten)}</td></tr>`).join('') + '</tbody></table>';
} else {
h += '<p class="muted">Geen gegevens over actieve tijd.</p>';
}
doel.innerHTML = h;
})
.catch(e => { doel.innerHTML = `<div class="alert alert-danger">Ophalen mislukt: ${giEsc(e)}</div>`; });
}
// Sorteren (klik op kolomkop) en 10 toestellen per pagina — alles in de browser
(function () {
const body = document.getElementById('toestelBody');
if (!body) return;
let PER_PAGINA = perPagina('toestellen');
const alle = Array.from(body.rows);
let pagina = 1, kol = null, op = true;
function toon() {
const grootte = PER_PAGINA || Infinity;
const paginas = Math.max(1, Math.ceil(alle.length / grootte));
pagina = Math.min(Math.max(1, pagina), paginas);
const van = PER_PAGINA ? (pagina - 1) * PER_PAGINA : 0, tot = van + grootte; // 0 = alles
Array.from(body.rows).forEach((tr, i) => tr.style.display = (i >= van && i < tot) ? '' : 'none');
document.getElementById('toestelTeller').textContent =
`${van + 1}–${Math.min(tot, alle.length)} van ${alle.length} toestel(len)`;
const nav = document.getElementById('toestelPaginering');
nav.innerHTML = '';
if (paginas <= 1) return;
const knop = (label, p, actief, titel) => {
const b = document.createElement('button');
b.type = 'button'; b.textContent = label; b.className = 'btn-sm ' + (actief ? 'btn-secondary' : 'btn-light');
if (titel) b.title = titel;
b.onclick = () => { pagina = p; toon(); body.closest('.table-wrap').scrollIntoView({ block: 'start' }); };
nav.appendChild(b);
};
if (pagina > 1) { knop('«', 1, false, 'Eerste pagina'); 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, false, 'Laatste pagina'); }
const info = document.createElement('span');
info.className = 'muted small'; info.textContent = `pagina ${pagina} van ${paginas}`;
nav.appendChild(info);
}
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : true; kol = k;
document.querySelectorAll('th.sorteer').forEach(x => x.classList.remove('op', 'af'));
th.classList.add(op ? 'op' : 'af');
const rijen = Array.from(body.rows);
rijen.sort((a, b) => {
const x = a.cells[k].dataset.sort || '', y = b.cells[k].dataset.sort || '';
if (!x !== !y) return x ? -1 : 1; // lege waarden altijd onderaan
const c = x.localeCompare(y, 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
});
rijen.forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(document.getElementById('toestellenPerPagina'), 'toestellen', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
function openStatusModal(toestelId, currentQuery, label) {
document.getElementById('modalkamxm_nummer').textContent = label || toestelId;
document.getElementById('modalHiddenkamxm_nummer').value = toestelId;
document.getElementById('modalHiddenQuery').value = currentQuery;
document.getElementById('statusModal').style.display = 'block';
}
function submitStatus(status) {
document.getElementById('modalHiddenStatus').value = status;
document.getElementById('statusForm').submit();
}
function openEditModalFromButton(buttonElement) {
openEditModal({
kamxm_nummer_key: buttonElement.getAttribute('data-kamxm-key'),
kamxm: buttonElement.getAttribute('data-kamxm'),
serienummer: buttonElement.getAttribute('data-serienummer'),
signpostnummer: buttonElement.getAttribute('data-signpost'),
type: buttonElement.getAttribute('data-type'),
garantie: buttonElement.getAttribute('data-garantie'),
opmerking: buttonElement.getAttribute('data-opmerking')
});
}
function openEditModal(rowData) {
document.getElementById('editModalkamxm_nummerTitle').textContent = rowData.kamxm || rowData.serienummer || '';
document.getElementById('editHiddenkamxm_nummerKey').value = rowData.kamxm_nummer_key;
document.getElementById('edit_kamxm_nummer_display').value = rowData.kamxm || '';
document.getElementById('edit_serienummer').value = rowData.serienummer || '';
document.getElementById('edit_signpostnummer').value = rowData.signpostnummer || '';
document.getElementById('edit_type').value = rowData.type || '';
document.getElementById('edit_garantie').value = rowData.garantie || '';
document.getElementById('edit_opmerking').value = rowData.opmerking || '';
document.getElementById('editModal').style.display = 'block';
makeHiddenTogglesVisible();
}
function toggleColumn(columnClass) {
const checkbox = document.getElementById('toggle-' + columnClass.replace('col-', ''));
if (!checkbox) return;
const displayStyle = checkbox.checked ? 'table-cell' : 'none';
document.querySelectorAll('.' + columnClass).forEach(cell => { cell.style.display = displayStyle; });
}
function makeHiddenTogglesVisible() {
['.toggle-garantie-label', '.toggle-opmerking-label'].forEach(selector => {
const el = document.querySelector(selector);
if (el) el.classList.add('shown');
});
}
document.addEventListener('DOMContentLoaded', () => {
['col-kamxm-nummer', 'col-signpostnummer', 'col-serienummer', 'col-type', 'col-status', 'col-bewerken']
.forEach(toggleColumn);
});
window.onclick = function (event) {
if (event.target.classList.contains('modal')) event.target.style.display = 'none';
};
</script>
{% endblock %}
+354
View File
@@ -0,0 +1,354 @@
{% extends "base.html" %}
{% block title %}2FA-hulp{% endblock %}
{% block page_title %}2FA-hulp{% endblock %}
{% block main_class %}{{ '' if tab == 'lijst' else 'medium' }}{% endblock %}
{% block extra_css %}
<style>
.user-head { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 4px; }
.avatar { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; flex-shrink: 0;
background: var(--primary-light); color: var(--primary-color); font-weight: 800; font-size: 18px; }
.user-head .who { flex: 1; min-width: 220px; }
.user-head .who strong { display: block; font-size: 1.1rem; }
.badges { display: flex; flex-wrap: wrap; gap: 6px; }
.actions-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 18px; }
@media (max-width: 760px) { .actions-grid { grid-template-columns: 1fr; } }
.action-box { border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; background: var(--surface-muted); }
.action-box h3 { margin: 0 0 4px; font-size: 1rem; }
.action-box p { font-size: 14px; color: var(--text-muted); margin: 0 0 14px; }
.action-box .btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
.code-panel { margin-top: 18px; border: 2px solid var(--success); border-radius: var(--radius-lg); background: var(--success-bg); padding: 20px; text-align: center; }
.big-code { font-family: ui-monospace, Consolas, monospace; font-size: 40px; font-weight: 800; letter-spacing: 6px; color: var(--success-text); margin: 6px 0 4px; }
.code-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin-top: 12px; }
.code-grid span { font-family: ui-monospace, Consolas, monospace; background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 6px; font-size: 15px; }
details summary { cursor: pointer; color: var(--text-muted); font-size: 14px; margin-top: 12px; }
.exception-active { margin-top: 14px; }
.pick-list { list-style: none; margin: 10px 0 0; padding: 0; }
.pick-list a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm); text-decoration: none; color: var(--text); }
.pick-list a:hover { background: var(--primary-light); }
.pick-list .ou { color: var(--text-muted); font-size: 13px; }
.setup-hint { font-size: 13px; }
/* Afdrukken: enkel het codebriefje */
@media print {
body.print-slip * { visibility: hidden; }
body.print-slip #codeSlip, body.print-slip #codeSlip * { visibility: visible; }
body.print-slip #codeSlip { position: absolute; left: 0; top: 0; width: 100%; border: 1px dashed #999; background: #fff; }
body.print-slip #codeSlip .no-print { display: none; }
}
</style>
{% endblock %}
{% block content %}
<div class="tabs">
<a class="tab-link {{ 'active' if tab != 'lijst' else '' }}" href="{{ url_for('tweestaps.overzicht') }}" style="text-decoration:none">🆘 Hulp bij inloggen</a>
<a class="tab-link {{ 'active' if tab == 'lijst' else '' }}" href="{{ url_for('tweestaps.overzicht', tab='lijst') }}" style="text-decoration:none" onclick="showLoader('Gebruikers ophalen bij Google...')">⚠️ Nog geen 2FA ingesteld</a>
</div>
{% if tab != 'lijst' %}
<section class="tab-content card active">
{# ---------- Zoeken ---------- #}
<form class="search-bar" method="GET" action="{{ url_for('tweestaps.overzicht') }}" onsubmit="showLoader()" style="margin:0">
<input type="text" name="zoek" placeholder="Naam of e-mailadres van de leerkracht" value="{{ zoek or (gebruiker.email if gebruiker else '') }}" autofocus>
<button type="submit">🔍 Zoeken</button>
</form>
{% if zoekresultaten %}
<ul class="pick-list">
{% for u in zoekresultaten %}
<li><a href="{{ url_for('tweestaps.overzicht', email=u.email) }}" onclick="showLoader()">
<span><strong>{{ u.naam }}</strong> · {{ u.email }}</span><span class="ou">{{ u.ou }}</span></a></li>
{% endfor %}
</ul>
{% endif %}
{% if fout %}<div class="alert alert-danger" style="margin-top:14px">{{ fout }}</div>{% endif %}
{# ---------- Gekozen gebruiker ---------- #}
{% if gebruiker %}
<div style="margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--border);">
<div class="user-head">
<span class="avatar">{{ (gebruiker.naam or gebruiker.email)[:1]|upper }}</span>
<div class="who">
<strong>{{ gebruiker.naam }}</strong>
<span class="muted">{{ gebruiker.email }} · {{ gebruiker.ou }}</span>
</div>
<div class="badges">
{% if gebruiker.ingesteld %}<span class="badge badge-success">✓ 2FA ingesteld</span>
{% else %}<span class="badge badge-warning">2FA niet ingesteld</span>{% endif %}
{% if uitzondering %}<span class="badge badge-info">⏳ Uitzondering tot {{ uitzondering.Tot.strftime('%H:%M') }}</span>
{% elif gebruiker.afgedwongen %}<span class="badge">Verplicht</span>{% endif %}
{% if gebruiker.geschorst %}<span class="badge badge-danger">Geschorst</span>{% endif %}
</div>
</div>
{# ---------- Codes tonen ---------- #}
{% if codes %}
<div class="code-panel" id="codeSlip">
<div class="muted small">Back-upcode voor <strong>{{ gebruiker.email }}</strong></div>
<div class="big-code">{{ codes[0][:4] }} {{ codes[0][4:] }}</div>
<div class="small">Typ deze code bij "Probeer een andere manier" › "Voer een van je back-upcodes van 8 cijfers in". Elke code werkt maar één keer.</div>
<details class="no-print">
<summary>Alle {{ codes|length }} geldige codes tonen</summary>
<div class="code-grid">{% for c in codes %}<span>{{ c[:4] }} {{ c[4:] }}</span>{% endfor %}</div>
</details>
<div class="btn-row no-print" style="justify-content:center; margin-top:14px">
<button type="button" class="btn-light btn-sm" onclick="printSlip()">🖨️ Afdrukken</button>
</div>
{% if code_actie == 'nieuw' %}<div class="small muted no-print" style="margin-top:8px">Er is een nieuwe set aangemaakt; oudere codes van deze gebruiker werken niet meer.</div>{% endif %}
</div>
{% endif %}
<div class="actions-grid">
{% if mag('tweestaps_codes') %}
<div class="action-box">
<h3>📱 Telefoon vergeten</h3>
<p>Geef een back-upcode. 2FA blijft gewoon aan; de code werkt één keer.</p>
<form method="POST" action="{{ url_for('tweestaps.codes') }}" class="btn-row" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<button type="submit" name="actie" value="tonen">🔑 Back-upcode geven</button>
<button type="submit" name="actie" value="nieuw" class="btn-light"
onclick="return confirm('Een nieuwe set maken? Alle oudere back-upcodes van deze gebruiker werken dan niet meer.')">Nieuwe set</button>
<button type="submit" name="actie" value="ongeldig" class="btn-ghost"
onclick="return confirm('Alle back-upcodes van deze gebruiker ongeldig maken?')">Codes intrekken</button>
</form>
</div>
{% endif %}
{% if mag('tweestaps_uitzetten') and reset_wacht %}
<div class="action-box">
<h3>⏳ 2FA-reset wacht op Google</h3>
<p>Gevraagd om {{ reset_wacht.Gevraagd_op.strftime('%H:%M') }} door {{ reset_wacht.Door or '?' }}.
{{ gebruiker.email }} staat al in de uitzonderingsgroep, maar Google past dat pas na enkele minuten toe.
PIVOT probeert elke minuut opnieuw (al {{ reset_wacht.Pogingen }} poging(en)), tot {{ reset_wacht.Tot.strftime('%H:%M') }}.
Je hoeft niets te doen.</p>
<p>Google meldt nu: 2FA afgedwongen voor deze gebruiker: <strong>{{ 'ja (uitzondering nog niet toegepast)' if gebruiker.afgedwongen else 'nee, de reset volgt bij de volgende poging' }}</strong>.
{% if reset_wacht.Laatste_fout %}<br><span class="muted">Laatste antwoord van Google: {{ reset_wacht.Laatste_fout }}</span>{% endif %}</p>
<p class="field-hint">Moet de gebruiker nu meteen binnen? Toon hierboven een <strong>back-upcode</strong>: daarmee logt hij in en stelt hij zijn nieuwe telefoon in via myaccount.google.com › Beveiliging › Authenticatie in 2 stappen. Dat werkt meteen, ook als 2FA afgedwongen wordt.</p>
<a class="btn-light" href="{{ url_for('tweestaps.overzicht', email=gebruiker.email) }}">🔄 Status vernieuwen</a>
</div>
{% elif mag('tweestaps_uitzetten') and gebruiker.ingesteld %}
<div class="action-box">
<h3>📵 Nieuwe telefoon: 2FA resetten</h3>
<p class="field-hint">💡 Snelste weg bij een nieuwe telefoon: geef de gebruiker een <strong>back-upcode</strong> (hierboven). Hij logt daarmee in en koppelt zelf zijn nieuwe telefoon. Resetten is pas nodig als dat niet lukt.</p>
<p>Zet 2FA uit zodat de gebruiker het opnieuw kan instellen met een nieuw toestel. Oude back-upcodes en het oude toestel werken daarna niet meer.
{% if gebruiker.afgedwongen %}Omdat 2FA verplicht is, krijgt hij ook een tijdelijke uitzondering tot vandaag {{ einde.strftime('%H:%M') }} om opnieuw in te stellen.{% endif %}</p>
<form method="POST" action="{{ url_for('tweestaps.reset') }}" onsubmit="return confirm('2FA resetten voor {{ gebruiker.email }}? De gebruiker moet 2FA daarna opnieuw instellen.') && (showLoader(), true)">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<div class="field" style="margin-bottom:10px">
<input type="text" name="reden" maxlength="255" placeholder="Reden (optioneel), bv. nieuwe telefoon">
</div>
<button type="submit" class="btn-danger">📵 2FA resetten</button>
</form>
</div>
{% endif %}
{% if mag('tweestaps_uitzetten') %}
<div class="action-box">
<h3>⏳ Tijdelijk zonder 2FA</h3>
{% if uitzondering %}
<p>Deze gebruiker kan tot <strong>{{ uitzondering.Tot.strftime('%d-%m %H:%M') }}</strong> zonder 2FA inloggen. Daarna wordt 2FA automatisch terug verplicht.</p>
<div class="btn-row">
{% if uitzondering.Tot < einde %}
<form method="POST" action="{{ url_for('tweestaps.uitzondering') }}" style="margin:0" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<button type="submit" class="btn-light">Verlengen tot {{ einde.strftime('%H:%M') }}</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('tweestaps.stop_uitzondering', uitz_id=uitzondering.ID) }}" style="margin:0" onsubmit="showLoader()">
<input type="hidden" name="terug" value="{{ gebruiker.email }}">
<button type="submit" class="btn-danger">Nu beëindigen</button>
</form>
</div>
{% elif not groep %}
<p>Nog niet ingesteld: vul de <strong>Uitzonderingsgroep</strong> in bij
{% if mag('koppelingen') %}<a href="{{ url_for('scholen.mijn_school') }}">🔌 Koppelingen van deze school</a>{% else %}🔌 Koppelingen van deze school{% endif %}
› 🛡️ 2FA-hulp (een Google-groep waarvoor 2FA niet afgedwongen wordt).</p>
{% else %}
<p>Voor wie 2FA nog moet instellen, of als een back-upcode niet lukt. De uitzondering loopt tot
<strong>vandaag {{ einde.strftime('%H:%M') }}</strong>; daarna zet PIVOT 2FA automatisch terug aan.</p>
<form method="POST" action="{{ url_for('tweestaps.uitzondering') }}" onsubmit="showLoader()">
<input type="hidden" name="email" value="{{ gebruiker.email }}">
<div class="field" style="margin-bottom:10px">
<input type="text" name="reden" maxlength="255" placeholder="Reden (optioneel), bv. nieuwe telefoon">
</div>
<button type="submit" class="btn-warning">⏳ Uitzondering geven tot {{ einde.strftime('%H:%M') }}</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
</section>
{# ---------- Lopende uitzonderingen ---------- #}
<div class="card">
<h3 class="card-title">⏳ Lopende uitzonderingen</h3>
{% if actief %}
<div class="table-wrap">
<table>
<thead><tr><th>Gebruiker</th><th>Tot</th><th>Gegeven door</th><th>Reden</th><th class="actions">Actie</th></tr></thead>
<tbody>
{% for u in actief %}
<tr>
<td><a href="{{ url_for('tweestaps.overzicht', email=u.Email) }}"><strong>{{ u.Naam or u.Email }}</strong></a><div class="muted small">{{ u.Email }}</div></td>
<td class="nowrap">
{{ u.Tot.strftime('%H:%M') if u.Tot.date() == nu.date() else u.Tot.strftime('%d-%m %H:%M') }}
{% if u.Tot <= nu %}<span class="badge badge-warning">wordt beëindigd</span>{% endif %}
</td>
<td>{{ u.Door or '?' }}</td>
<td class="muted">{{ u.Reden or '' }}</td>
<td class="actions">
{% if mag('tweestaps_uitzetten') %}
<form method="POST" action="{{ url_for('tweestaps.stop_uitzondering', uitz_id=u.ID) }}" onsubmit="showLoader()">
<button type="submit" class="btn-sm btn-danger">Nu beëindigen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted" style="margin:0">Geen lopende uitzonderingen. Iedereen moet 2FA gebruiken.</p>
{% endif %}
</div>
{# ---------- Logboek ---------- #}
{% if logboek %}
<div class="card" id="logboek">
<div class="page-head" style="margin-bottom:10px">
<h3 class="card-title" style="margin:0">🕑 Recente acties</h3>
<span class="muted small">{{ log_totaal }} actie(s)</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Tijdstip</th><th>Door</th><th>Actie</th><th>Gebruiker</th><th>Details</th></tr></thead>
<tbody>
{% for l in logboek %}
<tr>
<td class="nowrap">{{ l.Tijdstip.strftime('%d-%m %H:%M') }}</td>
<td>{{ l.Door or '?' }}</td>
<td>{{ l.Actie }}</td>
<td>{{ l.Email }}</td>
<td class="muted">{{ l.Details or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set extra = {'email': gebruiker.email} if gebruiker else {} %}
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;margin-top:12px">
<form method="GET" action="{{ url_for('tweestaps.overzicht') }}#logboek" style="margin:0">
{% if gebruiker %}<input type="hidden" name="email" value="{{ gebruiker.email }}">{% endif %}
<label class="per-pagina">Toon
<select name="logper" onchange="this.form.submit()" aria-label="Aantal per pagina">
{% for n in log_keuzes %}<option value="{{ n }}" {% if n == log_per %}selected{% endif %}>{{ n or 'alles' }}</option>{% endfor %}
</select> per pagina</label>
</form>
{% if log_paginas > 1 %}
<nav class="paginering" aria-label="Pagina's logboek" style="margin-top:0">
{% if log_pagina > 1 %}
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=1, **extra) }}#logboek" title="Eerste pagina">«</a>
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_pagina - 1, **extra) }}#logboek">‹ Vorige</a>
{% endif %}
{% for p in range([1, log_pagina - 2]|max, [log_paginas, log_pagina + 2]|min + 1) %}
<a class="btn btn-sm {{ 'btn-secondary' if p == log_pagina else 'btn-light' }}"
href="{{ url_for('tweestaps.overzicht', logpagina=p, **extra) }}#logboek" {% if p == log_pagina %}aria-current="page"{% endif %}>{{ p }}</a>
{% endfor %}
{% if log_pagina < log_paginas %}
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_pagina + 1, **extra) }}#logboek">Volgende ›</a>
<a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', logpagina=log_paginas, **extra) }}#logboek" title="Laatste pagina">»</a>
{% endif %}
<span class="muted small" style="margin-left:6px">pagina {{ log_pagina }} van {{ log_paginas }}</span>
</nav>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
{# ---------- Tab: nog geen 2FA ingesteld ---------- #}
<section class="tab-content card active">
<div class="page-head">
<div>
<h2 style="margin:0">Nog geen 2FA ingesteld</h2>
<div class="muted small">Actieve accounts in {{ ous }} zonder 2FA</div>
</div>
<div class="actions">
<a class="btn btn-light" href="{{ url_for('tweestaps.overzicht', tab='lijst', vernieuw=1) }}" onclick="showLoader('Gebruikers ophalen bij Google...')">🔄 Vernieuwen</a>
</div>
</div>
{% if lijst_fout %}<div class="alert alert-danger">{{ lijst_fout }}</div>{% endif %}
{% if lijst is not none %}
<div class="table-wrap">
<table>
<thead><tr><th class="sorteer" data-kol="0" data-type="tekst" title="Sorteren op naam">Naam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1" data-type="tekst" title="Sorteren op e-mail">E-mail <span class="pijl"></span></th><th>OU</th>
<th class="sorteer" data-kol="3" data-type="datum" title="Sorteren op laatste login">Laatste login <span class="pijl"></span></th>
<th class="sorteer" data-kol="4" data-type="datum" title="Sorteren op status">Status <span class="pijl"></span></th><th class="actions"></th></tr></thead>
<tbody id="lijstBody">
{% set uitz = actief|map(attribute='Email')|list %}
{% for u in lijst %}
<tr>
<td data-sort="{{ u.naam|lower }}"><strong>{{ u.naam }}</strong></td>
<td data-sort="{{ u.email|lower }}">{{ u.email }}</td>
<td class="muted">{{ u.ou }}</td>
<td class="nowrap" data-sort="{{ u.laatste_login or '' }}">{{ u.laatste_login or 'Nooit' }}</td>
{# volgorde bij sorteren: kan niet inloggen → uitzondering → nog niet verplicht; daarbinnen op naam #}
<td data-sort="{{ '2' if u.email in uitz else ('1' if u.afgedwongen else '3') }} {{ u.naam|lower }}">
{% if u.email in uitz %}<span class="badge badge-info">Uitzondering</span>
{% elif u.afgedwongen %}<span class="badge badge-danger">Kan niet inloggen</span>
{% else %}<span class="badge badge-warning">Nog niet verplicht</span>{% endif %}
</td>
<td class="actions"><a class="btn btn-sm btn-light" href="{{ url_for('tweestaps.overzicht', email=u.email) }}">Helpen</a></td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">🎉 Iedereen heeft 2FA ingesteld.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if lijst %}<p class="field-hint">{{ lijst|length }} account(s). "Kan niet inloggen": voor deze mensen wordt 2FA afgedwongen maar ze hebben het nog niet ingesteld. Geef ze een tijdelijke uitzondering zodat ze het kunnen instellen.</p>{% endif %}
{% endif %}
</section>
{% endif %}
{% endblock %}
{% block scripts %}
<style>
.paginering { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
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; }
</style>
<script>
// Sorteren via de kolomkop (Naam, Laatste login). "Nooit" telt als oudste.
document.querySelectorAll('th.sorteer').forEach(function (th) {
th.addEventListener('click', function () {
var body = document.getElementById('lijstBody');
if (!body) return;
var kol = +th.dataset.kol, op = !th.classList.contains('op');
document.querySelectorAll('th.sorteer').forEach(function (x) { x.classList.remove('op', 'af'); });
th.classList.add(op ? 'op' : 'af');
var rijen = Array.prototype.filter.call(body.rows, function (r) { return !r.classList.contains('empty-row'); });
rijen.sort(function (a, b) {
var x = a.cells[kol].dataset.sort || '', y = b.cells[kol].dataset.sort || '';
var c = th.dataset.type === 'tekst' ? x.localeCompare(y, 'nl') : (x < y ? -1 : x > y ? 1 : 0);
return op ? c : -c;
});
rijen.forEach(function (r) { body.appendChild(r); });
});
});
function printSlip() {
document.body.classList.add('print-slip');
window.print();
setTimeout(() => document.body.classList.remove('print-slip'), 500);
}
</script>
{% endblock %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block title %}Uitlenen{% endblock %}
{% block page_title %}Toestel uitdelen{% endblock %}
{% block main_class %}medium{% endblock %}
{% block head %}
<script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
{% endblock %}
{% block extra_css %}
<style>
#reader { width: 100%; margin-top: 14px; border: 3px solid var(--primary-color); border-radius: var(--radius); overflow: hidden; display: none; background: #000; }
#reader video { width: 100% !important; height: auto !important; object-fit: cover !important; }
#scan-result-container { display: none; margin-top: 14px; padding: 14px; border: 2px dashed var(--primary-color); border-radius: var(--radius); background: var(--primary-light); }
#scannedText { font-size: 1.2em; color: var(--primary-color); word-break: break-all; }
.scan-actions { display: flex; gap: 8px; margin-top: 10px; }
.scan-actions button { flex: 1; }
</style>
{% endblock %}
{% block content %}
<div class="split">
<div class="card">
<h2 class="card-title">Toestelgegevens</h2>
<form method="POST" action="{{ url_for('devices.uitdelen_submit') }}" onsubmit="showLoader()">
<div class="field zoek-wrap">
<label for="internnummer">Internnummer</label>
<input type="text" id="internnummer" name="internnummer" required autocomplete="off"
placeholder="Zoek op naam of scan/typ internnummer..." role="combobox"
value="{{ request.args.get('internnummer', '') }}"
aria-autocomplete="list" aria-controls="leerlingSuggesties" aria-expanded="false">
<ul class="suggesties" id="leerlingSuggesties" role="listbox"></ul>
{% if request.args.get('naam') %}<p class="field-hint">👤 {{ request.args.get('naam') }}</p>{% endif %}
</div>
<div class="field zoek-wrap">
<label for="serienummer">Toestelnummer</label>
<input type="text" id="serienummer" name="serienummer" required autocomplete="off"
placeholder="Zoek of scan toestelnummer (of serienummer)..." role="combobox"
{{ 'autofocus' if request.args.get('internnummer') }}
aria-autocomplete="list" aria-controls="toestelSuggesties" aria-expanded="false">
<ul class="suggesties" id="toestelSuggesties" role="listbox"></ul>
</div>
<button class="btn-block btn-lg" type="submit" name="action" value="check">✅ Ingeven</button>
<button class="btn-block btn-warning" type="submit" name="action" value="no_check">Zonder nakijken</button>
</form>
</div>
<div class="card order-first-mobile text-center">
<h2 class="card-title">📷 Scanner</h2>
<button type="button" class="btn-block btn-secondary" id="btn-toggle-scan">📷 Open camera-scanner</button>
{# Zonder https geeft de browser geen live camera: dan een foto nemen met de camera-app en de code daaruit lezen #}
<label class="btn btn-block btn-secondary" id="btn-foto" style="display:none">
📷 Foto van de code nemen
<input type="file" id="foto-input" accept="image/*" capture="environment" hidden>
</label>
<p class="muted small" id="scan-hint" style="display:none;margin:8px 0 0"></p>
<div id="reader"></div>
<div id="scan-result-container">
<div class="muted small">Gescande waarde</div>
<strong id="scannedText">-</strong>
<div class="scan-actions">
<button type="button" class="btn-sm" onclick="copyToField('internnummer')">→ Internnr.</button>
<button type="button" class="btn-sm" onclick="copyToField('serienummer')">→ Toestelnr.</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{% set toestel_voorkeur = 'beschikbaar' %}{% include '_toestel_zoekveld.html' %}
<script>
let html5QrCode;
const scanBtn = document.getElementById('btn-toggle-scan');
const readerDiv = document.getElementById('reader');
const resultContainer = document.getElementById('scan-result-container');
const scannedTextElement = document.getElementById('scannedText');
scanBtn.addEventListener('click', () => {
if (!html5QrCode || !html5QrCode.isScanning) startScanner(); else stopScanner();
});
// Live camera kan enkel via https (of localhost). Anders: foto nemen en de code uit de foto lezen.
const fotoBtn = document.getElementById('btn-foto');
const fotoInput = document.getElementById('foto-input');
const scanHint = document.getElementById('scan-hint');
function fotoModus(reden) {
scanBtn.style.display = 'none';
fotoBtn.style.display = '';
scanHint.textContent = reden;
scanHint.style.display = '';
}
if (!window.isSecureContext || !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
fotoModus('Neem een scherpe foto van de streepjes- of QR-code, van dichtbij en recht ervoor.');
}
fotoInput.addEventListener('change', () => {
const bestand = fotoInput.files && fotoInput.files[0];
if (!bestand) return;
scanHint.textContent = 'Code zoeken in de foto...';
scanHint.style.display = '';
const lezer = new Html5Qrcode("reader");
lezer.scanFile(bestand, false)
.then(tekst => {
scannedTextElement.textContent = tekst;
resultContainer.style.display = 'block';
scanHint.textContent = 'Code gevonden. Kies hieronder in welk veld ze moet komen.';
})
.catch(() => { scanHint.textContent = 'Geen code gevonden in de foto. Probeer opnieuw, van dichterbij en zonder schittering.'; })
.finally(() => { fotoInput.value = ''; lezer.clear(); });
});
function startScanner() {
readerDiv.style.display = 'block';
scanBtn.textContent = '❌ Stop scanner';
scanBtn.classList.replace('btn-secondary', 'btn-danger');
html5QrCode = new Html5Qrcode("reader");
const config = { fps: 20, qrbox: { width: 300, height: 150 }, aspectRatio: 1.777778 };
html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText) => {
scannedTextElement.textContent = decodedText;
resultContainer.style.display = 'block';
},
() => {}
).catch((err) => {
console.error(err);
readerDiv.style.display = 'none';
scanBtn.textContent = '📷 Open camera-scanner';
scanBtn.classList.replace('btn-danger', 'btn-secondary');
fotoModus('De live camera kon niet starten. Neem in de plaats een foto van de code.');
});
}
function stopScanner() {
if (html5QrCode) {
html5QrCode.stop().then(() => {
readerDiv.style.display = 'none';
scanBtn.textContent = '📷 Open camera-scanner';
scanBtn.classList.replace('btn-danger', 'btn-secondary');
}).catch(err => console.error(err));
}
}
function copyToField(fieldId) {
const input = document.getElementById(fieldId);
input.value = scannedTextElement.textContent;
input.style.backgroundColor = '#d1fae5';
setTimeout(() => input.style.backgroundColor = '', 500);
}
</script>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}{{ groep.naam }}{% endblock %}
{% block page_title %}Verzendlijst{% endblock %}
{% block extra_css %}
<style>
.lijst-grid { display: grid; grid-template-columns: 1fr 1.6fr; gap: 20px; align-items: start; }
@media (max-width: 900px) { .lijst-grid { grid-template-columns: 1fr; } }
.lijst-grid .card { margin: 0; }
.mail { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
.lid-acties { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
.lid-acties form { margin: 0; }
.lid-acties select { width: auto; padding: 4px 6px; font-size: .9em; }
.toevoegen { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: end; margin-bottom: 14px; }
.toevoegen textarea { min-height: 70px; }
.toevoegen .rechts { display: flex; flex-direction: column; gap: 8px; }
.zoek-lid { margin-bottom: 10px; }
</style>
{% endblock %}
{% block content %}
<p style="margin:0 0 14px"><a href="{{ url_for('verzendlijsten.overzicht') }}">← Alle verzendlijsten</a></p>
<div class="lijst-grid">
<div class="card">
<h2 class="card-title">📮 {{ groep.naam }}</h2>
<p class="mail" style="margin-top:-6px">{{ groep.email }}</p>
{% if groep.aliassen %}<p class="muted small">Ook bereikbaar via: {{ groep.aliassen|join(', ') }}</p>{% endif %}
<form method="POST" action="{{ url_for('verzendlijsten.bewerken', email=groep.email) }}" onsubmit="showLoader('Opslaan...')">
<div class="field">
<label for="naam">Naam</label>
<input type="text" id="naam" name="naam" required value="{{ groep.naam }}">
</div>
<div class="field">
<label for="beschrijving">Beschrijving</label>
<textarea id="beschrijving" name="beschrijving" rows="3">{{ groep.beschrijving }}</textarea>
</div>
{% if huidig_wie %}
<div class="field">
<label for="wie_mailt">Wie mag mailen naar de lijst?</label>
<select id="wie_mailt" name="wie_mailt">
{% for w, l in wie_mailt %}<option value="{{ w }}" {% if w == huidig_wie %}selected{% endif %}>{{ l }}</option>{% endfor %}
{% if huidig_wie not in wie_mailt|map('first') %}<option value="" selected>Andere instelling ({{ huidig_wie }}) – niet wijzigen</option>{% endif %}
</select>
</div>
{% else %}
<p class="field-hint">"Wie mag mailen" kan hier niet getoond worden: daarvoor moet de scope
<code>apps.groups.settings</code> bij het serviceaccount staan en de Groups Settings API aan staan.</p>
{% endif %}
<button type="submit">💾 Opslaan</button>
</form>
</div>
<div class="card">
<div class="page-head">
<h2 style="margin:0">👥 Leden <span class="muted small">({{ leden|length }})</span></h2>
</div>
<form method="POST" action="{{ url_for('verzendlijsten.leden_toevoegen', email=groep.email) }}" class="toevoegen"
onsubmit="showLoader('Leden toevoegen...')">
<div class="field" style="margin:0">
<label for="adressen">Leden toevoegen</label>
<textarea id="adressen" name="adressen" placeholder="Eén of meer e-mailadressen (ook een andere verzendlijst), gescheiden door komma's of op aparte regels"></textarea>
</div>
<div class="rechts">
<select name="rol" aria-label="Rol">
{% for r, l in rollen %}<option value="{{ r }}">{{ l }}</option>{% endfor %}
</select>
<button type="submit">➕ Toevoegen</button>
</div>
</form>
{% if leden|length > 10 %}
<input type="search" id="zoekLid" class="zoek-lid" placeholder="Zoek een lid..." aria-label="Zoek een lid">
{% endif %}
<div class="table-wrap">
<table>
<thead><tr><th>E-mailadres</th><th>Soort</th><th class="actions">Rol</th></tr></thead>
<tbody id="ledenBody">
{% for m in leden %}
<tr data-zoek="{{ m.email|lower }}">
<td class="mail">{{ m.email }}</td>
<td class="muted small">{{ {'USER': 'Gebruiker', 'GROUP': 'Verzendlijst', 'CUSTOMER': 'Hele organisatie', 'EXTERNAL': 'Extern'}.get(m.soort, m.soort) }}</td>
<td class="actions">
<span class="lid-acties">
<form method="POST" action="{{ url_for('verzendlijsten.lid_rol', email=groep.email) }}">
<input type="hidden" name="lid" value="{{ m.email }}">
<select name="rol" aria-label="Rol van {{ m.email }}" onchange="showLoader('Rol aanpassen...'); this.form.submit()">
{% for r, l in rollen %}<option value="{{ r }}" {% if r == m.rol %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
</form>
<form method="POST" action="{{ url_for('verzendlijsten.lid_verwijderen', email=groep.email) }}"
onsubmit="return confirm('{{ m.email }} uit deze verzendlijst halen?') && (showLoader('Verwijderen...'), true)">
<input type="hidden" name="lid" value="{{ m.email }}">
<button type="submit" class="btn-sm btn-ghost" title="Uit de lijst halen">🗑️</button>
</form>
</span>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="3">Deze verzendlijst heeft nog geen leden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const zoekLid = document.getElementById('zoekLid');
if (zoekLid) zoekLid.addEventListener('input', () => {
const z = zoekLid.value.trim().toLowerCase();
document.querySelectorAll('#ledenBody tr').forEach(tr => tr.style.display = !z || (tr.dataset.zoek || '').includes(z) ? '' : 'none');
});
</script>
{% endblock %}
+166
View File
@@ -0,0 +1,166 @@
{% extends "base.html" %}
{% block title %}Verzendlijsten{% endblock %}
{% block page_title %}Verzendlijsten{% endblock %}
{% block loader_text %}Verzendlijsten ophalen bij Google...{% endblock %}
{% block extra_css %}
<style>
.nieuw-grid { display: grid; grid-template-columns: 1.4fr 1.2fr 1.6fr; gap: 12px; align-items: end; }
.nieuw-grid .breed { grid-column: 1 / -1; }
.nieuw-grid .field { margin: 0; }
.adres-veld { display: flex; align-items: stretch; }
.adres-veld input { border-top-right-radius: 0; border-bottom-right-radius: 0; flex: 1; min-width: 0; }
.adres-veld select, .adres-veld .at { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.adres-veld .at { display: grid; place-items: center; padding: 0 8px; background: var(--surface-muted);
border: 1px solid var(--border); border-left: 0; border-right: 0; color: var(--text-muted); }
.adres-veld select { width: auto; max-width: 55%; }
@media (max-width: 760px) { .nieuw-grid { grid-template-columns: 1fr; } }
details.nieuw > summary { cursor: pointer; list-style: none; display: inline-flex; }
details.nieuw > summary::-webkit-details-marker { display: none; }
details.nieuw[open] > summary { margin-bottom: 14px; }
.zoekbalk { display: flex; gap: 10px; margin-bottom: 12px; }
.zoekbalk input { flex: 1; }
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; }
.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; }
td.mail { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; }
</style>
{% endblock %}
{% block content %}
<div class="card">
<details class="nieuw" {% if not lijsten and not fout %}open{% endif %}>
<summary class="btn btn-success">➕ Nieuwe verzendlijst</summary>
<form method="POST" action="{{ url_for('verzendlijsten.nieuw') }}" class="nieuw-grid" onsubmit="showLoader('Verzendlijst aanmaken...')">
<div class="field">
<label for="adres">E-mailadres</label>
<div class="adres-veld">
<input type="text" id="adres" name="adres" required placeholder="bv. leerkrachten-1ste-graad" autocomplete="off">
{% if domeinen %}
<span class="at">@</span>
<select name="domein" aria-label="Domein">
{% for d in domeinen %}<option>{{ d }}</option>{% endfor %}
</select>
{% endif %}
</div>
</div>
<div class="field">
<label for="naam">Naam</label>
<input type="text" id="naam" name="naam" required placeholder="bv. Leerkrachten 1ste graad">
</div>
<div class="field">
<label for="wie_mailt_n">Wie mag mailen naar de lijst?</label>
<select id="wie_mailt_n" name="wie_mailt">
<option value="">Standaard van Google</option>
{% for w, l in wie_mailt %}<option value="{{ w }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div class="field breed">
<label for="beschrijving">Beschrijving</label>
<input type="text" id="beschrijving" name="beschrijving" placeholder="Waarvoor dient deze lijst? (optioneel)">
</div>
<div class="breed"><button type="submit">💾 Verzendlijst aanmaken</button></div>
</form>
</details>
</div>
<div class="card">
<div class="page-head">
<h2>📮 Verzendlijsten</h2>
<a class="btn btn-light btn-sm" href="{{ url_for('verzendlijsten.overzicht') }}" onclick="showLoader()">🔄 Vernieuwen</a>
</div>
{% if fout %}
<div class="alert alert-danger">{{ fout }}</div>
{% else %}
<div class="zoekbalk">
<input type="search" id="zoek" placeholder="Zoek op naam, adres of beschrijving..." aria-label="Zoeken">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sorteer" data-kol="0">Naam <span class="pijl"></span></th>
<th class="sorteer" data-kol="1">E-mailadres <span class="pijl"></span></th>
<th class="sorteer text-right" data-kol="2">Leden <span class="pijl"></span></th>
<th class="sorteer" data-kol="3">Beschrijving <span class="pijl"></span></th>
<th class="actions"></th>
</tr>
</thead>
<tbody id="lijstBody">
{% for g in lijsten %}
<tr data-zoek="{{ [g.naam, g.email, g.beschrijving]|join(' ')|lower }}">
<td data-sort="{{ g.naam }}"><strong>{{ g.naam }}</strong></td>
<td class="mail" data-sort="{{ g.email }}">{{ g.email }}{% if g.aliassen %}<br><span class="muted small">ook: {{ g.aliassen|join(', ') }}</span>{% endif %}</td>
<td class="text-right" data-sort="{{ '%06d'|format(g.leden) }}">{{ g.leden }}</td>
<td class="muted small" data-sort="{{ g.beschrijving }}">{{ g.beschrijving }}</td>
<td class="actions"><a class="btn btn-sm btn-secondary" href="{{ url_for('verzendlijsten.detail', email=g.email) }}" onclick="showLoader('Leden ophalen...')">✏️ Openen</a></td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Er zijn nog geen verzendlijsten in Google.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tabel-voet">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<div class="muted small" id="teller"></div>
<span id="perPagina"></span>
</div>
<nav class="paginering" id="paginering" aria-label="Pagina's"></nav>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const body = document.getElementById('lijstBody');
if (!body || body.querySelector('.empty-row')) return;
let PER_PAGINA = perPagina('verzendlijsten'), pagina = 1, kol = null, op = true, zoek = '';
const zichtbaar = () => Array.from(body.rows).filter(tr => !zoek || tr.dataset.zoek.includes(zoek));
function toon() {
const rijen = zichtbaar(), grootte = PER_PAGINA || Infinity;
const 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} verzendlijst(en)` : 'Geen verzendlijsten gevonden';
const nav = document.getElementById('paginering');
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); }
}
document.getElementById('zoek').addEventListener('input', e => { zoek = e.target.value.trim().toLowerCase(); pagina = 1; toon(); });
document.querySelectorAll('th.sorteer').forEach(th => th.addEventListener('click', () => {
const k = +th.dataset.kol;
op = kol === k ? !op : (k !== 2); 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 c = (a.cells[k].dataset.sort || '').localeCompare(b.cells[k].dataset.sort || '', 'nl', { numeric: true, sensitivity: 'base' });
return op ? c : -c;
}).forEach(tr => body.appendChild(tr));
pagina = 1; toon();
}));
perPaginaKiezer(document.getElementById('perPagina'), 'verzendlijsten', n => { PER_PAGINA = n; pagina = 1; toon(); });
toon();
})();
</script>
{% endblock %}