Files
Pivot/templates/aanwezigheden.html
T
2026-10-14 12:29:29 +02:00

248 lines
15 KiB
HTML
Raw Blame History

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