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