From ee8fcb231b13bb00d9cbb8af97048a7437c96c8a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Mar 2026 23:10:31 +0100 Subject: [PATCH] feat: enhance user management table with search functionality and improved layout --- backend/templates/scholengroep_ict.html | 307 +++++++++++++++++++----- 1 file changed, 241 insertions(+), 66 deletions(-) diff --git a/backend/templates/scholengroep_ict.html b/backend/templates/scholengroep_ict.html index e845c57..ba4903c 100644 --- a/backend/templates/scholengroep_ict.html +++ b/backend/templates/scholengroep_ict.html @@ -49,12 +49,20 @@ .domain-chip{display:inline-block;padding:.15rem .45rem;background:#eff6ff;color:#1d4ed8;border-radius:4px;font-size:.72rem;margin:.1rem;border:1px solid #bfdbfe;} - /* Scholen grid */ - .schools-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:1rem;} - .school-card{border:1px solid var(--gray-200);border-radius:10px;overflow:hidden;} - .school-card-header{padding:.9rem 1.1rem;background:var(--gray-50);border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;} - .school-card-header h3{font-size:.95rem;} - .school-card-body{padding:.75rem 1.1rem;} + /* Gebruikers-per-school tabel */ + .schools-user-table{width:100%;border-collapse:collapse;} + .schools-user-table th{padding:.6rem .85rem;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);border-bottom:2px solid var(--gray-200);background:var(--gray-50);} + .school-row td{padding:.7rem .85rem;border-bottom:1px solid var(--gray-100);vertical-align:middle;font-size:.875rem;} + .school-row{cursor:pointer;transition:background .12s;} + .school-row:hover td{background:var(--gray-50);} + .school-row.expanded td{background:#f0f9ff;border-bottom:none;} + .expand-icon{display:inline-block;transition:transform .2s;font-style:normal;width:18px;text-align:center;} + .school-row.expanded .expand-icon{transform:rotate(90deg);} + .users-panel-row td{padding:0;border-bottom:1px solid var(--gray-200);} + .users-panel{padding:.75rem 1.1rem 1rem;background:#f8fafc;display:none;} + .users-panel.open{display:block;} + .users-search{width:100%;max-width:280px;padding:.4rem .65rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.82rem;margin-bottom:.75rem;} + .users-search:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 2px rgba(79,70,229,.1);} .user-row{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--gray-100);font-size:.84rem;gap:.5rem;} .user-row:last-child{border-bottom:none;} .user-info{flex:1;min-width:0;} @@ -64,6 +72,10 @@ .role-select{padding:.2rem .4rem;border:1px solid var(--gray-300);border-radius:4px;font-size:.75rem;cursor:pointer;background:white;color:var(--gray-700);} .role-select:focus{outline:none;border-color:var(--primary);} .group-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin:.6rem 0 .3rem;display:flex;justify-content:space-between;} + .school-search-bar{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem;} + .school-search-input{flex:1;max-width:320px;padding:.5rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.875rem;} + .school-search-input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(79,70,229,.1);} + .school-count-badge{font-size:.75rem;color:var(--gray-500);} /* Modal */ .modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;} @@ -91,8 +103,10 @@ th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;} td{border-color:#1e293b !important;color:#e2e8f0;} tr:hover td{background:#263548 !important;} - .school-card{border-color:#334155 !important;} - .school-card-header{background:#162032 !important;border-color:#334155 !important;} + .school-row.expanded td{background:#1a2744 !important;} + .users-panel{background:#162032 !important;} + .users-search{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;} + .school-search-input{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;} .domain-chip{background:#1e3a5f !important;border-color:#2563eb !important;color:#93c5fd !important;} .role-select,.form-group input,.form-group select{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;} .btn-secondary{background:#334155 !important;color:#e2e8f0 !important;} @@ -184,13 +198,32 @@ toevoegen - +

đŸ‘Ĩ Gebruikers per school

-
Laden...
+ +
+ + + + + + + + + + + + +
SchoolDomeinenSSOGebruikersActies
+
@@ -390,6 +423,14 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar); document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal); document.getElementById('btnSaveUser') && bind('btnSaveUser', 'click', addUser); + // Zoekbalk scholen/gebruikers + const schoolSearchEl = document.getElementById('schoolSearchInput'); + if (schoolSearchEl) { + schoolSearchEl.addEventListener('input', () => { + schoolFilter = schoolSearchEl.value.trim(); + renderSchoolsUserTable(); + }); + } const tasks = [loadStats(), loadSchoolsTable(), loadSchoolsGrid(), loadSgIct()]; await Promise.all(tasks); await loadJaren(); @@ -486,6 +527,7 @@ async function addSchool() { const data = await res.json(); if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; } closeModal(); notify('School aangemaakt', 'success'); + delete loadedUsers[data.school?.id]; // forceer herlaad als al gecached await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]); } @@ -553,6 +595,8 @@ async function saveSchool() { closeModal(); notify('School opgeslagen', 'success'); + // Gebruikerscache wissen zodat heropenen verse data toont + delete loadedUsers[parseInt(id)]; await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]); } @@ -561,114 +605,239 @@ async function deleteSchool(id, name) { const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' }); if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } notify('School verwijderd', 'success'); + expandedSchools.delete(parseInt(id)); + delete loadedUsers[parseInt(id)]; await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]); } -// ── Gebruikers grid ─────────────────────────────────────────────────────────── +// ── Gebruikers-per-school: lazy tabel ──────────────────────────────────────── +// State +const expandedSchools = new Set(); // welke school-rijen zijn open +const loadedUsers = {}; // cache: school_id -> users array +const loadingSchools = new Set(); // bezig met laden +let schoolFilter = ''; // huidig zoekterm + +// Laad de scholenlijst (zonder gebruikers) en render de tabel async function loadSchoolsGrid() { const res = await fetch('/admin/schools'); const data = await res.json(); schools = data.schools || []; - const grid = document.getElementById('schoolsGrid'); - if (!schools.length) { - grid.innerHTML = '

Nog geen scholen aangemaakt.

'; + renderSchoolsUserTable(); + updateSchoolCountBadge(); + + // Vul ook de school select in het "gebruiker toevoegen" modal + const sel = document.getElementById('addUserSchool'); + if (sel) sel.innerHTML = schools.map(s => ``).join(''); +} + +// Teken (of herteken) de volledige tabel op basis van huidige filter + expanded state +function renderSchoolsUserTable() { + const tbody = document.getElementById('schoolsUserTbody'); + const term = schoolFilter.toLowerCase(); + // Filter: schoolnaam OF (als school al geladen) gebruikersnaam + const filtered = schools.filter(s => { + if (s.name.toLowerCase().includes(term)) return true; + const users = loadedUsers[s.id]; + if (users && term) return users.some(u => u.full_name.toLowerCase().includes(term)); + return !term; + }); + + updateSchoolCountBadge(filtered.length); + + if (!filtered.length) { + tbody.innerHTML = `Geen scholen gevonden voor "${schoolFilter}"`; return; } - grid.innerHTML = ''; - await Promise.all(schools.map(s => renderSchoolCard(s, grid))); + + const rows = filtered.map(s => buildSchoolRows(s, term)).join(''); + tbody.innerHTML = rows; } -async function renderSchoolCard(school, container) { - const res = await fetch(`/admin/schools/${school.id}/users`); - const data = await res.json(); - const users = data.users || []; +function buildSchoolRows(school, term) { + const isOpen = expandedSchools.has(school.id); + const isLoading = loadingSchools.has(school.id); + const ssoIcon = school.google_sso_configured + ? 'G ✓' + : '—'; + const domainHtml = (school.email_domains||[]).map(d=>`${d}`).join('') + || 'geen'; + + // Hoofdrij + const mainRow = ` + + â–ļ + ${school.name} + ${domainHtml} + ${ssoIcon} + ${school.user_count} + + + + + `; + + // Uitklapbare gebruikerspaneel rij + const panelRow = ` + + +
+ ${isLoading + ? '
âŗ Laden...
' + : renderUsersPanel(school.id, term)} +
+ + `; + + return mainRow + panelRow; +} + +function renderUsersPanel(schoolId, term) { + const users = loadedUsers[schoolId]; + if (!users) return ''; // nog niet geladen + + // Filter op gebruikersnaam als er een zoekterm is + const filtered = term + ? users.filter(u => u.full_name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)) + : users; + const byRole = { - school_ict: users.filter(u => u.role === 'school_ict'), - director: users.filter(u => u.role === 'director'), - teacher: users.filter(u => u.role === 'teacher'), + school_ict: filtered.filter(u => u.role === 'school_ict'), + director: filtered.filter(u => u.role === 'director'), + teacher: filtered.filter(u => u.role === 'teacher'), }; - const card = document.createElement('div'); - card.className = 'school-card'; - card.id = `school-card-${school.id}`; - card.innerHTML = ` -
-
-

${school.name}

-
${(school.email_domains||[]).map(d=>`${d}`).join('') || 'geen domeinen'}
-
- ${users.length} gebruikers -
-
- ${renderUserGroup(school.id,'School ICT',byRole.school_ict)} - ${renderUserGroup(school.id,'Directeurs',byRole.director)} - ${renderUserGroup(school.id,'Leerkrachten',byRole.teacher,5)} -
`; - container.appendChild(card); + const total = filtered.length; + + if (!total) return `
Geen gebruikers gevonden${term ? ` voor "${term}"` : ''}.
`; + + return [ + renderUserGroup(schoolId, 'School ICT', byRole.school_ict), + renderUserGroup(schoolId, 'Directeurs', byRole.director), + renderUserGroup(schoolId, 'Leerkrachten', byRole.teacher), + ].join(''); } -function renderUserGroup(schoolId, label, users, maxShow=99) { +function renderUserGroup(schoolId, label, users) { if (!users.length) return ''; - const shown = users.slice(0, maxShow); - const hidden = users.length - shown.length; return ` -
${label}${users.length}
- ${shown.map(u => ` +
+ ${label} + ${users.length} +
+ ${users.map(u => `
-
`).join('')} - ${hidden > 0 ? `
+ ${hidden} meer...
` : ''}`; + `).join('')}`; } -async function changeRole(schoolId, userId, newRole, naam, selectEl) { +function updateSchoolCountBadge(visibleCount) { + const el = document.getElementById('schoolCountBadge'); + if (!el) return; + const total = schools.length; + if (visibleCount === undefined || visibleCount === total) { + el.textContent = `${total} school${total !== 1 ? 'en' : ''}`; + } else { + el.textContent = `${visibleCount} van ${total} scholen`; + } +} + +// Toggle een school open/dicht; laad gebruikers als dat nog niet is gebeurd +async function toggleSchool(schoolId) { + if (expandedSchools.has(schoolId)) { + expandedSchools.delete(schoolId); + renderSchoolsUserTable(); + return; + } + + expandedSchools.add(schoolId); + + if (!loadedUsers[schoolId]) { + // Toon laad-indicator via hertekenen (loading state) + loadingSchools.add(schoolId); + renderSchoolsUserTable(); + + const res = await fetch(`/admin/schools/${schoolId}/users`); + const data = await res.json(); + loadedUsers[schoolId] = data.users || []; + loadingSchools.delete(schoolId); + } + + renderSchoolsUserTable(); +} + +// Herlaad de gebruikers van ÊÊn school (na wijziging) en herteken +async function refreshSchool(schoolId) { + const res = await fetch(`/admin/schools/${schoolId}/users`); + const data = await res.json(); + loadedUsers[schoolId] = data.users || []; + // Bijwerken user_count in schools array + const school = schools.find(s => s.id === schoolId); + if (school) school.user_count = (data.users || []).length; + renderSchoolsUserTable(); +} + +async function changeRole(schoolId, userId, newRole, naam) { const res = await fetch(`/admin/schools/${schoolId}/users/${userId}/role`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ role: newRole }) }); - if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshCard(schoolId); return; } + if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshSchool(schoolId); return; } notify(`${naam} is nu ${SCHOOL_ROLLEN.find(r=>r.value===newRole)?.label}`, 'success'); - await refreshCard(schoolId); + await refreshSchool(schoolId); } async function addUser() { const err = document.getElementById('addUser-error'); err.style.display = 'none'; - const schoolId = document.getElementById('addUserSchool').value; + const schoolId = parseInt(document.getElementById('addUserSchool').value); const res = await fetch(`/admin/schools/${schoolId}/users`, { method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ email: document.getElementById('addUserEmail').value, first_name: document.getElementById('addUserFirst').value, last_name: document.getElementById('addUserLast').value, role: document.getElementById('addUserRole').value }) + body: JSON.stringify({ + email: document.getElementById('addUserEmail').value, + first_name: document.getElementById('addUserFirst').value, + last_name: document.getElementById('addUserLast').value, + role: document.getElementById('addUserRole').value, + }) }); const data = await res.json(); if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; } - closeModal(); notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success'); - await Promise.all([refreshCard(parseInt(schoolId)), loadStats()]); + closeModal(); + notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success'); + // Zorg dat de school open staat zodat de nieuwe gebruiker zichtbaar is + expandedSchools.add(schoolId); + delete loadedUsers[schoolId]; // forceer herlaad + await Promise.all([refreshSchool(schoolId), loadStats()]); } async function removeUser(schoolId, userId, naam) { if (!confirm(`${naam} verwijderen?`)) return; - const res = await fetch(`/admin/schools/${schoolId}/users/${userId}`, { method: 'DELETE' }); + const res = await fetch(`/admin/schools/${parseInt(schoolId)}/users/${userId}`, { method: 'DELETE' }); if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } notify(`${naam} verwijderd`, 'success'); - await Promise.all([refreshCard(schoolId), loadStats()]); -} - -async function refreshCard(schoolId) { - const card = document.getElementById(`school-card-${schoolId}`); - const school = schools.find(s => s.id === schoolId); - if (card && school) { card.remove(); await renderSchoolCard(school, document.getElementById('schoolsGrid')); } + delete loadedUsers[parseInt(schoolId)]; // forceer herlaad + await Promise.all([refreshSchool(parseInt(schoolId)), loadStats()]); } // ── Schooljaren ─────────────────────────────────────────────────────────────── @@ -798,6 +967,12 @@ function notify(msg, type='success') { // ── Event delegation voor dynamisch gegenereerde elementen ──────────────────── document.addEventListener('click', function(e) { + // toggleSchool: klik op de rij zelf, maar NIET op knoppen erin + const schoolRow = e.target.closest('.school-row[data-action="toggleSchool"]'); + if (schoolRow && !e.target.closest('button') && !e.target.closest('select')) { + toggleSchool(parseInt(schoolRow.dataset.id)); + return; + } const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; @@ -810,7 +985,7 @@ document.addEventListener('click', function(e) { }); document.addEventListener('change', function(e) { const sel = e.target.closest('[data-action="changeRole"]'); - if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name, sel); } + if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name); } });