teach/public/js/admin.js
Ramon df921d5968
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
v0.3.36-beta: scholen mét schoolbeheerders in het Systeem-tabblad
- Systeem toont per school een rij in klassen-stijl (🏫, accentkleur,
  aantal schoolbeheerders) met daaronder de schoolbeheerders als duidelijk
  onderscheiden subrijen (🛡, ingesprongen, eigen randkleur)
- Beide zijn in dit menu aan te passen via hetzelfde ✏-gereedschap:
  de school heeft een Naam-veld (nieuwe route PATCH /admin/schools/:id,
  alleen systeemmanager) en de bevestigde verwijder-actie; beheerders
  hebben hun volledige gereedschap (functie, extra functies, code,
  wachtwoord, verwijderen)
- Systeem-tab laadt beheerders van álle scholen, ook als de contextbalk
  elders op één school staat
- Drie nieuwe servertests voor het hernoemen (super ok, admin 403,
  naamvalidatie)
2026-07-15 16:02:57 +02:00

934 lines
40 KiB
JavaScript
Raw 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.

/* teach - beheerpaneel (👥): gebruikers, klassen en scholen.
Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */
"use strict";
(function(){
/* het beheerpaneel is een tabblad binnen Instellingen (settingsWrap/settingsModal),
geen los venster meer - modal is alleen nog de content-container voor render() */
const modal = document.getElementById("adminModal");
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
/* schoolbeheerders van alle scholen, voor het Systeem-tabblad (super) */
let SYS_ADMINS = [];
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
let ASSIGNMENTS = {};
/* resultaat van een laatste multireset (klas/school): blijft zichtbaar
totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet
meteen weer verdwijnen na een reload() */
let resetResults = null;
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
let adminTab = "gebruikers";
/* live zoekfilter voor de gebruikerslijst, blijft ook behouden over her-renders */
let userFilter = "";
/* uitgeklapte leerling-groepen en gebruikersrijen (standaard alles dicht
voor een rustig overzicht), blijven behouden over her-renders */
const openGroups = new Set();
const openRows = new Set();
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
const creatableRoles = () =>
[...new Set([currentUser.role, ...(currentUser.extraRoles||[])].flatMap(r => CREATABLE_ROLES[r] || []))];
const esc = s => String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;");
const h = (tag, cls, text)=>{
const el = document.createElement(tag);
if(cls) el.className = cls;
if(text != null) el.textContent = text;
return el;
};
/* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop
zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */
async function copyText(btn, text){
try{ await navigator.clipboard.writeText(text); }
catch(e){
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ }
document.body.removeChild(ta);
}
const orig = btn.textContent;
btn.textContent = "✅";
setTimeout(()=>{ btn.textContent = orig; }, 1200);
}
function copyBtn(text, cls){
const b = h("button", cls || "am-copy", "📋");
b.type = "button";
b.title = T("amCopy");
b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); });
return b;
}
const msg = (t, copyValue) => {
const m = modal.querySelector(".am-msg");
if(!m) return;
m.innerHTML = "";
if(!t) return;
m.appendChild(document.createTextNode(t));
if(copyValue) m.appendChild(copyBtn(copyValue));
};
/* ---- één interactiesysteem voor het hele beheer ---------------------------
field(): elk invoerveld/select krijgt een zichtbaar mini-label erboven
(een placeholder alleen is geen toelichting).
dangerBtn(): acties met gevolgen (wachtwoord/koppelcode vernieuwen,
verwijderen, resetten) zien er anders uit dan gewone knoppen
en vragen een tweede klik als bevestiging (zelfde
arm-then-confirm als elders in de app); wapening loopt
vanzelf af. Geen native confirm()-dialogen meer.
rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij
de gebruiker in de rij (met kopieerknop), niet bovenaan. */
function field(labelKey, control){
const w = h("label","am-field");
w.appendChild(h("span","am-field-lbl", T(labelKey)));
w.appendChild(control);
return w;
}
function dangerBtn(label, warnKey, fn){
const b = h("button","am-btn am-danger", label);
b.type = "button";
if(warnKey) b.title = T(warnKey);
let timer = null;
b.addEventListener("click", async ()=>{
if(!timer){
b.classList.add("armed");
b.textContent = "⚠ " + T("amSure");
timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000);
return;
}
clearTimeout(timer); timer = null;
b.classList.remove("armed"); b.textContent = label;
await fn(b);
});
return b;
}
/* per gebruiker het laatste credential-resultaat, overleeft de re-render
na de actie zodat het bij de rij zichtbaar is tot het paneel sluit */
const rowResults = new Map();
document.addEventListener("userchange", ()=>rowResults.clear());
function resultBadge(u){
const r = rowResults.get(u.id);
if(!r) return null;
const badge = h("div","am-result");
badge.appendChild(h("span", null, r.label + ":"));
const code = h("code", null, r.value);
badge.appendChild(code);
badge.appendChild(copyBtn(r.value));
return badge;
}
/* de beheer-tabs staan als losse knoppen in de ene instellingen-tabbalk
(#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */
const adminTabDefs = ()=>{
const defs = [];
if(currentUser && currentUser.role==="super") defs.push(["scholen", T("amOverarching")]);
defs.push(["klassen", T("amClasses")]);
defs.push(["gebruikers", T("amUsers")]);
defs.push(["toewijzingen", T("amAssignments")]);
defs.push(["voortgang", T("amProgress")]);
return defs;
};
function buildAdminTabs(){
const slot = document.getElementById("adminTabsSlot");
slot.innerHTML = "";
if(!currentUser || !can("admin.access")) return;
adminTabDefs().forEach(([t, label])=>{
const b = h("button", null, label);
b.type = "button";
b.dataset.tab = "admin";
b.dataset.admintab = t;
b.addEventListener("click", async ()=>{
adminTab = t;
/* data (opnieuw) laden bij binnenkomst in het beheer-deel; wisselen
tussen beheer-tabs onderling hergebruikt de geladen data */
const adminVisible = document.getElementById("settingsAdminPanel").style.display !== "none";
if(!adminVisible){
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
}
switchSettingsTab("admin");
switchAdminTab(t);
});
slot.appendChild(b);
});
}
document.addEventListener("userchange", ()=>{
if(!currentUser) resetResults = null;
buildAdminTabs();
});
document.addEventListener("langchange", buildAdminTabs);
async function reload(){
try{
if(currentUser.role==="super") SCHOOLS = await api("/schools");
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
USERS = (await api("/admin/users"+q)).users;
/* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als
de contextbalk op één school staat; zonder schoolfilter is USERS al
compleet en is de extra call overbodig */
if(currentUser.role==="super"){
SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin")
: USERS.filter(u=>u.role==="admin");
}
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
ASSIGNMENTS = {};
if(CLASSES.length){
const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null}))));
CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; });
}
}catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; }
render();
}
function classSel(current){
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amNoClass"), ""));
CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id)));
sel.value = current ?? "";
return sel;
}
function userRow(u, opts){
const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : ""));
/* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden
zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */
const tools = h("div","am-row-tools");
const actions = h("div","am-actions");
const icon = opts && opts.icon ? opts.icon + " " : "";
row.appendChild(h("span","am-name", icon + u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
if(u.role==="pupil"){
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
}else{
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
}
if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){
/* super mag de rol van andere stafaccounts wijzigen - leerlingen blijven altijd leerling */
const rs = h("select","am-sel");
["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r)));
rs.value = u.role;
rs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldRole", rs));
/* extra functies naast de hoofdrol (bv. een teacher die ook beheerdersrechten
krijgt) - los van de hoofdrol hierboven, direct toggelbaar per rol */
const extraWrap = h("div","am-classes");
["teacher","admin","super"].filter(r=>r!==u.role).forEach(r=>{
const has = (u.extraRoles||[]).includes(r);
const chip = h("button","am-chip", (has?"✓ ":"+ ")+T(roleKey(r)));
chip.type = "button";
chip.classList.toggle("on", has);
chip.addEventListener("click", async ()=>{
const next = has ? (u.extraRoles||[]).filter(x=>x!==r) : [...(u.extraRoles||[]), r];
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{extraRoles: next}}); reload(); }
catch(e){ msg(e.message); }
});
extraWrap.appendChild(chip);
});
tools.appendChild(field("amFieldExtraRoles", extraWrap));
}
if(u.role==="pupil"){
/* klas (uitwisselen met andere klassen) */
const cs = classSel(u.classId);
cs.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldClass", cs));
/* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling
nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */
const pwWrap = h("div","am-inline");
const pw = h("input","am-pw");
pw.value = u.password || "";
pw.placeholder = u.everLoggedIn ? T("amPwHidden") : T("amPwTypePh");
pw.addEventListener("keydown", ev=>ev.stopPropagation());
pw.addEventListener("change", async ()=>{
if(pw.value.length < 4) return;
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); }
catch(e){ msg(e.message); }
});
pwWrap.appendChild(pw);
const pwCopy = h("button","am-copy","📋");
pwCopy.type = "button";
pwCopy.title = T("amCopy");
pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); });
pwWrap.appendChild(pwCopy);
tools.appendChild(field("amFieldPw", pwWrap));
if(u.everLoggedIn){
actions.appendChild(dangerBtn("🎲 " + T("amNewPw"), "amNewPwWarn", async ()=>{
try{
const { password } = await api("/admin/genpw?role=pupil");
await api("/admin/users/"+u.id, {method:"PATCH", body:{password}});
rowResults.set(u.id, { label: T("amResultPw"), value: password });
openRows.add(u.id);
reload();
}catch(e){ msg(e.message); }
}));
}
}else{
if(u.pending && u.linkCode){
tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode));
}
if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){
actions.appendChild(dangerBtn("🔑 " + T("amNewCode"), "amNewCodeWarn", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}});
rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode });
openRows.add(u.id);
reload();
}catch(e){ msg(e.message); }
}));
/* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */
actions.appendChild(dangerBtn("🎲 " + T("amNewPw"), "amNewPwWarn", async ()=>{
try{
const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}});
rowResults.set(u.id, { label: T("amResultPw"), value: r.password });
openRows.add(u.id);
reload();
}catch(e){ msg(e.message); }
}));
}
}
if(u.id !== currentUser.id){
const del = dangerBtn("🗑 " + T("amDelete"), "amDeleteWarn", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
});
actions.appendChild(del);
}
const badge = resultBadge(u);
if(badge) tools.appendChild(badge);
if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions));
if(tools.childNodes.length){
const edit = h("button","am-edit","✏");
edit.type = "button";
edit.title = T("amEdit");
const sync = ()=>{
const open = openRows.has(u.id);
tools.style.display = open ? "" : "none";
edit.classList.toggle("on", open);
};
edit.addEventListener("click", ()=>{
if(openRows.has(u.id)) openRows.delete(u.id); else openRows.add(u.id);
sync();
});
sync();
row.appendChild(edit);
row.appendChild(tools);
}
return row;
}
/* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de " …"-knop,
pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op
de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */
function addForm(role, presetClassId){
const label = T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper");
const wrap = h("div","am-add-wrap");
const toggle = h("button","tbtn ghost", label);
toggle.type = "button";
wrap.appendChild(toggle);
const f = h("div","am-add");
f.style.display = "none";
const name = h("input","am-inp");
name.placeholder = T("amUserPh");
name.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(field("amFieldName", name));
let cs = null;
if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(field("amFieldClass", cs)); }
let pwInp = null;
if(role!=="pupil"){
/* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode
(die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */
const pwWrap = h("div","am-inline");
pwInp = h("input","am-inp am-pwinp");
pwInp.type = "password";
pwInp.autocomplete = "new-password";
pwInp.placeholder = T("amPwOptional");
pwInp.addEventListener("keydown", ev=>ev.stopPropagation());
pwWrap.appendChild(pwInp);
const gen = h("button","am-btn","🎲");
gen.type = "button";
gen.title = T("amGenerate");
gen.addEventListener("click", async ()=>{
try{
const { password } = await api("/admin/genpw");
pwInp.type = "text";
pwInp.value = password;
}catch(e){ msg(e.message); }
});
pwWrap.appendChild(gen);
f.appendChild(field("amFieldPw", pwWrap));
}
const go = h("button","tbtn", label);
go.type = "button";
go.disabled = true;
name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; });
go.addEventListener("click", async ()=>{
const body = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) body.classId = +cs.value;
if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim();
if(currentUser.role==="super" && role!=="super") body.school = +selSchool;
try{
const r = await api("/admin/users", { body });
name.value = "";
if(pwInp) pwInp.value = "";
await reload();
msg(role==="pupil"
? `${T("amPupilMade")} ${r.user.password}`
: r.user.password
? `${T("amPwSet")} ${r.user.password}`
: `${T("amCreated")} ${r.user.linkCode}`,
r.user.password || r.user.linkCode);
}catch(e){ msg(e.message); }
});
f.appendChild(go);
wrap.appendChild(f);
toggle.addEventListener("click", ()=>{
toggle.style.display = "none";
f.style.display = "flex";
name.focus();
});
return wrap;
}
/* paneel met het resultaat van een multireset (klas/school): een lijst
nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens
gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */
function resetPanel(){
if(!resetResults) return null;
const box = h("div","am-reset");
const head = h("div","am-reset-head");
head.appendChild(h("strong",null, resetResults.title));
const close = h("button","wclose","✕");
close.addEventListener("click", ()=>{ resetResults = null; render(); });
head.appendChild(close);
box.appendChild(head);
if(!resetResults.entries.length){
box.appendChild(h("div","am-msg", T("amResetNone")));
return box;
}
const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n");
const copyAll = h("button","am-btn","📋 "+T("amCopyAll"));
copyAll.type = "button";
copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); });
box.appendChild(copyAll);
const list = h("div","am-reset-list");
resetResults.entries.forEach(en=>{
const row = h("div","am-reset-row");
row.appendChild(h("span","am-name", en.displayName));
row.appendChild(h("span","am-reset-pw", en.password));
row.appendChild(copyBtn(`${en.displayName}: ${en.password}`));
list.appendChild(row);
});
box.appendChild(list);
return box;
}
/* systeem: overzicht van alle scholen + nieuwe school + systeemmanagers.
De schoolkiezer-contextbalk is hier verborgen (zie switchAdminTab), want
niets op dit tabblad hangt van een gekozen school af. Toevoegen zit
achter een bewuste extra stap (-knop klapt het formulier uit, zoals
bij gebruikers) en bevestigt daarna nog met de naam erin. */
function renderScholenPanel(panel){
const list = h("div","am-list");
SCHOOLS.forEach(s=>{
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
const row = h("div","am-row am-row-school");
row.appendChild(h("span","am-name", "🏫 " + s.name));
row.appendChild(h("span","am-role",
`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`));
/* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen
zitten achter het gereedschapsblok, de rij zelf blijft rustig */
const tools = h("div","am-row-tools");
const nameInp = h("input","am-inp");
nameInp.value = s.name;
nameInp.maxLength = 80;
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
nameInp.addEventListener("change", async ()=>{
const name = nameInp.value.trim();
if(name.length < 2 || name === s.name) return;
try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldName", nameInp));
const acts = h("div","am-actions");
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{
try{
await api("/admin/schools/"+s.id, {method:"DELETE"});
if(String(selSchool)===String(s.id)) selSchool = "";
reload();
}catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", acts));
const edit = h("button","am-edit","✏");
edit.type = "button";
edit.title = T("amEdit");
const key = "school:"+s.id;
const sync = ()=>{
const open = openRows.has(key);
tools.style.display = open ? "" : "none";
edit.classList.toggle("on", open);
};
edit.addEventListener("click", ()=>{
if(openRows.has(key)) openRows.delete(key); else openRows.add(key);
sync();
});
sync();
row.appendChild(edit);
row.appendChild(tools);
list.appendChild(row);
/* de schoolbeheerders van deze school, duidelijk onderscheiden van de
schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */
admins.forEach(a=>list.appendChild(userRow(a, { icon:"🛡", sub:true })));
});
panel.appendChild(list);
const wrap = h("div","am-add-wrap");
const toggle = h("button","tbtn ghost", T("amNewSchool"));
toggle.type = "button";
wrap.appendChild(toggle);
const f = h("div","am-add");
f.style.display = "none";
const inp = h("input","am-inp");
inp.placeholder = T("amSchoolNamePh");
inp.maxLength = 80;
inp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(field("amFieldName", inp));
const add = h("button","tbtn", T("amNewSchool"));
add.type = "button";
add.disabled = true;
let armTimer = null;
const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); };
inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); });
add.addEventListener("click", async ()=>{
const name = inp.value.trim();
if(name.length < 2) return;
if(!armTimer){
add.classList.add("danger");
add.textContent = `${T("amNewSchoolConfirm")}${name}”?`;
armTimer = setTimeout(disarm, 5000);
return;
}
disarm();
try{
const r = await api("/admin/schools", { body:{ name } });
selSchool = r.school.id; inp.value = "";
reload();
}catch(e){ msg(e.message); }
});
f.appendChild(add);
wrap.appendChild(f);
toggle.addEventListener("click", ()=>{
toggle.style.display = "none";
f.style.display = "flex";
inp.focus();
});
panel.appendChild(wrap);
/* systeemmanagers horen niet bij één school, dus het aanmaken ervan
staat hier i.p.v. tussen de school-gebonden gebruikers in de Gebruikers-tab
(de lijst van bestaande supers blijft wel gewoon in Gebruikers staan) */
if(creatableRoles().includes("super")) panel.appendChild(addForm("super"));
}
/* klassen (admin/super binnen een school) */
function renderKlassenPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
const canResetPw = can("schools.resetPasswords");
/* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken),
met de gevaarlijke acties als aparte, bevestigde knoppen */
const list = h("div","am-list");
CLASSES.forEach(c=>{
const row = h("div","am-row");
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
row.appendChild(h("span","am-name", c.name));
row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`));
if(canResetPw){
const acts = h("div","am-actions");
acts.appendChild(dangerBtn("🔄 " + T("amResetClass"), "amConfirmReset", async ()=>{
try{
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteClassWarn", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
}));
row.appendChild(acts);
}
list.appendChild(row);
});
panel.appendChild(list);
if(canResetPw){
const f = h("div","am-add");
const inp = h("input","am-inp");
inp.placeholder = T("amClassNamePh");
inp.maxLength = 80;
inp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(field("amFieldName", inp));
const go = h("button","tbtn", T("amNewClass"));
go.type = "button";
go.disabled = true;
inp.addEventListener("input", ()=>{ go.disabled = inp.value.trim().length < 2; });
go.addEventListener("click", async ()=>{
if(inp.value.trim().length < 2) return;
try{
await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } });
reload();
}catch(e){ msg(e.message); }
});
f.appendChild(go);
panel.appendChild(f);
/* multireset: alle leerlingwachtwoorden van de hele school in één keer */
panel.appendChild(dangerBtn("🔄 " + T("amResetSchool"), "amConfirmResetSchool", async ()=>{
const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId;
if(!schoolId) return;
try{
const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: T("amResetSchool"), entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
}
}
/* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of
een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord
live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw
toe te wijzen. Een expliciete "Toewijzen"-knop i.p.v. direct bij het kiezen van
een bord opslaan, zodat een klas-brede wijziging niet per ongeluk gebeurt. */
/* widgets van een eigen bord (voor de één-widget-keuze in assignmentRow);
widgets zonder wid komen uit borden van vóór v0.3.13 en verschijnen pas
na de eerstvolgende save van dat bord */
function boardWidgets(boardId){
let found = null;
BS.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(bo.id===boardId) found = bo; }));
return ((found && found.data && found.data.widgets) || []).filter(w=>w.wid);
}
function describeCurrent(current){
if(!current) return "";
let s = `${T("amAssignedAs")}${current.boardName || current.boardId}`;
if(current.widgetId){
const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId;
s += ` ${wName} (${current.mode==="kijken" ? T("amModeWatch") : T("amModeWork")})`;
}
return s;
}
function assignmentRow(target, boardOptions, current){
const row = h("div","am-row");
row.appendChild(h("span","am-name", target.label));
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amNoAssignment"), ""));
boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id)));
sel.value = current ? current.boardId : "";
/* keuze: heel bord (default, bestaand gedrag) of één widget + modus */
const widgetSel = h("select","am-sel am-widget-sel");
const modeSel = h("select","am-sel am-mode-sel");
modeSel.appendChild(new Option(T("amModeWork"), "werken"));
modeSel.appendChild(new Option(T("amModeWatch"), "kijken"));
const syncWidgetSel = ()=>{
widgetSel.innerHTML = "";
widgetSel.appendChild(new Option(T("amWholeBoard"), ""));
boardWidgets(sel.value).forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid)));
widgetSel.value = (current && current.boardId===sel.value && current.widgetId) ? current.widgetId : "";
widgetSel.style.display = sel.value ? "" : "none";
syncModeSel();
};
const syncModeSel = ()=>{ modeSel.style.display = (sel.value && widgetSel.value) ? "" : "none"; };
modeSel.value = current && current.mode ? current.mode : "werken";
sel.addEventListener("change", syncWidgetSel);
widgetSel.addEventListener("change", syncModeSel);
const info = h("span","am-code", describeCurrent(current));
row.appendChild(sel);
row.appendChild(widgetSel);
row.appendChild(modeSel);
row.appendChild(info);
syncWidgetSel();
const apply = h("button","am-btn", T("amAssign"));
apply.type = "button";
apply.addEventListener("click", async ()=>{
try{
if(sel.value){
const body = { boardId: sel.value };
if(widgetSel.value){ body.widgetId = widgetSel.value; body.mode = modeSel.value; }
await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body});
}
else await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"});
if(target.type==="class"){ await reload(); }
else{
const a = (await api("/assignments/pupil/"+target.id)).assignment;
info.textContent = describeCurrent(a);
msg(T("amAssignSaved"));
}
}catch(e){ msg(e.message); }
});
row.appendChild(apply);
return row;
}
/* vult een <select> met leerlingen gegroepeerd per klas (optgroup), gedeeld
tussen de Toewijzingen- en Voortgang-tab die beide één leerling kiezen */
function fillPupilSelect(sel, pupils){
sel.appendChild(new Option(T("amPickPupil"), ""));
const byClass = new Map();
pupils.forEach(u=>{
const key = u.classId || "none";
if(!byClass.has(key)) byClass.set(key, []);
byClass.get(key).push(u);
});
const addGroup = (label, us)=>{
if(!us || !us.length) return;
const og = document.createElement("optgroup");
og.label = label;
us.forEach(u=>og.appendChild(new Option(u.displayName, u.id)));
sel.appendChild(og);
};
CLASSES.forEach(c=>addGroup(c.name, byClass.get(c.id)));
addGroup(T("amNoClass"), byClass.get("none"));
}
/* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele
leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */
function renderToewijzingenPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
const boardOptions = [];
BS.folders.forEach(fo=>fo.boards.forEach(bo=>boardOptions.push({ id: bo.id, label: `${fo.name} / ${bo.name}` })));
panel.appendChild(h("div","am-group", T("amAssignClasses")));
const classList = h("div","am-list");
if(!CLASSES.length) classList.appendChild(h("div","guestnote", T("amNoClass")));
CLASSES.forEach(c=>classList.appendChild(assignmentRow({type:"class", id:c.id, label:c.name}, boardOptions, ASSIGNMENTS[c.id])));
panel.appendChild(classList);
panel.appendChild(h("div","am-group", T("amAssignPupils")));
const pupils = USERS.filter(u=>u.role==="pupil");
const pupilSel = h("select","am-sel");
fillPupilSelect(pupilSel, pupils);
panel.appendChild(pupilSel);
const pupilRowHolder = h("div");
panel.appendChild(pupilRowHolder);
pupilSel.addEventListener("change", async ()=>{
pupilRowHolder.innerHTML = "";
if(!pupilSel.value) return;
const u = pupils.find(x=>String(x.id)===pupilSel.value);
let current = null;
try{ current = (await api("/assignments/pupil/"+u.id)).assignment; }catch(e){}
pupilRowHolder.appendChild(assignmentRow({type:"pupil", id:u.id, label:u.displayName}, boardOptions, current));
});
}
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
function progressRow(ev){
const row = h("div","am-row");
row.appendChild(h("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType));
const scoreParts = [];
if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`);
if(ev.stars) scoreParts.push("★".repeat(ev.stars));
row.appendChild(h("span","am-code", scoreParts.join(" · ")));
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
return row;
}
/* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als
Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde
widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */
function renderVoortgangPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
const pupils = USERS.filter(u=>u.role==="pupil");
const pupilSel = h("select","am-sel");
fillPupilSelect(pupilSel, pupils);
panel.appendChild(pupilSel);
const eventsHolder = h("div","am-list");
panel.appendChild(eventsHolder);
pupilSel.addEventListener("change", async ()=>{
eventsHolder.innerHTML = "";
if(!pupilSel.value) return;
let events = [];
try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
catch(e){ msg(e.message); return; }
if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
events.forEach(ev=>eventsHolder.appendChild(progressRow(ev)));
});
}
/* zoekfilter + inklapbare groepen voor de gebruikerslijst: één plek die de
zichtbaarheid bepaalt (style.display i.p.v. her-render, zodat het zoekveld
focus houdt). Ingeklapte groepen verbergen hun rijen; zodra er gezocht
wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */
function applyUserFilter(panel){
const q = userFilter.trim().toLowerCase();
const list = panel.querySelector(".am-list");
if(!list) return;
let curGroup = null, groupVisible = false;
const closeGroup = ()=>{
if(curGroup) curGroup.style.display = (groupVisible || curGroup.dataset.collapsible) ? "" : "none";
};
[...list.children].forEach(el=>{
if(el.classList.contains("am-group")){
closeGroup();
curGroup = el; groupVisible = false;
return;
}
const collapsed = curGroup && curGroup.dataset.collapsible
&& !openGroups.has(curGroup.dataset.gkey) && !q;
if(el.classList.contains("am-row")){
const name = el.querySelector(".am-name");
const match = !q || (name && name.textContent.toLowerCase().includes(q));
el.style.display = (match && !collapsed) ? "" : "none";
if(match) groupVisible = true;
}else{
/* toevoegrij (am-add-wrap): volgt de ingeklapte staat van zijn groep */
el.style.display = collapsed ? "none" : "";
}
});
closeGroup();
}
/* gebruikers: lijst (altijd, ook zonder gekozen school) + aanmaak-formulieren.
Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd
per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst
(die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */
function renderGebruikersPanel(panel){
const filterBar = h("div","am-add");
const filterInp = h("input","am-inp am-filter");
filterInp.type = "search";
filterInp.placeholder = T("amSearchPh");
filterInp.value = userFilter;
filterInp.addEventListener("keydown", ev=>ev.stopPropagation());
filterInp.addEventListener("input", ()=>{ userFilter = filterInp.value; applyUserFilter(panel); });
filterBar.appendChild(filterInp);
panel.appendChild(filterBar);
const list = h("div","am-list");
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]];
staffGroups.forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role);
if(!us.length) return;
list.appendChild(h("div","am-group", `${label} (${us.length})`));
us.forEach(u=>list.appendChild(userRow(u)));
});
const pupils = USERS.filter(u=>u.role==="pupil");
const schoolChosen = currentUser.role!=="super" || !!selSchool;
const creatable = creatableRoles();
const canAddPupil = schoolChosen && creatable.includes("pupil");
/* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo
blijft de lijst overzichtelijk bij grote scholen; zoeken kijkt altijd
door alle groepen heen (zie applyUserFilter) */
const pupilGroup = (key, label, us, formEl)=>{
const g = h("div","am-group am-group-toggle");
g.dataset.collapsible = "1";
g.dataset.gkey = key;
const sync = ()=>{ g.textContent = `${openGroups.has(key) ? "▾" : "▸"} ${label} (${us.length})`; };
sync();
g.addEventListener("click", ()=>{
if(openGroups.has(key)) openGroups.delete(key); else openGroups.add(key);
sync();
applyUserFilter(panel);
});
list.appendChild(g);
us.forEach(u=>list.appendChild(userRow(u)));
if(formEl) list.appendChild(formEl);
};
/* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan
betrouwbaar); zonder gekozen school (super, alle scholen) of voor groepsleiding
één groep - anders vallen leerlingen met een classId die niet in
de (dan lege) CLASSES-lijst voorkomt onterecht helemaal weg */
if(currentUser.role==="teacher" || !schoolChosen){
if(pupils.length || canAddPupil){
pupilGroup("pupils", T("amPupils"), pupils, canAddPupil ? addForm("pupil") : null);
}
}else{
CLASSES.forEach(c=>{
const us = pupils.filter(u=>u.classId===c.id);
if(!us.length && !canAddPupil) return;
pupilGroup("class:"+c.id, c.name, us, canAddPupil ? addForm("pupil", c.id) : null);
});
const noClass = pupils.filter(u=>!u.classId);
if(noClass.length || canAddPupil){
pupilGroup("class:none", T("amNoClass"), noClass, canAddPupil ? addForm("pupil") : null);
}
}
panel.appendChild(list);
/* staf-aanmaakformulieren - welke rollen deze gebruiker mag aanmaken (CREATABLE_ROLES).
"super" aanmaken staat in het Systeem-tabblad, niet hier (die rol hoort niet
bij één school). */
if(schoolChosen){
["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); });
}
applyUserFilter(panel);
}
function switchAdminTab(tab){
adminTab = tab;
document.querySelectorAll("#adminTabsSlot button").forEach(b=>b.classList.toggle("active", b.dataset.admintab===tab));
modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; });
/* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer
alleen maar verwarrend, dus die verbergen we er */
const sb = modal.querySelector(".am-schoolbar");
if(sb) sb.style.display = tab === "scholen" ? "none" : "";
}
/* vaste contextbalk voor de systeemmanager: op elk beheer-tabblad direct
de school kiezen, zonder eerst naar het Systeem-tabblad te moeten */
function schoolContextBar(){
if(currentUser.role !== "super") return null;
const bar = h("div","am-schoolbar");
bar.appendChild(h("span","am-schoolbar-lbl","🏫"));
const sel = h("select","am-sel");
sel.appendChild(new Option(T("amAllSchools"), ""));
SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id)));
sel.value = selSchool ?? "";
sel.setAttribute("aria-label", T("amPickSchool"));
sel.addEventListener("change", ()=>{ selSchool = sel.value; reload(); });
bar.appendChild(sel);
return bar;
}
function render(){
modal.innerHTML = "";
const sb = schoolContextBar();
if(sb) modal.appendChild(sb);
modal.appendChild(h("div","am-msg"));
const rp = resetPanel();
if(rp) modal.appendChild(rp);
const tabDefs = adminTabDefs();
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0];
const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel };
tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel");
panel.dataset.panel = t;
panels[t](panel);
modal.appendChild(panel);
});
switchAdminTab(adminTab);
}
})();