feat: add functionality for linking teachers to classes with a new UI tab and API endpoint
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s

This commit is contained in:
2026-03-05 23:30:13 +01:00
parent 2782c3bea6
commit 4bfa3cd486
2 changed files with 139 additions and 0 deletions

View File

@@ -459,6 +459,29 @@ def set_my_classes():
return jsonify({'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes]}) return jsonify({'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes]})
# ── Klas-leerkracht koppeling (directeur) ──────────────────────────────────────
@api_bp.route('/classes/<int:class_id>/teachers', methods=['PUT'])
@login_required
@director_required
def set_class_teachers(class_id):
"""Directeur koppelt leerkrachten aan een klas."""
klas = Class.query.filter_by(id=class_id, school_id=current_user.school_id).first_or_404()
data = request.get_json() or {}
user_ids = data.get('teacher_ids', [])
teachers = User.query.filter(
User.id.in_(user_ids),
User.school_id == current_user.school_id,
User.is_active == True,
).all()
klas.users = teachers
audit_log('class.user_assignment', 'class', target_id=str(class_id),
detail={'class_name': klas.name, 'teacher_ids': user_ids,
'teacher_names': [t.full_name for t in teachers]})
db.session.commit()
return jsonify({'teachers': [{'id': t.id, 'full_name': t.full_name} for t in teachers]})
# ── Auditlog ─────────────────────────────────────────────────────────────────── # ── Auditlog ───────────────────────────────────────────────────────────────────
@api_bp.route('/audit-log') @api_bp.route('/audit-log')

View File

@@ -148,6 +148,7 @@
<button class="tab-btn" data-tab="vakken">📚 Per vak</button> <button class="tab-btn" data-tab="vakken">📚 Per vak</button>
<button class="tab-btn" data-tab="doelen">📋 Doelen detail</button> <button class="tab-btn" data-tab="doelen">📋 Doelen detail</button>
<button class="tab-btn" data-tab="vergelijk">⚖️ Vergelijken</button> <button class="tab-btn" data-tab="vergelijk">⚖️ Vergelijken</button>
<button class="tab-btn" data-tab="koppeling">👥 Leerkrachten</button>
</div> </div>
<div class="tab-content active" id="tab-klassen"> <div class="tab-content active" id="tab-klassen">
@@ -191,6 +192,10 @@
<label>Leeftijd</label> <label>Leeftijd</label>
<div class="leeftijd-cbs" id="leeftijdCbs"></div> <div class="leeftijd-cbs" id="leeftijdCbs"></div>
</div> </div>
<div class="fg">
<label>Sectie</label>
<select id="filterSectie"><option value="all">Alle secties</option></select>
</div>
<div class="fg"> <div class="fg">
<label>Zoeken</label> <label>Zoeken</label>
<input type="text" id="filterSearch" placeholder="Code of beschrijving..."> <input type="text" id="filterSearch" placeholder="Code of beschrijving...">
@@ -234,6 +239,30 @@
</div> </div>
</div> </div>
<!-- TAB: Leerkrachten koppelen -->
<div class="tab-content" id="tab-koppeling">
<div class="card">
<h2>👥 Leerkrachten aan klassen koppelen</h2>
<p style="font-size:.85rem;color:var(--gray-500);margin-bottom:1rem;">
Klik op een klas om de gekoppelde leerkrachten te wijzigen.
</p>
<div id="koppelingContent"><div class="empty">Laden...</div></div>
</div>
</div>
<!-- Modal: leerkrachten koppelen -->
<div id="koppelingModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;">
<div style="background:white;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.3);">
<h2 style="font-size:1.1rem;margin-bottom:.5rem;" id="koppelingModalTitle">Leerkrachten koppelen</h2>
<p style="font-size:.82rem;color:var(--gray-500);margin-bottom:1rem;">Selecteer de leerkrachten voor deze klas.</p>
<div id="koppelingCheckboxes" style="display:flex;flex-direction:column;gap:.4rem;max-height:280px;overflow-y:auto;margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;justify-content:flex-end;">
<button id="btnKoppelingAnnuleer" class="btn btn-secondary">Annuleren</button>
<button id="btnKoppelingOpslaan" class="btn btn-primary">Opslaan</button>
</div>
</div>
</div>
</div> </div>
<div class="notification" id="notification"></div> <div class="notification" id="notification"></div>
@@ -276,9 +305,14 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('filterKlas').addEventListener('change', applyFilters); document.getElementById('filterKlas').addEventListener('change', applyFilters);
document.getElementById('filterStatus').addEventListener('change', applyFilters); document.getElementById('filterStatus').addEventListener('change', applyFilters);
document.getElementById('filterSearch').addEventListener('input', applyFilters); document.getElementById('filterSearch').addEventListener('input', applyFilters);
document.getElementById('filterSectie').addEventListener('change', applyFilters);
// Export knoppen // Export knoppen
document.getElementById('btnExportCSV').addEventListener('click', exportCSV); document.getElementById('btnExportCSV').addEventListener('click', exportCSV);
document.getElementById('btnKoppelingAnnuleer').addEventListener('click', () => {
document.getElementById('koppelingModal').style.display = 'none';
});
document.getElementById('btnKoppelingOpslaan').addEventListener('click', saveKoppeling);
document.getElementById('btnExportPDF').addEventListener('click', exportPDF); document.getElementById('btnExportPDF').addEventListener('click', exportPDF);
// Vergelijk selects // Vergelijk selects
@@ -331,6 +365,7 @@ async function loadOverview() {
updateStats(); updateStats();
renderKlassen(); renderKlassen();
renderKlasProgress(); renderKlasProgress();
renderKoppelingTab();
renderVakStats(); renderVakStats();
populateFilters(); populateFilters();
applyFilters(); applyFilters();
@@ -498,6 +533,16 @@ function populateFilters() {
}); });
} }
function populateSectieFilter(vakId) {
const sel = document.getElementById('filterSectie');
sel.innerHTML = '<option value="all">Alle secties</option>';
if (!vakId || vakId === 'all' || !allGoals[vakId]) return;
const secties = [...new Set(allGoals[vakId].map(g => g.sectie).filter(Boolean))].sort();
secties.forEach(s => {
const o = document.createElement('option'); o.value = s; o.textContent = s; sel.appendChild(o);
});
}
function applyFilters() { function applyFilters() {
const vakFilter = document.getElementById('filterVak').value; const vakFilter = document.getElementById('filterVak').value;
const klasFilter = document.getElementById('filterKlas').value; const klasFilter = document.getElementById('filterKlas').value;
@@ -505,6 +550,11 @@ function applyFilters() {
const leeftFilter = [...document.querySelectorAll('#leeftijdCbs input:checked')].map(c=>c.value); const leeftFilter = [...document.querySelectorAll('#leeftijdCbs input:checked')].map(c=>c.value);
const search = document.getElementById('filterSearch').value.toLowerCase(); const search = document.getElementById('filterSearch').value.toLowerCase();
const sectieFilter = document.getElementById('filterSectie').value;
// Sectie filter opties bijwerken als vak wijzigt
populateSectieFilter(vakFilter);
const vakkenToShow = vakFilter === 'all' ? Object.keys(allGoals) : [vakFilter]; const vakkenToShow = vakFilter === 'all' ? Object.keys(allGoals) : [vakFilter];
const klassen = klasFilter === 'all' const klassen = klasFilter === 'all'
? (overviewData.classes||[]) ? (overviewData.classes||[])
@@ -515,6 +565,7 @@ function applyFilters() {
(allGoals[vakId]||[]).forEach(goal => { (allGoals[vakId]||[]).forEach(goal => {
if (search && !(goal.goNr+' '+goal.inhoud).toLowerCase().includes(search)) return; if (search && !(goal.goNr+' '+goal.inhoud).toLowerCase().includes(search)) return;
if (leeftFilter.length > 0 && !leeftFilter.some(l=>goal.leeftijden.includes(l))) return; if (leeftFilter.length > 0 && !leeftFilter.some(l=>goal.leeftijden.includes(l))) return;
if (sectieFilter !== 'all' && goal.sectie !== sectieFilter) return;
const statussen = klassen.map(k => (overviewData.assessments_by_class[k.id]?.[vakId]?.[goal.id])||''); const statussen = klassen.map(k => (overviewData.assessments_by_class[k.id]?.[vakId]?.[goal.id])||'');
if (statusFilter==='consensus' && !statussen.every(s=>s==='groen')) return; if (statusFilter==='consensus' && !statussen.every(s=>s==='groen')) return;
if (statusFilter==='verschil') { if (statusFilter==='verschil') {
@@ -662,6 +713,71 @@ function exportPDF() {
showNotification('PDF geëxporteerd', 'success'); showNotification('PDF geëxporteerd', 'success');
} }
// ── Koppeling tab ─────────────────────────────────────────────────────────────
let allUsers = [];
let activeKlasKoppeling = null;
async function renderKoppelingTab() {
const el = document.getElementById('koppelingContent');
const klassen = overviewData.classes || [];
if (!klassen.length) { el.innerHTML = '<div class="empty">Geen klassen gevonden</div>'; return; }
// Laad alle leerkrachten van de school
try {
const res = await fetch('/api/users');
if (res.ok) { const d = await res.json(); allUsers = d.users || []; }
} catch(e) { console.warn('Kon gebruikers niet laden'); }
el.innerHTML = '<div class="vak-grid">' + klassen.map(k => {
const teachers = (k.teachers||[]).map(t => t.full_name).join(', ') || '<em style="color:var(--gray-400)">Geen leerkrachten</em>';
return `<div class="vak-card" style="cursor:pointer;" data-action="openKoppeling" data-id="${k.id}" data-name="${k.name.replace(/"/g,'&quot;')}" data-teachers="${JSON.stringify((k.teachers||[]).map(t=>t.id)).replace(/"/g,'&quot;')}">
<div class="vak-card-header">
<h3>🏫 ${k.name}</h3>
<span style="font-size:.75rem;background:var(--primary);color:white;padding:.2rem .5rem;border-radius:4px;">Wijzigen</span>
</div>
<div style="font-size:.82rem;color:var(--gray-600);">${teachers}</div>
</div>`;
}).join('') + '</div>';
}
document.addEventListener('click', function(e) {
const card = e.target.closest('[data-action="openKoppeling"]');
if (!card) return;
activeKlasKoppeling = parseInt(card.dataset.id);
const name = card.dataset.name;
const teachers = JSON.parse(card.dataset.teachers.replace(/&quot;/g, '"'));
document.getElementById('koppelingModalTitle').textContent = `Leerkrachten voor ${name}`;
const container = document.getElementById('koppelingCheckboxes');
if (!allUsers.length) {
container.innerHTML = '<em style="color:var(--gray-400)">Geen leerkrachten beschikbaar. Voeg eerst leerkrachten toe via Gebruikersbeheer.</em>';
} else {
container.innerHTML = allUsers.map(u => `
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;padding:.35rem;border-radius:4px;">
<input type="checkbox" value="${u.id}" ${teachers.includes(u.id)?'checked':''}>
<span>${u.full_name} <span style="color:var(--gray-400);font-size:.8rem;">(${u.email})</span></span>
</label>`).join('');
}
document.getElementById('koppelingModal').style.display = 'flex';
});
async function saveKoppeling() {
if (!activeKlasKoppeling) return;
const checked = [...document.querySelectorAll('#koppelingCheckboxes input:checked')].map(i => parseInt(i.value));
// Gebruik de admin route om leerkrachten aan klas te koppelen
const res = await fetch(`/api/classes/${activeKlasKoppeling}/teachers`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ teacher_ids: checked })
});
if (!res.ok) { showNotification('Opslaan mislukt', 'error'); return; }
document.getElementById('koppelingModal').style.display = 'none';
showNotification('Koppeling opgeslagen', 'success');
await loadOverview(); // herlaad zodat klas-chips bijgewerkt worden
}
// ── Tabs ─────────────────────────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────────────────────────
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab===name)); document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab===name));