teach/public/js/admin.js
Ramon 111e48b044
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
fix: toevoegformulier duidelijk anders dan een bestaande-gebruikersrij (v0.4.06-beta)
Een uitgeklapt "+ ..."-formulier (nieuwe schoolbeheerder/leerkracht/leerling/
school/klas) zag er in het beheerpaneel bijna hetzelfde uit als het
bewerkpaneel van een bestaande gebruiker - beide gewoon een rij velden
zonder duidelijk onderscheid.

- .am-add krijgt nu een eigen kader (accentkleur, stippellijn) i.p.v. de
  neutrale achtergrond van een gebruikersrij
- het formulier toont bovenaan een titel die herhaalt wát je toevoegt (bv.
  "LEERLING"), ook zodra de knoptekst erboven uit beeld is
- de dichtgeklapte "+ ..."-knop zelf krijgt ook de accentkleur, zodat
  collapsed en uitgeklapt dezelfde "dit is toevoegen"-taal spreken
- geverifieerd in een echte headless Chromium: duidelijk visueel onderscheid
  tussen een toevoegformulier en een echte gebruikersrij
2026-07-18 04:22:17 +02:00

1451 lines
66 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 = {};
/* 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 = "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 closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */
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-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 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;
}
/* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------
Kop (grijs icoon + titel + samenvatting-als-toggle + Bewerken) met daaronder
optioneel uitklapbare compacte subrijen en/of een uitklapbaar bewerkblok.
De open-status leeft in een Set die re-renders overleeft; andere modules
(bv. de Opslag-tab in image-catalog.js) geven hun eigen Set mee. */
function groupCard(opts){
const openSet = opts.openSet || openGroups;
const editSet = opts.editSet || openRows;
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. Per rol alleen
de essentie: groepsleiding ziet gebruikers/toewijzingen/voortgang,
schoolbeheer ook klassen, de systeemmanager ook scholen en opslag. */
const ADMIN_SECTIONS = [
{ tab:"gebruikers", icon:"👥", group:"school", order:10, labelKey:"amUsers", subKey:"stUsersSub",
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"],
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("gebruikers", { filter:u.displayName }) })) },
{ tab:"klassen", icon:"🏷", group:"school", order:11, labelKey:"amClasses", subKey:"stKlassenSub",
show:()=>can("classes.manage"),
keywords:()=>[T("amNewClass"), T("amResetClass"), "klas","class","groep","group"],
search:q=>CLASSES
.filter(c=>c.name.toLowerCase().includes(q))
.map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>goSettingsSection("klassen") })) },
{ tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignments", subKey:"stToewijzSub",
show:()=>can("assignments.manage"),
keywords:()=>[T("amModeWatch"), T("amModeWork"), "bord","board","delen","share","toewijzen","assign","widget"] },
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
show:()=>can("progress.view"),
keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] },
{ tab:"scholen", icon:"🏫", group:"system", order:30, labelKey:"amSchools", subKey:"stScholenSub",
show:()=>currentUser.role==="super",
keywords:()=>[T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"],
search:q=>SCHOOLS
.filter(s=>s.name.toLowerCase().includes(q))
.map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>goSettingsSection("scholen") })) },
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
show:()=>currentUser.role==="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 de eerste
school als default zodat klassen/gebruikers meteen een concrete
school tonen); "" = bewust "alle scholen". 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==="gebruikers" && payload && payload.filter !== undefined) userFilter = payload.filter;
showAdminHost();
switchAdminTab(d.tab);
if(d.tab==="gebruikers" && payload && payload.filter !== undefined){
const panel = modal.querySelector('[data-panel="gebruikers"]');
const inp = panel && panel.querySelector(".am-filter");
if(inp){ inp.value = userFilter; applyUserFilter(panel); }
}
},
keywords:d.keywords,
searchItems:d.search,
});
});
document.addEventListener("userchange", ()=>{
if(!currentUser) resetResults = null;
loadPromise = null;
selectedUserKey = null;
selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */
});
async function reload(){
let reloadError = null;
try{
if(currentUser.role==="super"){
SCHOOLS = await api("/schools");
/* eerste bezoek: kies meteen een concrete school i.p.v. het verwarrende
"alle scholen" (klassen en per-school-lijsten zijn dan direct gevuld) */
if(selSchool == null) selSchool = SCHOOLS.length ? String(SCHOOLS[0].id) : "";
}
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 : "";
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;
}
/* ---- gebruikers: master-detail ---------------------------------------------
Links een compacte tabel (naam | functie | klas | status), rechts een vast
detailpaneel waarin de geselecteerde gebruiker bewerkt wordt. De selectie
overleeft reload(); credential-acties selecteren de gebruiker zodat het
eenmalige wachtwoord/de code direct in het paneel zichtbaar is. */
let selectedUserKey = null; /* "u:<id>" | "req:<id>" | "new" | null */
let masterHost = null, detailHost = null;
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);
}
function userKlasText(u){
if(u.role==="pupil"){
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
return cls ? cls.name : "";
}
if(u.role==="parent") return (u.childNames||[]).join(", ");
return (u.classIds||[])
.map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name)
.filter(Boolean).join(", ");
}
/* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op
eerste login/activatie. Tekst clipte in de smalle statuskolom. */
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;
}
function nameCellFor(display, username){
const cell = h("span","am-name");
cell.appendChild(avatar(display));
const nm = h("span","am-name-txt", display);
if(username && display!==username) nm.appendChild(h("small","am-username"," · "+username));
cell.appendChild(nm);
return cell;
}
/* tabelrij in de gebruikerslijst: klik selecteert (geen her-render, zodat
het zoekveld focus houdt); bewerken gebeurt in het detailpaneel */
function userCells(u){
const row = h("div","am-row am-tr");
row.dataset.selkey = "u:"+u.id;
row.appendChild(nameCellFor(u.displayName, u.username));
row.appendChild(h("span","am-cell", T(roleKey(u.role))));
row.appendChild(h("span","am-cell", userKlasText(u) || "—"));
const stCell = h("span","am-cell am-status");
stCell.appendChild(userStatusGlyph(u));
row.appendChild(stCell);
row.addEventListener("click", ()=>{ selectedUserKey = "u:"+u.id; syncSelection(); });
return row;
}
/* het bewerkformulier van één gebruiker; wordt gebruikt door het
detailpaneel (gebruikers) én inline door het Systeem-tabblad (subrijen) */
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(currentUser.role==="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));
}
if(isStaff && can("users.role.change") && u.id!==currentUser.id){
/* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super);
het aparte extra-functies-systeem is uit de UI gehaald */
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){ 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 });
selectedUserKey = "u:"+u.id;
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 });
selectedUserKey = "u:"+u.id;
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 });
selectedUserKey = "u:"+u.id;
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 });
selectedUserKey = "u:"+u.id;
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"}); selectedUserKey = null; reload(); }
catch(e){ note(e.message); }
});
actions.appendChild(del);
}
if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions));
return tools;
}
/* inline-variant voor het Systeem-tabblad (schoolbeheerder-subrijen):
naam + rol met een "Bewerken"-tekstknop die het formulier uitklapt */
function userRow(u, opts){
const compact = !!(opts && opts.compact);
const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "") + (compact ? " am-card-sub" : ""));
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
if(compact){
/* compacte kaart-subrij: status als glyph met uitleg-title i.p.v. tekst */
row.appendChild(h("span","am-role", T(roleKey(u.role))));
const glyph = h("span","am-glyph" + (u.pending ? "" : " ok"), u.pending ? "○" : "●");
glyph.title = u.pending ? T("amPending") : T("amStatusActive");
row.appendChild(glyph);
}else{
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
}
const tools = h("div","am-row-tools");
const badge = resultBadge(u);
if(badge) tools.appendChild(badge);
tools.appendChild(userTools(u));
const edit = h("button","am-edit", T("amEdit"));
edit.type = "button";
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;
}
/* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding
afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */
function syncSelection(){
if(!masterHost || !detailHost) return;
/* selectie valideren tegen de actuele data (na reload kan hij weg zijn) */
if(selectedUserKey && selectedUserKey.startsWith("u:")
&& !USERS.some(u=>"u:"+u.id===selectedUserKey)) selectedUserKey = null;
if(selectedUserKey && selectedUserKey.startsWith("req:")
&& !LINK_REQUESTS.some(r=>"req:"+r.id===selectedUserKey)) selectedUserKey = null;
masterHost.querySelectorAll(".am-tr").forEach(r=>r.classList.toggle("sel", r.dataset.selkey===selectedUserKey));
const split = masterHost.closest(".am-split");
if(split) split.classList.toggle("has-sel", !!selectedUserKey);
renderDetail();
}
function detailHead(title, subtitle){
const head = h("div","am-detail-head");
head.appendChild(avatar(title, true));
const txt = h("div","am-detail-title");
txt.appendChild(h("h4", null, title));
if(subtitle) txt.appendChild(h("span","am-role", subtitle));
head.appendChild(txt);
const close = h("button","am-btn am-detail-close", T("close"));
close.type = "button";
close.addEventListener("click", ()=>{ selectedUserKey = null; syncSelection(); });
head.appendChild(close);
return head;
}
function renderDetail(){
if(!detailHost) return;
detailHost.innerHTML = "";
if(selectedUserKey === "new"){
detailHost.appendChild(detailHead(T("amNewUser"), ""));
detailHost.appendChild(newUserForm());
return;
}
if(selectedUserKey && selectedUserKey.startsWith("u:")){
const u = USERS.find(x=>"u:"+x.id===selectedUserKey);
detailHost.appendChild(detailHead(u.displayName, T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : "")));
const badge = resultBadge(u);
if(badge) detailHost.appendChild(badge);
/* fouten (bv. verwijderen mislukt) tonen we hier, waar de beheerder kijkt,
niet alleen bovenaan het paneel (daar valt het buiten beeld) */
const note = h("div","am-detail-msg formMsg");
detailHost.appendChild(note);
detailHost.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; }));
return;
}
if(selectedUserKey && selectedUserKey.startsWith("req:")){
const r = LINK_REQUESTS.find(x=>"req:"+x.id===selectedUserKey);
detailHost.appendChild(detailHead(r.pupil.displayName, `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`));
detailHost.appendChild(requestTools(r));
return;
}
detailHost.appendChild(h("div","am-detail-empty", T("amDetailEmpty")));
}
/* 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 });
name.value = "";
if(pwInp) pwInp.value = "";
if(confirmInp) confirmInp.value = "";
/* het wachtwoord of de koppelcode verschijnt direct bij de nieuwe
gebruiker in de lijst (rij en groep klappen vanzelf open) */
rowResults.set(r.user.id, {
label: r.user.password ? T("amResultPw") : T("amResultCode"),
value: r.user.password || r.user.linkCode,
});
openRows.add(r.user.id);
if(role==="pupil"){
openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none");
openGroups.add("pupils");
}
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;
}
/* nieuw-gebruiker-formulier in het detailpaneel: eerst de rol kiezen
(super aanmaken blijft op het Systeem-tabblad - die rol hoort niet bij
één school), daarna de velden die bij die rol horen */
function newUserForm(){
const wrap = h("div","am-tools");
const roles = creatableRoles().filter(r=>r!=="super");
const roleSel = h("select","am-sel");
roles.forEach(r=>roleSel.appendChild(new Option(T(roleKey(r)), r)));
roleSel.value = roles.includes("pupil") ? "pupil" : roles[0];
wrap.appendChild(field("amFieldRole", roleSel));
const body = h("div","am-tools");
wrap.appendChild(body);
const build = ()=>{
body.innerHTML = "";
const role = roleSel.value;
const name = h("input","am-inp");
name.placeholder = T("amUserPh");
name.addEventListener("keydown", ev=>ev.stopPropagation());
body.appendChild(field("amFieldName", name));
let cs = null;
if(role==="pupil"){ cs = classSel(""); body.appendChild(field("amFieldClass", cs)); }
let pwInp = null;
if(role!=="pupil"){
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);
body.appendChild(field("amFieldPw", pwWrap));
}
const go = h("button","am-btn am-primary", T("amNewUser"));
go.type = "button";
go.disabled = true;
name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; });
const errEl = h("div","am-form-err");
go.addEventListener("click", async ()=>{
errEl.textContent = "";
const reqBody = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) reqBody.classId = +cs.value;
if(pwInp && pwInp.value.trim()) reqBody.password = pwInp.value.trim();
if(currentUser.role==="super") reqBody.school = +selSchool;
try{
const r = await api("/admin/users", { body: reqBody });
rowResults.set(r.user.id, {
label: r.user.password ? T("amResultPw") : T("amResultCode"),
value: r.user.password || r.user.linkCode,
});
if(role==="pupil"){
openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none");
openGroups.add("pupils");
}
/* de nieuwe gebruiker wordt direct geselecteerd: het eenmalige
wachtwoord of de koppelcode staat dan in het detailpaneel */
selectedUserKey = "u:"+r.user.id;
reload();
}catch(e){ errEl.textContent = e.message; }
});
body.appendChild(go);
body.appendChild(errEl);
setTimeout(()=>name.focus(), 0);
};
roleSel.addEventListener("change", build);
build();
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){
SCHOOLS.forEach(s=>{
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
/* bewerk-gereedschap van de school zelf (hernoemen/verwijderen) */
const tools = h("div","am-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));
/* één kader om de school en haar beheerders; beheerders standaard
ingeklapt achter de teller, als compacte subrijen */
panel.appendChild(groupCard({
icon:"🏫",
title:s.name,
summary:`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`,
key:"schooladmins:"+s.id,
edit:{ key:"school:"+s.id, tools },
subs: admins.map(a=>userRow(a, { sub:true, compact:true })),
}));
});
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";
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 = 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);
}
/* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij
een school; de systeemmanager kan een klas naar een andere school
verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete
school om klassen bij te tonen of aan te maken. */
function renderKlassenPanel(panel){
if(currentUser.role==="super" && !selSchool){
panel.appendChild(h("div","guestnote", T("amPickSchoolFirst")));
return;
}
const canResetPw = can("schools.resetPasswords");
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
CLASSES.forEach(c=>{
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
let edit = null;
if(canResetPw){
const tools = h("div","am-tools");
/* systeemmanager: klas naar een andere school verplaatsen */
if(currentUser.role==="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); }
});
tools.appendChild(field("amFieldMoveSchool", ss));
}
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); }
}));
tools.appendChild(field("amFieldActions", acts));
edit = { key:"klas:"+c.id, tools };
}
panel.appendChild(groupCard({
title:c.name,
summary:`${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`,
edit,
}));
});
/* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat
bestaande klassen bij een andere school kunnen horen */
if(!CLASSES.length){
panel.appendChild(h("div","guestnote", T("amNoClassesHere")));
}
if(canResetPw){
const f = h("div","am-add");
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);
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 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));
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); }
});
/* kop toont compact de huidige stand; de keuzes klappen uit via Bewerken */
const tools = h("div","am-tools");
const inline = h("div","am-inline");
inline.appendChild(sel);
inline.appendChild(widgetSel);
inline.appendChild(modeSel);
inline.appendChild(apply);
tools.appendChild(inline);
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;
}
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);
const moreBtn = h("button","am-btn", T("amShowMore"));
moreBtn.type = "button";
moreBtn.style.display = "none";
panel.appendChild(moreBtn);
pupilSel.addEventListener("change", async ()=>{
eventsHolder.innerHTML = "";
moreBtn.style.display = "none";
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; }
/* 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})`;
};
moreBtn.onclick = showMore;
showMore();
});
}
/* 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();
/* expliciet de gebruikerslijst: het Aanmeldingen-blok erboven is ook een
.am-list maar valt buiten het zoekfilter en de groepslogica */
const list = panel.querySelector(".am-userlist") || 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)). */
/* aanmeldverzoek van een ouder (thuiskind -> deze school): de school kiest
adopteren (thuisaccount wordt de schoolleerling, evt. met klas en - bij
naamconflict - een andere gebruikersnaam), koppelen aan een bestaande
leerling, of afwijzen */
/* aanmeldverzoek als tabelrij; afhandelen gebeurt in het detailpaneel */
function requestCells(r){
const row = h("div","am-row am-tr");
row.dataset.selkey = "req:"+r.id;
row.appendChild(nameCellFor(r.pupil.displayName, r.pupil.username));
row.appendChild(h("span","am-cell", T("amRequestOne")));
row.appendChild(h("span","am-cell", `${T("amRequestVia")} ${r.parent.displayName}`));
const stCell = h("span","am-cell am-status");
stCell.appendChild(h("span","am-chip on", T("amReqNew")));
row.appendChild(stCell);
row.addEventListener("click", ()=>{ selectedUserKey = "req:"+r.id; syncSelection(); });
return row;
}
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 }); selectedUserKey = 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 } }); selectedUserKey = 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" } }); selectedUserKey = null; reload(); }
catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", actions));
return tools;
}
function renderGebruikersPanel(panel){
const schoolChosenTop = currentUser.role!=="super" || !!selSchool;
/* werkbalk: zoeken links, nieuwe gebruiker rechts */
const bar = h("div","am-toolbar");
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); });
bar.appendChild(filterInp);
if(schoolChosenTop && creatableRoles().some(r=>r!=="super")){
const add = h("button","am-btn am-primary", T("amNewUser"));
add.type = "button";
add.addEventListener("click", ()=>{ selectedUserKey = "new"; syncSelection(); });
bar.appendChild(add);
}
panel.appendChild(bar);
/* master (tabel) + detail (bewerkpaneel) */
const split = h("div","am-split");
const master = h("div","am-master");
const detail = h("div","am-detail");
masterHost = master; detailHost = detail;
/* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */
if(LINK_REQUESTS.length){
master.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`));
const rl = h("div","am-reqlist am-list");
LINK_REQUESTS.forEach(r=>rl.appendChild(requestCells(r)));
master.appendChild(rl);
}
/* kolomkoppen boven de tabel */
const thead = h("div","am-thead");
["amFieldName","amFieldRole","amFieldClass","amColStatus"].forEach(k=>thead.appendChild(h("span",null,T(k))));
master.appendChild(thead);
const list = h("div","am-list am-userlist");
/* inklapbare groepskop; standaard dicht tenzij defaultOpen (dan onthoudt
closedGroups een bewuste dichtklap). Zoeken kijkt door alle groepen heen. */
const group = (key, label, us, defaultOpen)=>{
if(defaultOpen && !closedGroups.has(key)) openGroups.add(key);
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); closedGroups.add(key); }
else{ openGroups.add(key); closedGroups.delete(key); }
sync();
applyUserFilter(panel);
});
list.appendChild(g);
us.forEach(u=>list.appendChild(userCells(u)));
};
if(currentUser.role==="super" && !selSchool){
/* ALLE SCHOLEN: groepeer per school zodat je meteen ziet wie waar hoort;
daarna systeemmanagers, ouders en losse accounts apart */
const roleOrder = { admin:0, teacher:1, pupil:2 };
SCHOOLS.forEach(sc=>{
const us = USERS.filter(u=>Number(u.schoolId)===Number(sc.id))
.sort((a,b)=>(roleOrder[a.role]??9)-(roleOrder[b.role]??9) || a.displayName.localeCompare(b.displayName));
if(us.length) group("gschool:"+sc.id, sc.name, us);
});
const supers = USERS.filter(u=>u.role==="super");
if(supers.length) group("gsys", T("amSuperGroup"), supers);
/* systeemmanagers horen niet bij één school, dus het aanmaken ervan
staat hier (bij "alle scholen") i.p.v. tussen de school-gebonden
gebruikers - met een extra bevestigingsstap (eigen wachtwoord). */
if(creatableRoles().includes("super")) list.appendChild(addForm("super"));
const parents = USERS.filter(u=>u.role==="parent");
if(parents.length) group("gparents", T("amParents"), parents);
const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null);
if(loose.length) group("gnoschool", T("amNoSchoolGroup"), loose);
}else{
/* één school gekozen: groepeer op rol, leerlingen per klas - elke groep
krijgt direct een "snel toevoegen"-formulier eronder (net als bij
systeemmanagers), zodat een nieuwe schoolbeheerder/leerkracht/
leerling niet via een ander tabblad hoeft. Een groep zonder leden
blijft zichtbaar zolang er wél iets aan toegevoegd kan worden -
anders is een lege klas nergens te vinden om iemand aan toe te voegen. */
[["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]].forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role);
if(us.length || creatableRoles().includes(role)) group("role:"+role, label, us, true);
if(creatableRoles().includes(role)) list.appendChild(addForm(role));
});
const pupils = USERS.filter(u=>u.role==="pupil");
if(currentUser.role==="teacher"){
if(pupils.length || creatableRoles().includes("pupil")) group("pupils", T("amPupils"), pupils, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil"));
}else{
CLASSES.forEach(c=>{
const us = pupils.filter(u=>u.classId===c.id);
if(us.length || creatableRoles().includes("pupil")) group("class:"+c.id, c.name, us, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil", c.id));
});
const noClass = pupils.filter(u=>!u.classId);
if(noClass.length || creatableRoles().includes("pupil")) group("class:none", T("amNoClass"), noClass, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil"));
}
const parents = USERS.filter(u=>u.role==="parent");
if(parents.length) group("parents", T("amParents"), parents);
}
master.appendChild(list);
split.appendChild(master);
split.appendChild(detail);
panel.appendChild(split);
applyUserFilter(panel);
syncSelection(); /* selectie valideren + detailpaneel vullen */
}
function switchAdminTab(tab){
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 = ["scholen","opslag"].includes(tab) ? "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; selectedUserKey = null; 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, opslag: renderImageStoragePanel };
tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel");
panel.dataset.panel = t;
panels[t](panel);
modal.appendChild(panel);
});
switchAdminTab(adminTab);
}
})();