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

270 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 %}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'">&times;</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 %}