248 lines
15 KiB
HTML
248 lines
15 KiB
HTML
{% 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 %}
|