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

+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 %}