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
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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,'"')}" data-teachers="${JSON.stringify((k.teachers||[]).map(t=>t.id)).replace(/"/g,'"')}">
|
||||||
|
<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(/"/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));
|
||||||
|
|||||||
Reference in New Issue
Block a user