Files
leerdoelen_tracker/backend/templates/doelen_beheer.html
Sam 5d0e4f9c91
All checks were successful
Build & Push / Build & Push image (push) Successful in 1m1s
implement leerdoelen_converter and bugfixes with storage_uri
2026-02-28 22:47:28 +01:00

310 lines
17 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.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Leerdoelen bestanden {{ org_name }}</title>
<style>
:root {
--primary:#4f46e5;--primary-dark:#4338ca;--success:#10b981;
--danger:#ef4444;--warning:#f59e0b;
--gray-50:#f9fafb;--gray-100:#f3f4f6;--gray-200:#e5e7eb;
--gray-300:#d1d5db;--gray-500:#6b7280;--gray-600:#4b5563;
--gray-700:#374151;--gray-800:#1f2937;
}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--gray-100);color:var(--gray-800);}
.container{max-width:900px;margin:0 auto;padding:1rem;}
.header{background:linear-gradient(135deg,#065f46,#047857);color:white;border-radius:12px;padding:1.25rem 1.5rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;}
.header h1{font-size:1.3rem;display:flex;align-items:center;gap:.6rem;}
.header-sub{opacity:.85;font-size:.85rem;margin-top:.2rem;}
.nav-link{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem .9rem;background:rgba(255,255,255,.15);color:white;border:1px solid rgba(255,255,255,.25);border-radius:6px;text-decoration:none;font-size:.85rem;font-weight:500;transition:background .2s;}
.nav-link:hover{background:rgba(255,255,255,.25);}
.section{background:white;border-radius:12px;padding:1.5rem;margin-bottom:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.1);}
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:.5rem;}
.section-header h2{font-size:1.05rem;color:var(--gray-700);}
.section-hint{color:var(--gray-500);font-size:.83rem;margin-bottom:1.25rem;line-height:1.6;}
.btn{display:inline-flex;align-items:center;gap:.35rem;padding:.5rem 1rem;border:none;border-radius:6px;font-size:.85rem;font-weight:500;cursor:pointer;transition:all .2s;text-decoration:none;}
.btn-danger{background:var(--danger);color:white;} .btn-danger:hover{background:#dc2626;}
.btn-sm{padding:.25rem .55rem;font-size:.78rem;}
table{width:100%;border-collapse:collapse;font-size:.875rem;}
th{padding:.65rem .75rem;text-align:left;font-weight:600;color:var(--gray-600);border-bottom:2px solid var(--gray-200);background:var(--gray-50);white-space:nowrap;}
td{padding:.6rem .75rem;border-bottom:1px solid var(--gray-100);vertical-align:middle;}
tr:hover td{background:var(--gray-50);}
.empty-row td{text-align:center;color:var(--gray-500);padding:2.5rem;font-style:italic;}
.drop-zone{border:2px dashed var(--gray-300);border-radius:10px;padding:2.5rem;text-align:center;cursor:pointer;transition:all .2s;background:var(--gray-50);}
.drop-zone:hover,.drop-zone.over{border-color:var(--primary);background:rgba(79,70,229,.04);}
.drop-icon{font-size:2.5rem;margin-bottom:.75rem;}
.drop-zone strong{font-size:1rem;}
.drop-zone p{color:var(--gray-500);font-size:.83rem;margin-top:.4rem;}
.versie-badge{background:#d1fae5;color:#065f46;padding:.2rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;}
.upload-ok{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;padding:.85rem 1rem;margin-bottom:.5rem;}
.upload-ok strong{color:#15803d;}
.upload-err{background:#fef2f2;border:1px solid #fecaca;border-radius:6px;padding:.85rem 1rem;}
.upload-err strong{color:#dc2626;}
.upload-ok ul,.upload-err ul{margin:.4rem 0 0 1.25rem;font-size:.82rem;}
.upload-ok li{color:#166534;}
.upload-err li{color:#991b1b;}
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.75rem;margin-bottom:1.5rem;}
.stat{background:white;border-radius:10px;padding:.9rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.08);}
.stat-value{font-size:1.7rem;font-weight:700;color:#047857;}
.stat-label{font-size:.72rem;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-top:.2rem;}
.notification{position:fixed;bottom:1rem;right:1rem;padding:.85rem 1.25rem;border-radius:8px;color:white;font-weight:500;transform:translateY(100px);opacity:0;transition:all .3s;z-index:2000;}
.notification.show{transform:translateY(0);opacity:1;}
.notification.success{background:var(--success);}
.notification.error{background:var(--danger);}
.notification.warning{background:var(--warning);}
@media (prefers-color-scheme: dark) {
:root {
--gray-50:#1a1a2e;--gray-100:#16213e;--gray-200:#0f3460;
--gray-300:#1a1a3e;--gray-500:#9ca3af;--gray-600:#d1d5db;
--gray-700:#e5e7eb;--gray-800:#f3f4f6;
}
body{background:#0f172a;color:#e2e8f0;}
.section,.stat{background:#1e293b !important;border-color:#334155;}
th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;}
td{border-color:#1e293b !important;color:#e2e8f0;}
tr:hover td{background:#263548 !important;}
.drop-zone{background:#162032 !important;border-color:#334155 !important;}
.drop-zone:hover,.drop-zone.over{background:#1a2744 !important;border-color:#6366f1 !important;}
.upload-ok{background:#064e3b !important;border-color:#065f46 !important;}
.upload-err{background:#450a0a !important;border-color:#7f1d1d !important;}
.versie-badge{background:#064e3b !important;color:#6ee7b7 !important;}
::-webkit-scrollbar{width:8px;} ::-webkit-scrollbar-track{background:#0f172a;} ::-webkit-scrollbar-thumb{background:#334155;border-radius:4px;}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>📂 Leerdoelen bestanden</h1>
<div class="header-sub">{{ org_name }}</div>
</div>
<a href="/dashboard" class="nav-link">← Terug naar beheer</a>
</div>
<!-- Stats -->
<div class="stat-row" id="statRow">
<div class="stat"><div class="stat-value" id="statVakken">-</div><div class="stat-label">Vakken</div></div>
<div class="stat"><div class="stat-value" id="statDoelen">-</div><div class="stat-label">Doelzinnen</div></div>
<div class="stat"><div class="stat-value" id="statVersie">-</div><div class="stat-label">Versie</div></div>
</div>
<!-- Upload -->
<div class="section">
<div class="section-header">
<h2>⬆️ Nieuwe bestanden uploaden</h2>
</div>
<!-- Format tabs -->
<div style="display:flex;gap:.5rem;margin-bottom:1rem;">
<button class="btn btn-primary" id="tabXlsx" onclick="switchUploadTab('xlsx')"
style="font-size:.85rem;">
📊 Excel (.xlsx) — aanbevolen
</button>
<button class="btn btn-secondary" id="tabJson" onclick="switchUploadTab('json')"
style="font-size:.85rem;">
📄 JSON
</button>
</div>
<!-- XLSX upload -->
<div id="panelXlsx">
<p class="section-hint">
Upload de originele Excel bestanden van GO!
(<code>Doelenset_BaO_*.xlsx</code>). De conversie naar JSON
gebeurt automatisch op de server — geen tussentijdse stap nodig.
Bij een update gewoon opnieuw uploaden.
</p>
<div class="info-box" style="background:var(--gray-50);border:1px solid var(--gray-200);
border-left:4px solid var(--primary);border-radius:6px;padding:.85rem 1rem;
margin-bottom:1rem;font-size:.83rem;color:var(--gray-600);">
💡 <strong>Nieuwe versie van GO!?</strong>
De GO! publiceert updates van de doelensets op
<a href="https://pro.g-o.be/themas/leerplannen/basisonderwijs/nieuw-leerplan-basisonderwijs/" target="_blank"
rel="noopener noreferrer" style="color:var(--primary);">
pro.g-o.be → Leerplannen → BaO
</a>.
Er is geen automatische synchronisatie — download de nieuwe xlsx bestanden
manueel en upload ze hier.
</div>
<div class="drop-zone" id="dropZoneXlsx"
onclick="document.getElementById('fileInputXlsx').click()"
ondragover="event.preventDefault();this.classList.add('over')"
ondragleave="this.classList.remove('over')"
ondrop="this.classList.remove('over');handleDropXlsx(event)">
<div class="drop-icon">📊</div>
<strong>Klik of sleep Excel bestanden hier</strong>
<p>Meerdere bestanden tegelijk · Enkel .xlsx · Max. 10 MB per bestand</p>
</div>
<input type="file" id="fileInputXlsx" accept=".xlsx" multiple style="display:none"
onchange="uploadXlsx(this.files)">
</div>
<!-- JSON upload -->
<div id="panelJson" style="display:none;">
<p class="section-hint">
Upload reeds geconverteerde JSON bestanden. Gebruik dit enkel als je
de Excel bestanden niet beschikbaar hebt of als je handmatig aangepaste
JSON wil laden.
</p>
<div class="drop-zone" id="dropZoneJson"
onclick="document.getElementById('fileInputJson').click()"
ondragover="event.preventDefault();this.classList.add('over')"
ondragleave="this.classList.remove('over')"
ondrop="this.classList.remove('over');handleDropJson(event)">
<div class="drop-icon">📄</div>
<strong>Klik of sleep JSON bestanden hier</strong>
<p>Meerdere bestanden tegelijk · Enkel .json · Max. 10 MB per bestand</p>
</div>
<input type="file" id="fileInputJson" accept=".json" multiple style="display:none"
onchange="uploadJson(this.files)">
</div>
<div id="uploadResults" style="margin-top:.85rem;display:none;"></div>
</div>
<!-- Geïnstalleerde vakken -->
<div class="section">
<div class="section-header">
<h2>📚 Geïnstalleerde vakken</h2>
<span id="doelenVersie"></span>
</div>
<table>
<thead>
<tr>
<th>Vak</th>
<th>Bestand ID</th>
<th>Doelzinnen</th>
<th>Versie</th>
<th>Gewijzigd door GO!</th>
<th></th>
</tr>
</thead>
<tbody id="doelenBody">
<tr class="empty-row"><td colspan="5">Laden...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="notification" id="notification"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadDoelen();
switchUploadTab('xlsx');
});
// ── Tab wisselen ──────────────────────────────────────────────────────────────
function switchUploadTab(tab) {
document.getElementById('panelXlsx').style.display = tab === 'xlsx' ? '' : 'none';
document.getElementById('panelJson').style.display = tab === 'json' ? '' : 'none';
document.getElementById('tabXlsx').className = 'btn ' + (tab === 'xlsx' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabJson').className = 'btn ' + (tab === 'json' ? 'btn-primary' : 'btn-secondary');
document.getElementById('tabXlsx').style.fontSize = '.85rem';
document.getElementById('tabJson').style.fontSize = '.85rem';
}
// ── Doelen overzicht laden ────────────────────────────────────────────────────
async function loadDoelen() {
const res = await fetch('/admin/doelen');
if (!res.ok) return;
const data = await res.json();
if (data.versie) {
document.getElementById('doelenVersie').innerHTML =
`<span class="versie-badge">index versie ${data.versie}</span>`;
document.getElementById('statVersie').textContent = data.versie;
}
const vakken = data.vakken || [];
document.getElementById('statVakken').textContent = vakken.length;
document.getElementById('statDoelen').textContent =
vakken.reduce((s, v) => s + (v.aantalDoelzinnen || 0), 0).toLocaleString('nl-BE');
const tbody = document.getElementById('doelenBody');
if (!vakken.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">Nog geen doelen geüpload</td></tr>';
return;
}
tbody.innerHTML = vakken.map(v => {
const vn = v.naam.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const datum = v.bronDatum
? `<span title="'Gewijzigd op' datum uit Excel metadata van GO!">${v.bronDatum}</span>`
: `<span style="color:var(--gray-400)">onbekend</span>`;
return `<tr>
<td><strong>${v.naam}</strong></td>
<td style="font-family:monospace;font-size:.78rem;color:var(--gray-500);">${v.id}</td>
<td>${(v.aantalDoelzinnen||0).toLocaleString('nl-BE')}</td>
<td><span class="versie-badge">${v.versie || '?'}</span></td>
<td style="font-size:.83rem;">${datum}</td>
<td><button class="btn btn-danger btn-sm"
onclick="deleteDoelen('${v.id}','${vn}')">Verwijderen</button></td>
</tr>`;
}).join('');
}
// ── Upload helpers ────────────────────────────────────────────────────────────
function toonResultaten(data) {
const el = document.getElementById('uploadResults');
const ok = data.results.filter(r => r.ok);
const err = data.results.filter(r => !r.ok);
let html = '';
if (ok.length)
html += `<div class="upload-ok"><strong>✓ ${ok.length} bestand(en) verwerkt</strong><ul>`
+ ok.map(r => `<li><strong>${r.vak_id}</strong> — ${r.aantalDoelzinnen} doelzinnen (v${r.versie})</li>`).join('')
+ `</ul></div>`;
if (err.length)
html += `<div class="upload-err"><strong>✗ ${err.length} mislukt</strong><ul>`
+ err.map(r => `<li><strong>${r.filename}</strong>: ${r.error}</li>`).join('')
+ `</ul></div>`;
el.innerHTML = html;
el.style.display = 'block';
loadDoelen();
setTimeout(() => { el.style.display = 'none'; }, 15000);
}
async function doUpload(endpoint, files) {
if (!files?.length) return;
const el = document.getElementById('uploadResults');
el.style.display = 'block';
el.innerHTML = `<p style="color:var(--gray-500);font-size:.85rem;">⏳ Verwerken van ${files.length} bestand(en)…`
+ (endpoint.includes('xlsx') ? ' (Excel conversie kan even duren)' : '') + `</p>`;
const fd = new FormData();
for (const f of files) fd.append('files', f);
try {
const res = await fetch(endpoint, { method: 'POST', body: fd });
const data = await res.json();
toonResultaten(data);
} catch (e) {
el.innerHTML = `<div class="upload-err"><strong>✗ Netwerkfout:</strong> ${e.message}</div>`;
}
}
// ── XLSX upload ───────────────────────────────────────────────────────────────
function handleDropXlsx(e) { e.preventDefault(); uploadXlsx(e.dataTransfer.files); }
function uploadXlsx(files) { doUpload('/admin/doelen/upload-xlsx', files); }
// ── JSON upload ───────────────────────────────────────────────────────────────
function handleDropJson(e) { e.preventDefault(); uploadJson(e.dataTransfer.files); }
function uploadJson(files) { doUpload('/admin/doelen/upload', files); }
// ── Verwijderen ───────────────────────────────────────────────────────────────
async function deleteDoelen(vakId, vakNaam) {
if (!confirm(`Doelen voor "${vakNaam}" verwijderen? Bestaande beoordelingen blijven bewaard.`)) return;
const res = await fetch(`/admin/doelen/${vakId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`${vakNaam} verwijderd`, 'success');
await loadDoelen();
}
function notify(msg, type='success') {
const el = document.getElementById('notification');
el.textContent = msg;
el.className = `notification ${type} show`;
setTimeout(() => el.classList.remove('show'), 3500);
}
</script>
</body>
</html>