Initial commit

This commit is contained in:
Kristof committed 2026-10-14 12:29:29 +02:00
commit 536ff43ce4
158 files changed
+30355

No files matched your search

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