Files
leerdoelen_tracker/backend/templates/directeur_klassen.html
Sam 5afe297161
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
feat: add class management page for directors and enhance access control
2026-03-04 11:45:45 +01:00

364 lines
18 KiB
HTML
Raw Permalink 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>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>