PIVOT 1.1.0
This commit is contained in:
commit
8a53234eb6
161 files changed
+31109
No files matched your search
@@ -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'">×</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 %}
|
||||
Reference in new issue
Block a user