feat: add class management page for directors and enhance access control
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s

This commit is contained in:
2026-03-04 11:45:45 +01:00
parent c571967701
commit 5afe297161
4 changed files with 394 additions and 84 deletions

View File

@@ -437,7 +437,12 @@
</h2>
<div class="teacher-chips" id="teacherChips"></div>
<div class="action-bar" style="margin-top:1rem;">
<button id="btnAddTeacher" class="btn btn-primary">+ Leerkracht toevoegen</button>
<a href="/klassen" class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Klassenbeheer
</a>
<button id="btnExportCSV" class="btn btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Exporteer CSV
@@ -616,33 +621,6 @@
</div><!-- /container -->
<!-- Modal: leerkracht toevoegen -->
<div class="modal-overlay" id="addTeacherModal">
<div class="modal">
<h2>👤 Leerkracht toevoegen</h2>
<div class="form-group">
<label>Voornaam</label>
<input type="text" id="newFirstName" placeholder="Voornaam...">
</div>
<div class="form-group">
<label>Achternaam</label>
<input type="text" id="newLastName" placeholder="Achternaam...">
</div>
<div class="form-group">
<label>E-mailadres</label>
<input type="email" id="newEmail" placeholder="naam@school.be">
</div>
<div class="form-group">
<label>Wachtwoord (tijdelijk)</label>
<input type="password" id="newPassword" placeholder="Minimaal 8 tekens...">
</div>
<div id="addTeacherError" style="display:none;color:var(--danger);font-size:0.85rem;margin-top:0.5rem;"></div>
<div class="modal-buttons">
<button id="btnCancelTeacher" class="btn btn-secondary">Annuleren</button>
<button id="btnConfirmTeacher" class="btn btn-primary">Toevoegen</button>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
@@ -661,14 +639,11 @@ let activeYearId = null; // null = huidig actief jaar
document.addEventListener('DOMContentLoaded', async () => {
bind('jaarSelector', 'change', switchJaar);
bind('btnVernieuw', 'click', loadOverview);
bind('btnAddTeacher', 'click', openAddTeacher);
bind('btnExportCSV', 'click', exportToCSV);
bind('btnExportPDF', 'click', exportToPDF);
bind('tab-doelen', 'click', () => switchTab('doelen'));
bind('tab-klassen', 'click', () => switchTab('klassen'));
document.getElementById('tab-vergelijk') && bind('tab-vergelijk', 'click', () => switchTab('vergelijk'));
bind('btnCancelTeacher', 'click', closeModal);
bind('btnConfirmTeacher', 'click', addTeacher);
bind('filterVak', 'change', applyFilters);
bind('filterTeacher', 'change', applyFilters);
document.getElementById('filterKlas') && bind('filterKlas', 'change', applyFilters);
@@ -746,9 +721,6 @@ function renderTeacherList() {
<div class="teacher-chip">
<span class="name">${t.full_name}</span>
<span class="klas">${(t.classes||[]).map(c=>c.name).join(', ') || ''}</span>
<button data-action="removeTeacher" data-id="${t.id}"
style="width:18px;height:18px;border-radius:50%;border:none;background:var(--gray-300);cursor:pointer;font-size:0.7rem;"
title="Verwijderen">×</button>
</div>`).join('');
}
// Toon/verberg de sectie
@@ -1018,50 +990,6 @@ function applyFilters() {
}).join('');
}
// ── Leerkrachten beheer ───────────────────────────────────────────────────────
function openAddTeacher() {
document.getElementById('addTeacherModal').classList.add('active');
}
function closeModal() {
document.getElementById('addTeacherModal').classList.remove('active');
}
async function addTeacher() {
const errorEl = document.getElementById('addTeacherError');
errorEl.style.display = 'none';
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: document.getElementById('newFirstName').value,
last_name: document.getElementById('newLastName').value,
email: document.getElementById('newEmail').value,
password: document.getElementById('newPassword').value,
})
});
const data = await res.json();
if (!res.ok) {
errorEl.textContent = data.error;
errorEl.style.display = 'block';
return;
}
closeModal();
showNotification(`${data.user.full_name} toegevoegd!`, 'success');
await loadTeachers();
await loadOverview();
}
async function removeTeacher(userId) {
if (!confirm('Leerkracht deactiveren?')) return;
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
showNotification('Leerkracht verwijderd', 'success');
await loadTeachers();
await loadOverview();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function vakNaam(id) {
return id.replace(/^doelenset-bao-/, '').replace(/-/g, ' ')
@@ -1473,8 +1401,7 @@ function renderVergelijking() {
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'removeTeacher') { removeTeacher(btn.dataset.id); }
// (geen acties meer via event delegation in directeur dashboard)
});
</script>
</body>

View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Klassenbeheer — {{ 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-400: #9ca3af; --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', Roboto, sans-serif;
background: var(--gray-100); color: var(--gray-800); line-height: 1.5; }
.container { max-width: 900px; margin: 0 auto; padding: 1.25rem; }
/* Header */
.header { background: white; border-radius: 12px; padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1);
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; }
.header-left { display: flex; align-items: center; gap: .75rem; }
.back-link { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .75rem;
border: 1px solid var(--gray-300); border-radius: 6px; font-size: .85rem;
color: var(--gray-600); text-decoration: none; transition: all .15s; }
.back-link:hover { background: var(--gray-100); border-color: var(--gray-400); }
.header h1 { font-size: 1.25rem; color: var(--gray-900);
display: flex; align-items: center; gap: .5rem; }
.school-badge { font-size: .75rem; background: var(--primary); color: white;
padding: .2rem .55rem; border-radius: 9999px; font-weight: 500; }
/* Section */
.section { background: white; border-radius: 12px; padding: 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.section-header { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.1rem; flex-wrap: wrap; gap: .5rem; }
.section-header h2 { font-size: 1rem; color: var(--gray-700);
display: flex; align-items: center; gap: .4rem; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: .35rem; padding: .45rem .9rem;
border: none; border-radius: 6px; font-size: .85rem; font-weight: 500;
cursor: pointer; transition: all .15s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--gray-200); color: var(--gray-700); }
.btn-secondary:hover { background: var(--gray-300); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
/* Tabel */
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
thead th { padding: .6rem .85rem; text-align: left; font-size: .75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .04em; color: var(--gray-500);
border-bottom: 2px solid var(--gray-200); background: var(--gray-50); }
tbody td { padding: .7rem .85rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
tbody tr:hover td { background: var(--gray-50); }
tbody tr:last-child td { border-bottom: none; }
.teacher-names { font-size: .82rem; color: var(--gray-500); }
.no-teacher { font-style: italic; color: var(--gray-400); font-size: .82rem; }
td:last-child { white-space: nowrap; text-align: right; }
/* Empty state */
.empty { text-align: center; padding: 2.5rem; color: var(--gray-500); font-style: italic; }
/* Assign modal (dynamisch ingeladen) */
.assign-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5);
z-index: 1000; display: flex; align-items: center; justify-content: center; }
.assign-modal { background: white; border-radius: 12px; padding: 1.5rem;
max-width: 420px; width: 90%; max-height: 80vh; overflow-y: auto; }
.assign-modal h3 { font-size: 1rem; margin-bottom: 1rem; }
.checkbox-list { max-height: 240px; overflow-y: auto; display: flex;
flex-direction: column; gap: .4rem; margin-bottom: 1.1rem; }
.checkbox-list label { display: flex; align-items: center; gap: .5rem;
cursor: pointer; font-size: .875rem; padding: .25rem 0; }
.checkbox-list label:hover { color: var(--primary); }
.modal-buttons { display: flex; gap: .5rem; justify-content: flex-end; }
.form-error { color: var(--danger); font-size: .82rem; margin-top: .4rem; display: none; }
/* Notification */
.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); }
/* Dark mode */
@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; }
.header, .section, .assign-modal { background: #1e293b !important; }
.back-link { border-color: #334155; color: #94a3b8; }
.back-link:hover { background: #263548; }
thead th { background: #1e293b !important; border-color: #334155 !important; }
tbody td { border-color: #1e293b !important; }
tbody tr:hover td { background: #263548 !important; }
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<a href="/dashboard" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Terug naar dashboard
</a>
<h1>
🏫 Klassenbeheer
<span class="school-badge" id="schoolBadge">Laden...</span>
</h1>
</div>
<a href="/auth/logout" class="btn btn-secondary btn-sm">Uitloggen</a>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Klassen
</h2>
<button class="btn btn-primary btn-sm" id="btnAddKlas">+ Klas toevoegen</button>
</div>
<div id="klassenList">
<div class="empty">Laden...</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Leerkrachten van deze school
</h2>
</div>
<div id="teachersList">
<div class="empty">Laden...</div>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script nonce="{{ csp_nonce() }}">
let mySchoolId = null;
let allTeachers = [];
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btnAddKlas').addEventListener('click', openAddKlas);
const me = await fetch('/api/me').then(r => r.json());
mySchoolId = me.user?.school_id;
document.getElementById('schoolBadge').textContent = me.user?.school_name || 'Mijn school';
await Promise.all([loadKlassen(), loadTeachers()]);
});
// ── Klassen laden & renderen ──────────────────────────────────────────────────
async function loadKlassen() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`);
const data = await res.json();
const klassen = data.classes || [];
const container = document.getElementById('klassenList');
if (!klassen.length) {
container.innerHTML = '<div class="empty">Nog geen klassen aangemaakt. Klik op "+ Klas toevoegen" om te starten.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Klas</th>
<th>Leerkrachten</th>
<th></th>
</tr>
</thead>
<tbody>
${klassen.map(c => `
<tr>
<td><strong>${c.name}</strong></td>
<td>
${c.teachers?.length
? `<span class="teacher-names">${c.teachers.map(t => t.full_name).join(', ')}</span>`
: '<span class="no-teacher">Geen leerkracht gekoppeld</span>'}
</td>
<td>
<button class="btn btn-secondary btn-sm"
data-action="assignTeachers"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}"
data-teachers="${JSON.stringify(c.teachers?.map(t=>t.id)||[]).replace(/"/g,'&quot;')}">
Leerkrachten koppelen
</button>
<button class="btn btn-danger btn-sm"
data-action="deleteKlas"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}">
×
</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Leerkrachtenlijst ─────────────────────────────────────────────────────────
async function loadTeachers() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/users`);
const data = await res.json();
allTeachers = (data.users || []).filter(u => u.role === 'teacher');
const container = document.getElementById('teachersList');
if (!allTeachers.length) {
container.innerHTML = '<div class="empty">Nog geen leerkrachten geregistreerd via SSO.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Naam</th>
<th>E-mail</th>
<th>Laatste login</th>
</tr>
</thead>
<tbody>
${allTeachers.map(u => `
<tr>
<td><strong>${u.full_name}</strong></td>
<td style="color:var(--gray-500);font-size:.85rem;">${u.email}</td>
<td style="color:var(--gray-500);font-size:.82rem;">
${u.last_login
? new Date(u.last_login).toLocaleString('nl-BE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '<em>Nog niet ingelogd</em>'}
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Klas toevoegen ────────────────────────────────────────────────────────────
async function openAddKlas() {
const name = prompt('Naam van de nieuwe klas (bv. 3A):');
if (!name?.trim()) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: name.trim() })
});
const data = await res.json();
if (!res.ok) { notify(data.error || 'Aanmaken mislukt', 'error'); return; }
notify(`Klas "${name.trim()}" aangemaakt`, 'success');
await loadKlassen();
}
// ── Klas verwijderen ──────────────────────────────────────────────────────────
async function deleteKlas(classId, name) {
if (!confirm(`Klas "${name}" verwijderen? Alle leerkrachtkoppelingen worden ook verwijderd.`)) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${classId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`Klas "${name}" verwijderd`, 'success');
await loadKlassen();
}
// ── Leerkrachten koppelen ─────────────────────────────────────────────────────
let assignClassId = null;
async function openAssignTeachers(classId, className, currentTeacherIds) {
assignClassId = classId;
// Haal verse leerkrachtenlijst op als nog niet geladen
if (!allTeachers.length) await loadTeachers();
const html = `
<div class="assign-overlay" id="assignModal">
<div class="assign-modal">
<h3>Leerkrachten koppelen aan <strong>${className}</strong></h3>
<div class="checkbox-list">
${allTeachers.length
? allTeachers.map(t => `
<label>
<input type="checkbox" value="${t.id}"
${currentTeacherIds.includes(t.id) ? 'checked' : ''}>
<span>${t.full_name}</span>
<span style="font-size:.75rem;color:var(--gray-400);">(${t.email})</span>
</label>`).join('')
: '<em style="color:var(--gray-400);">Geen leerkrachten beschikbaar.<br>Leerkrachten verschijnen hier zodra ze voor het eerst inloggen via SSO.</em>'}
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" id="btnCancelAssign">Annuleren</button>
<button class="btn btn-primary" id="btnSaveAssign">Opslaan</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('btnCancelAssign').addEventListener('click', () => document.getElementById('assignModal')?.remove());
document.getElementById('btnSaveAssign').addEventListener('click', saveAssignTeachers);
// Klik buiten modal sluit ook
document.getElementById('assignModal').addEventListener('click', e => {
if (e.target.id === 'assignModal') document.getElementById('assignModal').remove();
});
}
async function saveAssignTeachers() {
const ids = [...document.querySelectorAll('#assignModal input:checked')].map(i => parseInt(i.value));
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${assignClassId}/teachers`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ user_ids: ids })
});
document.getElementById('assignModal')?.remove();
if (!res.ok) { notify('Opslaan mislukt', 'error'); return; }
notify('Leerkrachten bijgewerkt', 'success');
await loadKlassen();
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'deleteKlas') {
deleteKlas(btn.dataset.id, btn.dataset.name);
}
if (action === 'assignTeachers') {
const ids = JSON.parse(btn.dataset.teachers.replace(/&quot;/g, '"'));
openAssignTeachers(btn.dataset.id, btn.dataset.name, ids);
}
});
// ── Notificaties ──────────────────────────────────────────────────────────────
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>