teach/public/js/admin.js
Ramon b2f6a69897
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 1m10s
fix: maak leerkrachtfuncties zichtbaar in voorbeeldmodus
2026-07-23 23:15:08 +02:00

1761 lines
80 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (👥): scholen, klassen en gebruikers als één drill-down
navigator (tegel-overzicht → gefocust detail, i.p.v. alles tegelijk
uitgeklapt op één lange pagina). 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 de schooltegels op "alle scholen" */
let SYS_ADMINS = [];
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
let ASSIGNMENTS = {};
/* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */
let LINK_REQUESTS = [];
/* 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 = "school";
/* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden
over her-renders heen zoals adminTab hierboven. */
let kmClass = null, kmSubject = "rekenen";
const KM_SUBJECTS = ["taal", "rekenen", "world"];
/* navigatie binnen "School": welk niveau van de boom je ziet.
{type:"overview"} - tegels (scholen op "alle scholen", anders
klassen+platte groepen van de gekozen school)
{type:"class", id} - gefocust detail van één klas
{type:"flat", kind} - gefocust detail van één platte groep
(admins/supers/parents/noschool/unassigned/requests)
Blijft behouden over her-renders, reset bij het wisselen van school en bij
in-/uitloggen - anders belandt een systeemmanager na het kiezen van een
andere school midden in de klas van de vórige school. */
let navLevel = { type:"overview" };
/* welke sheet (persoon of aanmelding) open staat; blijft ook over reload()
heen zodat een actie ín de sheet (nieuw wachtwoord, klas koppelen) de
sheet niet laat verdwijnen maar met verse data laat herladen - zelfde
reden als waarom rowResults/resetResults hieronder blijven staan. */
let openSheet = null;
/* uitgeklapt "⋯"-actiepaneel (klas- of schoolbeheer) op het huidige niveau;
hoort bij navLevel, dus reset zodra dat wijzigt */
let kebabOpen = false;
/* systeembrede zoekopdracht: vult, op het tegel-niveau, het scherm met een
platte resultatenlijst i.p.v. tegels */
let searchQuery = "";
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
const creatableRoles = () =>
[...new Set(activeRoles().flatMap(r => CREATABLE_ROLES[r] || []))];
/* wie er als groepsleiding aan een klas gekoppeld kan staan - zelfde rollenset
als de server accepteert in POST /admin/classes/:id/teachers (niet alleen
"teacher": een schoolbeheerder of systeemmanager mag ook aan een klas
gekoppeld worden, en moet dan ook als zodanig getoond worden). */
const isClassStaff = u => ["teacher","admin","super"].includes(u.role);
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-btn", T("amCopy"));
b.type = "button";
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 sheet (met kopieerknop). */
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;
}
/* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------
Nog gebruikt door assignmentRow (Toewijzingen) en de Opslag-tab
(image-catalog.js) - niet meer door dit bestand zelf. */
function groupCard(opts){
const openSet = opts.openSet || new Set();
const editSet = opts.editSet || new Set();
const card = h("div","am-card");
const head = h("div","am-card-head");
card.appendChild(head);
if(opts.icon) head.appendChild(h("span","am-ico", opts.icon));
head.appendChild(h("span","am-card-title", opts.title));
const sum = h("span","am-card-sum");
head.appendChild(sum);
let tools = null;
if(opts.edit){
tools = h("div","am-card-tools");
tools.appendChild(opts.edit.tools);
const btn = h("button","am-edit", T("amEdit"));
btn.type = "button";
const ekey = opts.edit.key;
const esync = ()=>{
const open = editSet.has(ekey);
tools.style.display = open ? "" : "none";
btn.classList.toggle("on", open);
};
btn.addEventListener("click", ()=>{
if(editSet.has(ekey)) editSet.delete(ekey); else editSet.add(ekey);
esync();
});
esync();
head.appendChild(btn);
card.appendChild(tools);
}
if(opts.subs && opts.subs.length){
const subHost = h("div","am-card-subs");
opts.subs.forEach(el=>subHost.appendChild(el));
card.appendChild(subHost);
sum.classList.add("am-card-toggle");
const sync = ()=>{
const open = openSet.has(opts.key);
sum.textContent = `${opts.summary} ${open ? "▾" : "▸"}`;
subHost.style.display = open ? "" : "none";
};
sum.addEventListener("click", ()=>{
if(openSet.has(opts.key)) openSet.delete(opts.key); else openSet.add(opts.key);
sync();
});
sync();
}else if(opts.summary){
sum.textContent = opts.summary;
}
return card;
}
window.amGroupCard = groupCard;
/* ---- beheer-secties in het instellingen-register (settings.js) ------------
Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en
zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Scholen, klassen
en gebruikers zijn hier bewust ÉÉN sectie ("school") die intern een eigen
drill-down navigator opbouwt (zie renderSchoolNavigator). */
const ADMIN_SECTIONS = [
{ tab:"school", icon:"🏫", group:"school", order:10, labelKey:"amSchoolMenu", subKey:"stSchoolMenuSub",
show:()=>true,
keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"),
T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password",
"koppelcode","aanmelding","enrolment",
T("amNewClass"), T("amResetClass"), "klas","class","groep","group",
T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"],
search:q=>[
...USERS
.filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q))
.map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫",
label:u.displayName, sub:T(roleKey(u.role)),
go:()=>goSettingsSection("school", { openPerson:u.id }) })),
...CLASSES
.filter(c=>c.name.toLowerCase().includes(q))
.map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"),
go:()=>{ navLevel={type:"class", id:c.id}; kebabOpen=false; goSettingsSection("school"); } })),
...SCHOOLS
.filter(s=>s.name.toLowerCase().includes(q))
.map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"),
go:()=>{ selSchool=String(s.id); navLevel={type:"overview"}; goSettingsSection("school"); } })),
] },
{ tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignments", subKey:"stToewijzSub",
show:()=>can("assignments.manage"),
keywords:()=>[T("amModeWatch"), T("amModeWork"), T("amDashboardTitle"), T("castPathTitle"),
"bord","board","delen","share","toewijzen","assign","widget","dashboard","opdrachtenreeks","learning path"] },
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
show:()=>can("progress.view"),
keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] },
{ tab:"klassenmanagement", icon:"🎓", group:"school", order:14, labelKey:"amLevels", subKey:"amLevelsSub",
show:()=>can("assignments.manage"),
keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep",
"leerjaar","year group","differentiatie"] },
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
show:()=>activeRole()==="super",
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
];
const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]);
/* data één keer per bezoek aan Instellingen laden; secties onderling
hergebruiken de lading (in-flight promise voorkomt dubbel laden) */
let loadPromise = null;
function ensureLoaded(force){
if(force) loadPromise = null;
if(!loadPromise){
loadPromise = (async ()=>{
/* super: selSchool null = nog niet gekozen (reload() zet dan "alle
scholen" als default). Andere rollen: eigen school. */
if(currentUser.role!=="super") selSchool = currentUser.schoolId;
await reload();
})();
}
return loadPromise;
}
/* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */
window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); };
ADMIN_SECTIONS.forEach(d=>{
registerSettingsSection({
id:d.tab, icon:d.icon, group:d.group, order:d.order,
label:()=>T(d.labelKey), sub:()=>T(d.subKey),
visible:()=>!!currentUser && can("admin.access") && d.show(),
async activate(payload){
await ensureLoaded();
if(d.tab==="school" && payload && payload.openPerson !== undefined) openSheet = { type:"person", id:payload.openPerson };
showAdminHost();
switchAdminTab(d.tab);
if(d.tab==="school") render();
},
keywords:d.keywords,
searchItems:d.search,
});
});
document.addEventListener("userchange", ()=>{
if(!currentUser) resetResults = null;
loadPromise = null;
selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */
navLevel = { type:"overview" };
openSheet = null;
kebabOpen = false;
searchQuery = "";
});
async function reload(){
let reloadError = null;
try{
if(currentUser.role==="super"){
SCHOOLS = await api("/schools");
/* eerste bezoek: standaard "alle scholen" (het scholenoverzicht) i.p.v.
meteen een school te kiezen - de systeemmanager beheert immers over
scholen heen; een specifieke school kiezen is een bewuste stap. */
if(selSchool == null) selSchool = "";
}
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
USERS = (await api("/admin/users"+q)).users;
/* schooltegels op "alle scholen" tonen schoolbeheerders van élke school;
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 : "";
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; });
}
LINK_REQUESTS = [];
if(currentUser.role!=="super" || selSchool){
const lq = currentUser.role==="super" ? "?school="+selSchool : "";
LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || [];
}
}catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; reloadError = e; }
render();
/* render() bouwt de modal (incl. .am-msg) helemaal opnieuw op, dus de
melding pas ná render() zetten - anders wordt hij meteen weer gewist.
Zonder dit bleef het paneel bij een fout stilletjes helemaal leeg staan
zonder enige aanwijzing waarom. */
if(reloadError) msg(T("amLoadError") + ": " + reloadError.message);
}
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 avatar(nameStr, big){
const parts = String(nameStr || "?").trim().split(/\s+/);
const ini = ((parts[0][0] || "?") + (parts[1] ? parts[1][0] : "")).toUpperCase();
return h("span", "am-avatar" + (big ? " big" : ""), ini);
}
/* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op
eerste login/activatie. */
function userStatusGlyph(u){
const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn);
const label = u.pending ? T("amPending")
: (u.role==="pupil" && !u.everLoggedIn) ? T("amNeverLoggedIn")
: T("amStatusActive");
const g = h("span","am-glyph" + (waiting ? "" : " ok"), waiting ? "○" : "●");
g.title = label;
g.setAttribute("aria-label", label);
return g;
}
/* het bewerkformulier van één gebruiker; wordt in de sheet getoond zodra
iemand op een personRow tikt. */
function userTools(u, note){
note = note || msg;
const tools = h("div","am-tools");
const actions = h("div","am-actions");
const isStaff = ["teacher","admin","super"].includes(u.role);
/* school koppelen/losmaken (systeemmanager): losse accounts terugzetten
naar hun school, of bewust losmaken - super/ouder zijn per definitie
schoolloos en vallen hierbuiten */
if(activeRole()==="super" && ["teacher","admin","pupil"].includes(u.role)){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amNoSchool"), ""));
SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.value = u.schoolId ?? "";
ss.addEventListener("change", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); }
catch(e){ note(e.message); ss.value = u.schoolId ?? ""; }
});
tools.appendChild(field("amFieldSchool", ss));
}
/* rolwijziging: alleen tussen groepsleiding/schoolbeheerder onderling.
Systeemmanager is hier bewust nooit een optie - die rol ontstaat alleen
via de aparte "Nieuwe systeemmanager"-aanmaak (met bevestiging van het
eigen wachtwoord), nooit door een bestaand account te promoveren. Een
bestaande systeemmanager toont dit veld daarom ook niet: er is geen
veilige weg om die rol via dit simpele dropdown-veld te wijzigen. */
if(isStaff && u.role!=="super" && can("users.role.change") && u.id!==currentUser.id){
const rs = h("select","am-sel");
["teacher","admin"].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){ note(e.message); }
});
tools.appendChild(field("amFieldRole", rs));
}
/* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding,
schoolbeheerder én systeemmanager): huidige klassen als chips met ✕,
en een select om een klas toe te voegen */
if(isStaff && can("classes.manage") && CLASSES.length){
const wrap = h("div","am-classes");
(u.classIds||[]).forEach(cid=>{
const c = CLASSES.find(x=>Number(x.id)===Number(cid));
if(!c) return;
const chip = h("span","am-chip on", c.name);
const x = h("button","am-chipx","✕");
x.type = "button";
x.title = T("amClassRemove");
x.addEventListener("click", async ()=>{
try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); }
catch(e){ note(e.message); }
});
chip.appendChild(x);
wrap.appendChild(chip);
});
const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id)));
if(rest.length){
const addSel = h("select","am-sel");
addSel.appendChild(new Option(T("amClassAdd"), ""));
rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id)));
addSel.addEventListener("change", async ()=>{
if(!addSel.value) return;
try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); }
catch(e){ note(e.message); }
});
wrap.appendChild(addSel);
}
tools.appendChild(field("amFieldClasses", wrap));
}
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}}); reload(); }
catch(e){ note(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){ note(e.message); }
});
pwWrap.appendChild(pw);
const pwCopy = h("button","am-btn", T("amCopy"));
pwCopy.type = "button";
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 });
reload();
}catch(e){ note(e.message); }
}));
}
/* eenmalige ouder-code waarmee een ouder dit kind aan het eigen
ouderaccount koppelt (ouderportaal); een eerdere code vervalt */
actions.appendChild(dangerBtn(T("amParentCode"), "amParentCodeWarn", async ()=>{
try{
const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} });
rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode });
reload();
}catch(e){ note(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 });
reload();
}catch(e){ note(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 });
reload();
}catch(e){ note(e.message); }
}));
}
}
if(u.id !== currentUser.id){
const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{
try{ await api("/admin/users/"+u.id, {method:"DELETE"}); openSheet = null; reload(); }
catch(e){ note(e.message); }
});
actions.appendChild(del);
}
if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions));
return tools;
}
/* 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";
/* het formulier zelf herhaalt wat je aan het toevoegen bent, zodat het
nooit met een bestaande-gebruikersrij te verwarren is zodra de
knoptekst erboven uit beeld is (zie ook .am-add's eigen kader in css) */
f.appendChild(h("div","am-add-title", label));
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", T("amGenerate"));
gen.type = "button";
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));
}
/* een systeemmanager aanmaken is de meest ingrijpende actie in dit
beheer (school-onafhankelijk, alle rechten overal) - vereist daarom
als extra stap het eigen wachtwoord van de aanmaker zelf, ter
bevestiging. De server controleert dit ook; dit is niet louter cosmetisch. */
let confirmInp = null;
if(role==="super"){
confirmInp = h("input","am-inp");
confirmInp.type = "password";
confirmInp.autocomplete = "current-password";
confirmInp.placeholder = T("amOwnPasswordPh");
confirmInp.addEventListener("keydown", ev=>ev.stopPropagation());
f.appendChild(field("amOwnPassword", confirmInp));
}
const go = h("button","tbtn", label);
go.type = "button";
go.disabled = true;
const sync = ()=>{ go.disabled = name.value.trim().length < 2 || (confirmInp && !confirmInp.value); };
name.addEventListener("input", sync);
if(confirmInp) confirmInp.addEventListener("input", sync);
/* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */
const errEl = h("div","am-form-err");
go.addEventListener("click", async ()=>{
errEl.textContent = "";
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;
if(confirmInp) body.confirmPassword = confirmInp.value;
try{
const r = await api("/admin/users", { body });
/* groepsleiding die vanuit een klasrij wordt aangemaakt, hoort ook
meteen aan die klas gekoppeld te zijn - anders staat de nieuwe
leerkracht wel in "Groepsleiding" toegevoegd, maar (nog) niet echt
bij die klas. Hergebruikt de al bestaande koppel-route. */
if(role==="teacher" && presetClassId){
await api("/admin/classes/"+presetClassId+"/teachers", { body:{ userId: r.user.id } });
}
name.value = "";
if(pwInp) pwInp.value = "";
if(confirmInp) confirmInp.value = "";
rowResults.set(r.user.id, {
label: r.user.password ? T("amResultPw") : T("amResultCode"),
value: r.user.password || r.user.linkCode,
});
reload();
}catch(e){ errEl.textContent = e.message; }
});
f.appendChild(go);
f.appendChild(errEl);
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);
/* export als uitdeelvel - alleen mogelijk zolang dit scherm openstaat: de
wachtwoorden zijn hierna nergens meer in leesbare vorm te achterhalen
(alleen het gehashte wachtwoord blijft in de database), dus dit is
bewust "exporteer wat nu net gegenereerd is", niet "haal wachtwoorden
van bestaande accounts op". Gebruikersnaam (het echte inlog-veld) staat
er wél bij, ook al toont de schermlijst hierboven alleen de naam. */
const exportBtn = h("button","am-btn", T("amExportCsv"));
exportBtn.type = "button";
exportBtn.addEventListener("click", e=>{
e.preventDefault();
const header = [T("amExportColPupil"), T("amExportColUsername"), T("amResultPw")];
const rows = [header, ...resetResults.entries.map(en=>[en.displayName, en.username, en.password])];
const safeName = resetResults.title.replace(/[^a-z0-9]+/gi, "-");
downloadCsv(`${safeName}.csv`, rows);
});
box.appendChild(exportBtn);
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;
}
/* toewijzingsrij: overzicht van de huidige toewijzing (aangemaakt via de
Cast-knop op het whiteboard, cast.js) + loskoppelen. Aanmaken/wijzigen
gebeurt niet meer hier - zie cast.js. */
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, current){
const info = h("span","am-code", describeCurrent(current) || T("amNoAssignment"));
const tools = h("div","am-tools");
if(current){
const del = dangerBtn(T("amUnlink"), null, async ()=>{
try{
await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"});
if(target.type==="class"){ await reload(); }
else{
info.textContent = T("amNoAssignment");
del.remove();
msg(T("amAssignSaved"));
}
}catch(e){ msg(e.message); }
});
tools.appendChild(del);
}
tools.appendChild(info);
return groupCard({
title: target.label,
summary: describeCurrent(current) || T("amNoAssignment"),
edit: { key: "assign:"+target.type+":"+target.id, tools },
});
}
/* 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;
}
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}, ASSIGNMENTS[c.id])));
panel.appendChild(classList);
panel.appendChild(h("div","am-group", T("amAssignPupils")));
const pupils = USERS.filter(u=>u.role==="pupil");
panel.appendChild(h("div","am-group", T("amDashboardTitle")));
const dashboardClass = classSel();
panel.appendChild(dashboardClass);
const dashboardBody = h("div","teacher-dashboard");
panel.appendChild(dashboardBody);
async function loadDashboard(){
dashboardBody.innerHTML = "";
if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; }
dashboardBody.appendChild(h("div","guestnote","…"));
try{
const data = await api("/progress/dashboard/class/"+dashboardClass.value);
dashboardBody.innerHTML = "";
const summary = h("div","dashboard-summary");
["complete","active","support","notStarted"].forEach(status=>{
const chip = h("div","dashboard-summary-chip "+status);
chip.appendChild(h("strong",null,String(data.summary?.[status]||0)));
chip.appendChild(h("span",null,T("amDashboardStatus_"+status)));
summary.appendChild(chip);
});
dashboardBody.appendChild(summary);
if(!(data.pupils||[]).length){ dashboardBody.appendChild(h("div","guestnote",T("castNoPupils"))); return; }
const grid = h("div","dashboard-grid");
(data.pupils||[]).forEach(pupil=>{
const card = h("button","dashboard-card "+pupil.status);
card.type = "button";
card.appendChild(h("span","dashboard-status",T("amDashboardStatus_"+pupil.status)));
card.appendChild(h("strong","dashboard-name",pupil.pupilName));
if(pupil.sequence){
card.appendChild(h("span","dashboard-path",pupil.sequence.title));
const bar = h("span","dashboard-path-bar");
const fill = h("i"); fill.style.width = Math.round((pupil.sequence.completed/pupil.sequence.total)*100)+"%";
bar.appendChild(fill); card.appendChild(bar);
card.appendChild(h("span","am-role",T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total)));
}
card.appendChild(h("span","dashboard-score",pupil.attempts ? pupil.accuracy+"% · "+pupil.correct+"/"+pupil.attempts : T("amDashboardNoActivity")));
card.addEventListener("click",()=>{ pupilSel.value=String(pupil.pupilId); pupilSel.dispatchEvent(new Event("change")); });
grid.appendChild(card);
});
dashboardBody.appendChild(grid);
}catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); }
}
dashboardClass.addEventListener("change",loadDashboard);
loadDashboard();
panel.appendChild(h("div","am-group", T("amProgressDetail")));
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}, current));
});
}
/* CSV-download zonder library: waarden met komma/quote/newline krijgen
quotes (Excel-conventie), een BOM vooraan zodat Excel-NL accenten en de
komma-scheiding correct herkent. Eerste CSV-export in deze codebase -
bewust hier lokaal gehouden (nog maar één gebruiker); als de geplande
wachtwoorden-export dit ook nodig heeft, dan pas verplaatsen. */
function csvValue(v){
const s = String(v ?? "");
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
}
function downloadCsv(filename, rows){
const csv = rows.map(r=>r.map(csvValue).join(",")).join("\r\n");
const blob = new Blob([""+csv], {type:"text/csv;charset=utf-8;"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
/* eenvoudige, met de hand getekende SVG-grafiekjes voor de Voortgang-tab -
geen library nodig (zelfde aanpak als avatar.js dit sessie), puur
client-side berekend uit de al opgehaalde events. */
const SVG_NS = "http://www.w3.org/2000/svg";
function svgEl(tag, attrs){
const el = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,v));
return el;
}
function progressAccuracyRing(correct, total){
const pct = total ? Math.round((correct/total)*100) : 0;
const r = 40, c = 2*Math.PI*r, offset = c*(1-pct/100);
const svg = svgEl("svg", {viewBox:"0 0 100 100", class:"progress-ring"});
svg.appendChild(svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:"var(--surface-3)", "stroke-width":10}));
if(total){
const color = pct>=80 ? "var(--green)" : pct>=50 ? "var(--yellow)" : "var(--red)";
const arc = svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:color,
"stroke-width":10, "stroke-dasharray":c, "stroke-dashoffset":offset, "stroke-linecap":"round"});
arc.setAttribute("transform", "rotate(-90 50 50)");
svg.appendChild(arc);
}
const text = svgEl("text", {x:50, y:56, "text-anchor":"middle", class:"progress-ring-pct"});
text.textContent = total ? pct+"%" : "";
svg.appendChild(text);
return svg;
}
/* goed/fout per dag, laatste 14 dagen - attempts/correct i.p.v. een simpele
goed/fout-vlag per event, zodat dit ook klopt voor oudere geaggregeerde
events (bv. een pre-upgrade rekenblok van 10 sommen: attempts:10,
correct:7 telt als 7 goed + 3 fout, niet als één enkel "fout"-moment). */
function progressDailyBars(events){
const days = [];
const byDay = new Map();
const now = new Date();
for(let i=13; i>=0; i--){
const d = new Date(now); d.setDate(d.getDate()-i);
const key = d.toISOString().slice(0,10);
byDay.set(key, {correct:0, wrong:0});
days.push(key);
}
events.forEach(ev=>{
const key = new Date(ev.createdAt).toISOString().slice(0,10);
const bucket = byDay.get(key);
if(!bucket) return;
bucket.correct += ev.correct || 0;
bucket.wrong += Math.max(0, (ev.attempts||0) - (ev.correct||0));
});
const w = 280, h = 70, barW = w/days.length;
const max = Math.max(1, ...days.map(k=>{ const b = byDay.get(k); return b.correct+b.wrong; }));
const svg = svgEl("svg", {viewBox:`0 0 ${w} ${h}`, class:"progress-bars"});
days.forEach((key,i)=>{
const b = byDay.get(key);
const total = b.correct+b.wrong;
const totalH = (total/max)*(h-4);
const correctH = total ? (b.correct/total)*totalH : 0;
const wrongH = totalH-correctH;
const x = i*barW+barW*0.22, bw = barW*0.56;
if(wrongH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH, width:bw, height:wrongH, fill:"var(--red)", rx:1}));
if(correctH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH+wrongH, width:bw, height:correctH, fill:"var(--green)", rx:1}));
});
return svg;
}
/* welk vakgebied hoort bij een widgetType - zelfde categorieën als
REGISTRY's cat in board.js, ook gebruikt door klassenmanagement. */
function widgetSubject(widgetType){
const def = REGISTRY.find(d=>d.id===widgetType);
return def ? def.cat : null;
}
/* éé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 en een vakgebied, ziet meteen
het toegewezen niveau (klassenmanagement-koppeling), een grafiek
(nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke
opdracht het was (waar een widget dat detail meegeeft) - de volledige
ruwe tijdlijn blijft eronder beschikbaar, niets verdwijnt. */
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 subjectRow = h("div","am-inline");
panel.appendChild(subjectRow);
const body = h("div");
panel.appendChild(body);
let allEvents = [];
let pvSubject = "rekenen";
function renderBody(){
body.innerHTML = "";
const events = allEvents.filter(ev=>widgetSubject(ev.widgetType)===pvSubject);
if(!events.length){ body.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
const totals = events.reduce((acc,ev)=>({
correct: acc.correct + (ev.correct||0),
attempts: acc.attempts + (ev.attempts||0),
}), {correct:0, attempts:0});
const summary = h("div","progress-summary");
summary.appendChild(progressAccuracyRing(totals.correct, totals.attempts));
const summaryText = h("div","progress-summary-text");
summaryText.appendChild(h("div","progress-summary-count",
`${totals.correct} / ${totals.attempts} ${T("mGoed")}`));
summaryText.appendChild(h("div","progress-summary-hint", T("amProgressLast14Days")));
summary.append(summaryText, progressDailyBars(events));
body.appendChild(summary);
const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10);
if(mistakes.length){
body.appendChild(h("div","am-group", T("amProgressMistakes")));
const list = h("div","am-list");
mistakes.forEach(ev=>{
const row = h("div","am-row");
row.appendChild(h("span","am-name", ev.detail.question||""));
row.appendChild(h("span","am-code",
T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " +
T("amProgressExpected").replace("{answer}", ev.detail.expected)));
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
list.appendChild(row);
});
body.appendChild(list);
}
body.appendChild(h("div","am-group", T("amProgressTimeline")));
const eventsHolder = h("div","am-list");
body.appendChild(eventsHolder);
const moreBtn = h("button","am-btn", T("amShowMore"));
moreBtn.type = "button";
body.appendChild(moreBtn);
/* in porties van 30: de tijdlijn kan 200 regels lang zijn */
let shown = 0;
const showMore = ()=>{
events.slice(shown, shown+30).forEach(ev=>eventsHolder.appendChild(progressRow(ev)));
shown = Math.min(shown+30, events.length);
moreBtn.style.display = shown < events.length ? "" : "none";
moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`;
};
showMore();
moreBtn.onclick = showMore;
}
async function renderLevelBadge(){
if(!pupilSel.value) return;
let lvl = null;
try{ lvl = (await api(`/levels/pupil/${pupilSel.value}?subject=${pvSubject}`)).level; }catch(e){}
const old = body.querySelector(".progress-level-badge");
if(old) old.remove();
const badge = h("div","progress-level-badge",
lvl ? T("amYearGroupN").replace("{n}", lvl.yearGroup) + " · " + T("lvl"+lvl.level) : T("amLevelNone"));
body.prepend(badge);
}
async function loadPupil(){
body.innerHTML = "";
subjectRow.innerHTML = "";
if(!pupilSel.value) return;
KM_SUBJECTS.forEach(s=>{
const b = h("button","am-btn", T("amSubject_"+s));
b.type = "button";
b.classList.toggle("active", s===pvSubject);
b.addEventListener("click", ()=>{
pvSubject = s;
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
renderBody();
renderLevelBadge();
});
subjectRow.appendChild(b);
});
body.appendChild(h("div","guestnote", "…"));
try{ allEvents = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
catch(e){ body.innerHTML = ""; body.appendChild(h("div","am-msg", e.message)); return; }
renderBody();
renderLevelBadge();
}
pupilSel.addEventListener("change", loadPupil);
panel.appendChild(h("div","am-group", T("amExportClass")));
const classSelExport = classSel();
panel.appendChild(classSelExport);
const exportBtn = h("button","am-btn", T("amExportCsv"));
exportBtn.type = "button";
panel.appendChild(exportBtn);
exportBtn.addEventListener("click", async ()=>{
if(!classSelExport.value){ msg(T("amPickClassHint")); return; }
let events = [];
try{ events = (await api("/progress/class/"+classSelExport.value+"?limit=5000")).events || []; }
catch(e){ msg(e.message); return; }
if(!events.length){ msg(T("amProgressEmpty")); return; }
const cls = CLASSES.find(c=>String(c.id)===classSelExport.value);
const header = [T("amExportColPupil"), T("amExportColWidget"), T("amExportColAttempts"),
T("amExportColCorrect"), T("amExportColStars"), T("amExportColDate")];
const rows = [header, ...events.map(ev=>[
ev.pupilName,
T("wg_"+ev.widgetType) || ev.widgetType,
ev.attempts, ev.correct, ev.stars,
new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"),
])];
const safeName = (cls ? cls.name : classSelExport.value).replace(/[^a-z0-9]+/gi, "-");
downloadCsv(`voortgang-${safeName}.csv`, rows);
msg(T("amExportDone"));
});
}
/* =========================================================================
"School" - drill-down navigator: scholen/klassen/gebruikers in één geheel.
Ieder niveau toont hetzelfde patroon: tegel-overzicht met kerngetallen,
tikken toont een gefocust detailscherm met een terugknop. Personen en
aanmeldingen openen in een sheet i.p.v. een los detailpaneel.
========================================================================= */
/* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */
function personRow(u){
const row = h("div","am-row am-person");
/* eigen personage vervangt de initialen-cirkel zodra iemand er één heeft
ingesteld (Instellingen > "Mijn personage") - zo ziet een leerkracht de
personages van de klas via het scherm dat toch al gebruikt wordt. */
row.appendChild(u.avatar ? renderAvatarFace(u.avatar, "sm") : avatar(u.displayName));
const info = h("div","am-person-info");
info.appendChild(h("span","am-name", u.displayName));
info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
row.appendChild(info);
const badge = resultBadge(u);
if(badge) row.appendChild(badge);
row.appendChild(userStatusGlyph(u));
row.addEventListener("click", ()=>{ openSheet = { type:"person", id:u.id }; render(); });
return row;
}
/* rij voor één aanmeldverzoek (thuiskind -> deze school) */
function requestRow(r){
const row = h("div","am-row am-person");
row.appendChild(avatar(r.pupil.displayName));
const info = h("div","am-person-info");
info.appendChild(h("span","am-name", r.pupil.displayName));
info.appendChild(h("span","am-role", `${T("amRequestVia")} ${r.parent.displayName}`));
row.appendChild(info);
row.appendChild(h("span","am-chip on", T("amReqNew")));
row.addEventListener("click", ()=>{ openSheet = { type:"request", id:r.id }; render(); });
return row;
}
function peopleList(people){
const list = h("div","am-list am-people");
people.forEach(u=>list.appendChild(personRow(u)));
return list;
}
/* aanmeldverzoek afhandelen: adopteren (thuisaccount wordt schoolleerling,
evt. met klas en - bij naamconflict - een andere gebruikersnaam),
koppelen aan een bestaande leerling, of afwijzen. In de sheet getoond. */
function requestTools(r){
const tools = h("div","am-tools");
const adoptWrap = h("div","am-inline");
const cs = classSel(null);
const un = h("input","am-inp");
un.placeholder = T("amAdoptUserPh");
un.style.display = "none";
un.addEventListener("keydown", ev=>ev.stopPropagation());
const adopt = h("button","am-btn", T("amAdopt"));
adopt.type = "button";
adopt.addEventListener("click", async ()=>{
const body = { action:"adopt", classId: cs.value ? +cs.value : null };
if(un.style.display!=="none" && un.value.trim()) body.username = un.value.trim();
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body }); openSheet = null; reload(); }
catch(e){
/* naamconflict binnen de school: veld tonen om direct een andere
gebruikersnaam mee te geven */
if(e.status===409){ un.style.display = ""; un.focus(); }
msg(e.message);
}
});
adoptWrap.appendChild(cs);
adoptWrap.appendChild(un);
adoptWrap.appendChild(adopt);
tools.appendChild(field("amAdopt", adoptWrap));
const matchWrap = h("div","am-inline");
const ms = h("select","am-sel");
fillPupilSelect(ms, USERS.filter(u=>u.role==="pupil"));
const mb = h("button","am-btn", T("amMatchBtn"));
mb.type = "button";
mb.addEventListener("click", async ()=>{
if(!ms.value) return;
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"match", pupilId: +ms.value } }); openSheet = null; reload(); }
catch(e){ msg(e.message); }
});
matchWrap.appendChild(ms);
matchWrap.appendChild(mb);
tools.appendChild(field("amMatchBtn", matchWrap));
const actions = h("div","am-actions");
actions.appendChild(dangerBtn(T("amReject"), "amRejectWarn", async ()=>{
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"reject" } }); openSheet = null; reload(); }
catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", actions));
return tools;
}
/* tegel: één cel in het overzicht (school/klas/platte groep), of de "+"
variant die een toevoegformulier opent (zie newSchoolTile/newClassTile). */
function tile(opts){
const cls = "am-tile" + (opts.wide?" wide":"") + (opts.warn?" warn":"") + (opts.add?" add":"");
const btn = h("button", cls);
btn.type = "button";
if(opts.add){
btn.textContent = opts.label;
}else{
btn.appendChild(h("div","am-tile-name", opts.label));
if(opts.stats && opts.stats.length){
const chips = h("div","am-tile-chips");
opts.stats.forEach(s=>chips.appendChild(h("span","am-chip" + (s.flag?" flag":""), s.text)));
btn.appendChild(chips);
}
}
if(opts.onClick) btn.addEventListener("click", opts.onClick);
return btn;
}
/* "+ School" als tegel: toevoegen zit achter een bewuste extra stap (tik
opent het veld) en bevestigt daarna nog met de naam erin. */
function newSchoolTile(){
const wrap = h("div","am-add-wrap");
const toggle = tile({ label:T("amNewSchool"), add:true });
wrap.appendChild(toggle);
const f = h("div","am-add");
f.style.display = "none";
f.appendChild(h("div","am-add-title", T("amNewSchool")));
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 = String(r.school.id); inp.value = ""; navLevel = { type:"overview" };
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();
});
return wrap;
}
/* "+ Klas" als tegel, zelfde opzet als newSchoolTile hierboven */
function newClassTile(){
const wrap = h("div","am-add-wrap");
const toggle = tile({ label:T("amNewClass"), add:true });
wrap.appendChild(toggle);
const f = h("div","am-add");
f.style.display = "none";
f.appendChild(h("div","am-add-title", T("amNewClass")));
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);
wrap.appendChild(f);
toggle.addEventListener("click", ()=>{
toggle.style.display = "none";
f.style.display = "flex";
inp.focus();
});
return wrap;
}
/* "⋯"-actiepaneel van de gekozen school: naam wijzigen, verwijderen.
Alleen systeemmanager - schoolbeheerder/leerkracht zien dit nooit. */
function schoolActionsPanel(school){
const box = h("div","am-kebab-panel");
const nameInp = h("input","am-inp");
nameInp.value = school.name;
nameInp.maxLength = 80;
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
nameInp.addEventListener("change", async ()=>{
const name = nameInp.value.trim();
if(name.length < 2 || name === school.name) return;
try{ await api("/admin/schools/"+school.id, {method:"PATCH", body:{ name }}); reload(); }
catch(e){ msg(e.message); }
});
box.appendChild(field("amFieldName", nameInp));
box.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{
try{
await api("/admin/schools/"+school.id, {method:"DELETE"});
selSchool = ""; navLevel = { type:"overview" }; kebabOpen = false;
reload();
}catch(e){ msg(e.message); }
}));
return box;
}
/* "⋯"-actiepaneel van de gekozen klas: verplaatsen naar andere school
(alleen systeemmanager), wachtwoorden resetten, verwijderen. */
function classActionsPanel(c, otherSchools){
const box = h("div","am-kebab-panel");
if(activeRole()==="super" && otherSchools.length){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amMoveToSchool"), ""));
otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.addEventListener("change", async ()=>{
if(!ss.value) return;
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); }
catch(e){ msg(e.message); }
});
box.appendChild(field("amFieldMoveSchool", ss));
}
box.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); }
}));
box.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{
try{
await api("/admin/classes/"+c.id, {method:"DELETE"});
navLevel = { type:"overview" }; kebabOpen = false;
reload();
}catch(e){ msg(e.message); }
}));
return box;
}
function yearGroupSel(current){
const sel = h("select","am-sel");
for(let g=1; g<=8; g++) sel.appendChild(new Option(T("amYearGroupN").replace("{n}", g), g));
sel.value = current ?? 4;
return sel;
}
function levelSel(current){
const sel = h("select","am-sel");
[1,2,3].forEach(n=>sel.appendChild(new Option(
typeof learningLevelLabel==="function" ? learningLevelLabel(n) : T("lvl"+n), n)));
sel.value = current ?? 1;
return sel;
}
function describeLevel(effective, isOverride){
if(!effective) return T("amLevelNone");
const base = `${T("amYearGroupN").replace("{n}", effective.yearGroup)} · ${T("lvl"+effective.level)}`;
return isOverride ? base : `${base} (${T("amLevelInherited")})`;
}
/* klas-standaard: geldt voor iedereen in de klas tenzij een leerling een
eigen afwijking heeft (zie renderPupilLevelRow hieronder). */
function renderClassDefaultCard(classId, subject, current, onSaved){
const box = h("div","am-card");
box.appendChild(h("div","am-card-title", T("amLevelClassDefault")));
const row = h("div","am-inline");
const ygSel = yearGroupSel(current && current.yearGroup);
const lvlSel = levelSel(current && current.level);
row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
const summary = h("div","guestnote learning-level-summary");
const updateSummary=()=>{
const p=typeof learningProfile==="function" ? learningProfile(subject,+ygSel.value,+lvlSel.value) : null;
summary.textContent=p ? p.title+" — "+p.goals.join(" · ") : "";
};
ygSel.addEventListener("change",updateSummary);lvlSel.addEventListener("change",updateSummary);updateSummary();
const saveBtn = h("button","am-btn", T("save"));
saveBtn.type = "button";
saveBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/class/${classId}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
row.appendChild(saveBtn);
box.appendChild(row);
box.appendChild(summary);
return box;
}
/* één leerling: toont de EFFECTIEVE groep/niveau (eigen afwijking, anders de
klas-standaard, gedempt met "erft van klas"). Instellen van een eigen
afwijking is even laagdrempelig als de klas-standaard - het
speciaal-onderwijs-geval waar dat vanaf het begin de norm is, mag geen
verstopt uitzonderingspad zijn. */
function renderPupilLevelRow(pupil, subject, override, classDefault, onSaved){
const effective = override || classDefault;
const wrap = h("div");
const row = h("div","am-row am-person");
const info = h("div","am-person-info");
info.appendChild(h("span","am-name", pupil.displayName));
info.appendChild(h("span","am-role", describeLevel(effective, !!override)));
row.appendChild(info);
const editBox = h("div","am-inline");
editBox.style.display = "none";
const ygSel = yearGroupSel(effective && effective.yearGroup);
const lvlSel = levelSel(effective && effective.level);
editBox.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
const saveBtn = h("button","am-btn", T("save"));
saveBtn.type = "button";
saveBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/pupil/${pupil.id}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
editBox.appendChild(saveBtn);
const tools = h("div","am-tools");
const editBtn = h("button","am-btn", T("amEdit"));
editBtn.type = "button";
editBtn.addEventListener("click", ()=>{
editBox.style.display = editBox.style.display==="none" ? "flex" : "none";
});
tools.appendChild(editBtn);
if(override){
const resetBtn = h("button","am-btn", T("amLevelReset"));
resetBtn.type = "button";
resetBtn.addEventListener("click", async ()=>{
try{
await api(`/levels/pupil/${pupil.id}?subject=${subject}`, {method:"DELETE"});
msg(T("amLevelSaved"));
await onSaved();
}catch(e){ msg(e.message); }
});
tools.appendChild(resetBtn);
}
row.appendChild(tools);
wrap.append(row, editBox);
return wrap;
}
/* klassenmanagement: per vakgebied een niveaugroep (leerjaar 1-8, waaraan
de leerdoelen gekoppeld zijn) + niveau (1-3) instellen, met een
klas-standaard en individuele afwijkingen - zie roadmap-memo. */
function renderKlassenmanagementPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
panel.appendChild(h("div","am-group", T("amLevels")));
const clsSel = classSel(kmClass ?? "");
panel.appendChild(clsSel);
const subjectRow = h("div","am-inline");
panel.appendChild(subjectRow);
const bodyHolder = h("div");
panel.appendChild(bodyHolder);
async function loadBody(){
bodyHolder.innerHTML = "";
if(!kmClass) return;
bodyHolder.appendChild(h("div","guestnote", "…"));
let data;
try{ data = await api(`/levels/class/${kmClass}?subject=${kmSubject}`); }
catch(e){ bodyHolder.innerHTML = ""; bodyHolder.appendChild(h("div","am-msg", e.message)); return; }
bodyHolder.innerHTML = "";
bodyHolder.appendChild(renderClassDefaultCard(kmClass, kmSubject, data.classDefault, loadBody));
bodyHolder.appendChild(h("div","am-group", T("amLevelPupils")));
const list = h("div","am-list");
const overrideMap = new Map(data.pupilOverrides.map(o=>[o.pupilId, o]));
const pupilsInClass = USERS.filter(u=>u.role==="pupil" && String(u.classId)===String(kmClass));
if(!pupilsInClass.length) list.appendChild(h("div","guestnote", T("amNoClass")));
pupilsInClass.forEach(u=>{
list.appendChild(renderPupilLevelRow(u, kmSubject, overrideMap.get(u.id), data.classDefault, loadBody));
});
bodyHolder.appendChild(list);
}
KM_SUBJECTS.forEach(s=>{
const b = h("button","am-btn", T("amSubject_"+s));
b.type = "button";
b.classList.toggle("active", s===kmSubject);
b.addEventListener("click", ()=>{
kmSubject = s;
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
loadBody();
});
subjectRow.appendChild(b);
});
clsSel.addEventListener("change", ()=>{ kmClass = clsSel.value || null; loadBody(); });
if(kmClass) loadBody();
}
/* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school +
Systeemmanagers/Ouders/Zonder school + "+ School". */
function renderRootOverview(panel){
const stats = h("div","am-stat-strip");
[[SCHOOLS.length, T("amSchools")],
[USERS.filter(u=>["admin","teacher","super"].includes(u.role)).length, T("amStaff")],
[USERS.filter(u=>u.role==="pupil").length, T("amPupils")]].forEach(([n,label])=>{
const s = h("div","am-stat");
s.appendChild(h("b",null,String(n)));
s.appendChild(h("span",null,label));
stats.appendChild(s);
});
panel.appendChild(stats);
const grid = h("div","am-tilegrid");
SCHOOLS.forEach(sc=>{
const staff = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && ["admin","teacher"].includes(u.role)).length;
const pupils = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && u.role==="pupil").length;
grid.appendChild(tile({
label: sc.name,
stats: [{text:`🧑‍🏫 ${staff}`}, {text:`🧒 ${pupils}`}],
onClick: ()=>{ selSchool = String(sc.id); navLevel = { type:"overview" }; kebabOpen = false; reload(); },
}));
});
const supers = USERS.filter(u=>u.role==="super");
grid.appendChild(tile({ label:T("amSupers"), wide:true, stats:[{text:String(supers.length)}],
onClick: ()=>{ navLevel = { type:"flat", kind:"supers" }; render(); } }));
const parents = USERS.filter(u=>u.role==="parent");
if(parents.length) grid.appendChild(tile({ label:T("amParents"), wide:true, stats:[{text:String(parents.length)}],
onClick: ()=>{ navLevel = { type:"flat", kind:"parents" }; render(); } }));
const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null);
if(loose.length) grid.appendChild(tile({ label:T("amNoSchoolGroup"), wide:true, warn:true,
stats:[{text:String(loose.length), flag:true}],
onClick: ()=>{ navLevel = { type:"flat", kind:"noschool" }; render(); } }));
if(can("schools.manage")) grid.appendChild(newSchoolTile());
panel.appendChild(grid);
}
/* niveau 1: één school - tegel per klas + Schoolbeheerder/Niet ingedeeld/
Ouders/Aanmeldingen + "+ Klas". "⋯" toont schoolbeheer (alleen super). */
function renderSchoolOverview(panel){
const school = SCHOOLS.find(s=>String(s.id)===String(selSchool));
const canManageClasses = can("classes.manage");
if(activeRole()==="super" && school){
const titleRow = h("div","am-kebab-row");
titleRow.appendChild(h("h2","am-nav-title", school.name));
const kebab = h("button","am-kebab","⋯");
kebab.type = "button";
kebab.addEventListener("click", ()=>{ kebabOpen = !kebabOpen; render(); });
titleRow.appendChild(kebab);
panel.appendChild(titleRow);
if(kebabOpen) panel.appendChild(schoolActionsPanel(school));
}
const pupils = USERS.filter(u=>u.role==="pupil");
const stats = h("div","am-stat-strip");
[[CLASSES.length, T("amClasses")],
[USERS.filter(u=>["admin","teacher"].includes(u.role)).length, T("amStaff")],
[pupils.length, T("amPupils")]].forEach(([n,label])=>{
const s = h("div","am-stat");
s.appendChild(h("b",null,String(n)));
s.appendChild(h("span",null,label));
stats.appendChild(s);
});
panel.appendChild(stats);
const grid = h("div","am-tilegrid");
if(LINK_REQUESTS.length){
grid.appendChild(tile({ label:T("amLinkRequests"), wide:true, warn:true,
stats:[{text:String(LINK_REQUESTS.length), flag:true}],
onClick: ()=>{ navLevel = { type:"flat", kind:"requests" }; render(); } }));
}
CLASSES.forEach(c=>{
const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id))).length;
const cp = pupils.filter(u=>u.classId===c.id).length;
grid.appendChild(tile({ label:c.name, stats:[{text:`🧑‍🏫 ${staff}`},{text:`🧒 ${cp}`}],
onClick: ()=>{ navLevel = { type:"class", id:c.id }; kebabOpen = false; render(); } }));
});
const admins = USERS.filter(u=>u.role==="admin");
grid.appendChild(tile({ label:T("roleAdmin"), wide:true, stats:[{text:String(admins.length)}],
onClick: ()=>{ navLevel = { type:"flat", kind:"admins" }; render(); } }));
const unassigned = [
...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length),
...pupils.filter(u=>!u.classId),
];
grid.appendChild(tile({ label:T("amUnassigned"), wide:true, warn:unassigned.length>0,
stats:[{text:String(unassigned.length), flag:unassigned.length>0}],
onClick: ()=>{ navLevel = { type:"flat", kind:"unassigned" }; render(); } }));
const parents = USERS.filter(u=>u.role==="parent");
if(parents.length) grid.appendChild(tile({ label:T("amParents"), wide:true, stats:[{text:String(parents.length)}],
onClick: ()=>{ navLevel = { type:"flat", kind:"parents" }; render(); } }));
if(canManageClasses) grid.appendChild(newClassTile());
panel.appendChild(grid);
}
/* niveau 2a: één klas - Groepsleiding en Leerlingen als twee sublijstjes,
elk met een eigen "+"; "⋯" toont klasbeheer. */
function renderClassDetail(panel, classId){
const c = CLASSES.find(x=>Number(x.id)===Number(classId));
if(!c){ navLevel = { type:"overview" }; renderSchoolOverview(panel); return; }
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
const canManageClasses = can("classes.manage");
const titleRow = h("div","am-kebab-row");
titleRow.appendChild(h("h2","am-nav-title", c.name));
if(canManageClasses){
const kebab = h("button","am-kebab","⋯");
kebab.type = "button";
kebab.addEventListener("click", ()=>{ kebabOpen = !kebabOpen; render(); });
titleRow.appendChild(kebab);
}
panel.appendChild(titleRow);
if(kebabOpen && canManageClasses) panel.appendChild(classActionsPanel(c, otherSchools));
const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id)));
panel.appendChild(h("p","am-section-label", T("roleTeacher")));
panel.appendChild(peopleList(staff));
if(creatableRoles().includes("teacher")) panel.appendChild(addForm("teacher", c.id));
const pupils = USERS.filter(u=>u.role==="pupil" && u.classId===c.id);
panel.appendChild(h("p","am-section-label", T("amPupils")));
panel.appendChild(peopleList(pupils));
if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil", c.id));
}
/* niveau 2b: één platte groep (Schoolbeheerder/Systeemmanagers/Ouders/
Zonder school/Niet ingedeeld/Aanmeldingen) - één lijst, evt. met "+". */
function renderFlatDetail(panel, kind){
if(kind==="requests"){
const titleRow = h("div","am-kebab-row");
titleRow.appendChild(h("h2","am-nav-title", T("amLinkRequests")));
panel.appendChild(titleRow);
const list = h("div","am-list am-people");
LINK_REQUESTS.forEach(r=>list.appendChild(requestRow(r)));
panel.appendChild(list);
return;
}
const pupils = USERS.filter(u=>u.role==="pupil");
let title, people, addRole, presetClassId = null;
if(kind==="admins"){ title = T("roleAdmin"); people = USERS.filter(u=>u.role==="admin"); addRole = creatableRoles().includes("admin") ? "admin" : null; }
else if(kind==="supers"){ title = T("amSupers"); people = USERS.filter(u=>u.role==="super"); addRole = creatableRoles().includes("super") ? "super" : null; }
else if(kind==="parents"){ title = T("amParents"); people = USERS.filter(u=>u.role==="parent"); addRole = null; }
else if(kind==="noschool"){ title = T("amNoSchoolGroup"); people = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); addRole = null; }
else{ /* unassigned */
title = T("amUnassigned");
people = [
...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length),
...pupils.filter(u=>!u.classId),
];
addRole = creatableRoles().includes("pupil") ? "pupil" : null;
}
const titleRow = h("div","am-kebab-row");
titleRow.appendChild(h("h2","am-nav-title", title));
panel.appendChild(titleRow);
panel.appendChild(peopleList(people));
if(addRole) panel.appendChild(addForm(addRole, presetClassId));
}
/* zoekresultaten: systeembreed op naam, vervangt de tegels zolang er getypt is */
function renderSearchResults(panel, q){
const matches = USERS.filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q));
if(!matches.length){ panel.appendChild(h("div","guestnote", T("amNoResults"))); return; }
panel.appendChild(peopleList(matches));
}
/* de sheet: personen- of aanmeldingsdetail, altijd bovenop het huidige
niveau. validateSheet() controleert eerst of de selectie nog bestaat
(na reload() kan iemand weg zijn, bv. net verwijderd). */
function validateSheet(){
if(openSheet && openSheet.type==="person" && !USERS.some(u=>u.id===openSheet.id)) openSheet = null;
if(openSheet && openSheet.type==="request" && !LINK_REQUESTS.some(r=>r.id===openSheet.id)) openSheet = null;
}
function renderSheetInto(panel){
const backdrop = h("div","am-sheet-backdrop" + (openSheet ? " open" : ""));
backdrop.addEventListener("click", (e)=>{ if(e.target===backdrop){ openSheet = null; render(); } });
if(openSheet){
const sheet = h("div","am-sheet");
const closeBtn = h("button","am-btn am-detail-close", T("close"));
closeBtn.type = "button";
closeBtn.addEventListener("click", ()=>{ openSheet = null; render(); });
if(openSheet.type==="person"){
const u = USERS.find(x=>x.id===openSheet.id);
const head = h("div","am-detail-head");
head.appendChild(avatar(u.displayName, true));
const txt = h("div","am-detail-title");
txt.appendChild(h("h4", null, u.displayName));
txt.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : "")));
head.appendChild(txt);
head.appendChild(closeBtn);
sheet.appendChild(head);
const badge = resultBadge(u);
if(badge) sheet.appendChild(badge);
const note = h("div","am-detail-msg formMsg");
sheet.appendChild(note);
sheet.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; }));
}else if(openSheet.type==="request"){
const r = LINK_REQUESTS.find(x=>x.id===openSheet.id);
const head = h("div","am-detail-head");
head.appendChild(avatar(r.pupil.displayName, true));
const txt = h("div","am-detail-title");
txt.appendChild(h("h4", null, r.pupil.displayName));
txt.appendChild(h("span","am-role", `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`));
head.appendChild(txt);
head.appendChild(closeBtn);
sheet.appendChild(head);
sheet.appendChild(requestTools(r));
}
backdrop.appendChild(sheet);
}
panel.appendChild(backdrop);
}
/* het "School"-tabblad zelf: bepaalt o.b.v. selSchool/navLevel welk niveau
te tonen. Een leerkracht (groepsleiding) heeft geen klassenbeheer en
krijgt de eenvoudiger platte leerlingenlijst, ongewijzigd t.o.v. eerder -
server-side toch al beperkt tot de eigen klas(sen), dus geen boom nodig. */
function renderSchoolNavigator(panel){
validateSheet();
panel.classList.add("am-nav-panel");
if(activeRole()==="teacher"){
const header = h("div","am-nav-header");
const searchInp = h("input","am-inp am-filter");
searchInp.type = "search";
searchInp.placeholder = T("amSearchPh");
searchInp.value = searchQuery;
searchInp.addEventListener("keydown", ev=>ev.stopPropagation());
searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); });
header.appendChild(searchInp);
panel.appendChild(header);
const q = searchQuery.trim().toLowerCase();
const pupils = USERS.filter(u=>u.role==="pupil" && (!q || `${u.displayName} ${u.username}`.toLowerCase().includes(q)));
panel.appendChild(h("p","am-section-label", T("amPupils")));
panel.appendChild(peopleList(pupils));
if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil"));
renderSheetInto(panel);
return;
}
const atRoot = currentUser.role==="super" && !selSchool;
const header = h("div","am-nav-header");
if(navLevel.type!=="overview"){
const back = h("button","am-crumb-back", "← " + T("amBack"));
back.type = "button";
back.addEventListener("click", ()=>{ navLevel = { type:"overview" }; kebabOpen = false; render(); });
header.appendChild(back);
}else{
const searchInp = h("input","am-inp am-filter");
searchInp.type = "search";
searchInp.placeholder = T("amSearchPh");
searchInp.value = searchQuery;
searchInp.addEventListener("keydown", ev=>ev.stopPropagation());
searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); });
header.appendChild(searchInp);
}
panel.appendChild(header);
if(navLevel.type==="overview" && searchQuery.trim()){
renderSearchResults(panel, searchQuery.trim().toLowerCase());
}else if(navLevel.type==="overview"){
if(atRoot) renderRootOverview(panel); else renderSchoolOverview(panel);
}else if(navLevel.type==="class"){
renderClassDetail(panel, navLevel.id);
}else if(navLevel.type==="flat"){
renderFlatDetail(panel, navLevel.kind);
}
renderSheetInto(panel);
}
function switchAdminTab(tab){
adminTab = tab;
modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; });
/* Opslag is school-onafhankelijk: daar is de schoolkiezer alleen maar
verwarrend, dus die verbergen we er. "school" toont 'm juist wél: de
kiezer bepaalt daarbinnen ook echt wat je ziet (scholentegels bij
"alle scholen", klassentegels bij een gekozen school). */
const sb = modal.querySelector(".am-schoolbar");
if(sb) sb.style.display = tab==="opslag" ? "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", T("amFieldSchool")));
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; navLevel = { type:"overview" }; openSheet = null; kebabOpen = false;
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 = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, klassenmanagement: renderKlassenmanagementPanel, opslag: renderImageStoragePanel };
tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel");
panel.dataset.panel = t;
panels[t](panel);
modal.appendChild(panel);
});
switchAdminTab(adminTab);
}
})();