All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- Leerlingen stonden onder standaard-ingeklapte klasgroepen, dus met een gekozen school leek de lijst leeg terwijl de detailweergave de juiste school toonde. Klasgroepen staan nu standaard open zodra een school is gekozen (de schoolkeuze wás al het filter); bewust dichtklappen wordt onthouden - Losse klassen (school verwijderd) waren nergens zichtbaar: de systeemmanager ziet ze nu zonder gekozen school met een uitleg, en kan ze via een School-veld terugkoppelen; nieuwe PATCH /admin/classes/:id + GET ?school=none (super) met servertests - Bij het aan een school koppelen van een leerling blijft de klasindeling behouden als die klas al bij de doelschool hoort (eerst klas terugkoppelen, dan de leerlingen) - UI-consistentie: één uniforme control-hoogte (32px) voor álle knoppen, selects en invoervelden in Instellingen - einde aan de grote maatverschillen; T() valt terug op NL en dan de sleutelnaam zodat een knop nooit zonder tekst kan staan - Status in de gebruikerstabel als compacte glyph (● in gebruik / ○ wacht) met uitleg bij aanwijzen i.p.v. een afgekapte "nog niet ing…"-chip - Geverifieerd in headless Chromium: nieuwe suite (21 checks: zichtbaarheid met schoolfilter, losse klassen, geen lege knoppen, uniforme hoogte over 8 secties) + volledige regressie van 5 bestaande suites Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
1400 lines
62 KiB
JavaScript
1400 lines
62 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;
|
||
/* 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,"&").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-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 ()=>{
|
||
selSchool = 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;
|
||
});
|
||
|
||
async function reload(){
|
||
try{
|
||
if(currentUser.role==="super") SCHOOLS = await api("/schools");
|
||
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
|
||
USERS = (await api("/admin/users"+q)).users;
|
||
/* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als
|
||
de contextbalk op één school staat; zonder schoolfilter is USERS al
|
||
compleet en is de extra call overbodig */
|
||
if(currentUser.role==="super"){
|
||
SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin")
|
||
: USERS.filter(u=>u.role==="admin");
|
||
}
|
||
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=none") : "";
|
||
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 = []; }
|
||
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;
|
||
}
|
||
|
||
/* ---- 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){
|
||
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){ msg(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){ msg(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){ msg(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){ msg(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}}); }
|
||
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-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){ msg(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){ 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 });
|
||
selectedUserKey = "u:"+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 });
|
||
selectedUserKey = "u:"+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"}); selectedUserKey = null; reload(); }
|
||
catch(e){ msg(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);
|
||
detailHost.appendChild(userTools(u));
|
||
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";
|
||
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));
|
||
}
|
||
const go = h("button","tbtn", label);
|
||
go.type = "button";
|
||
go.disabled = true;
|
||
name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; });
|
||
/* 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;
|
||
try{
|
||
const r = await api("/admin/users", { body });
|
||
name.value = "";
|
||
if(pwInp) pwInp.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";
|
||
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); de systeemmanager ziet zonder
|
||
gekozen school de losse klassen (school verwijderd) om ze terug te koppelen */
|
||
function renderKlassenPanel(panel){
|
||
const looseView = currentUser.role==="super" && !selSchool;
|
||
const canResetPw = can("schools.resetPasswords");
|
||
if(looseView){
|
||
if(!CLASSES.length){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
panel.appendChild(h("div","guestnote", T("amLooseClassesHint")));
|
||
}
|
||
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 aan een school (terug)koppelen */
|
||
if(currentUser.role==="super"){
|
||
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 = looseView ? "" : (selSchool || "");
|
||
ss.addEventListener("change", async ()=>{
|
||
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); }
|
||
catch(e){ msg(e.message); }
|
||
});
|
||
tools.appendChild(field("amFieldSchool", ss));
|
||
}
|
||
const acts = h("div","am-actions");
|
||
if(!looseView){
|
||
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,
|
||
}));
|
||
});
|
||
if(looseView) return;
|
||
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 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");
|
||
/* losse accounts (ooit aan een school gekoppeld, nu zonder): alleen
|
||
relevant voor de systeemmanager in de alle-scholen-weergave */
|
||
const showLoose = currentUser.role==="super" && !selSchool;
|
||
const isLoose = u=>showLoose && ["teacher","admin","pupil"].includes(u.role) && u.schoolId==null;
|
||
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]];
|
||
staffGroups.forEach(([role, label])=>{
|
||
const us = USERS.filter(u=>u.role===role && !isLoose(u));
|
||
if(!us.length) return;
|
||
list.appendChild(h("div","am-group", `${label} (${us.length})`));
|
||
us.forEach(u=>list.appendChild(userCells(u)));
|
||
});
|
||
|
||
const pupils = USERS.filter(u=>u.role==="pupil" && !isLoose(u));
|
||
const schoolChosen = schoolChosenTop;
|
||
|
||
/* 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)=>{
|
||
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)));
|
||
};
|
||
|
||
/* 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) pupilGroup("pupils", T("amPupils"), pupils);
|
||
}else{
|
||
CLASSES.forEach(c=>{
|
||
const us = pupils.filter(u=>u.classId===c.id);
|
||
/* met een gekozen school staan de klasgroepen standaard open - de
|
||
schoolkeuze wás al het filter; dichtklappen wordt onthouden */
|
||
if(us.length && !closedGroups.has("class:"+c.id)) openGroups.add("class:"+c.id);
|
||
if(us.length) pupilGroup("class:"+c.id, c.name, us);
|
||
});
|
||
const noClass = pupils.filter(u=>!u.classId);
|
||
if(noClass.length) pupilGroup("class:none", T("amNoClass"), noClass);
|
||
}
|
||
const parents = USERS.filter(u=>u.role==="parent");
|
||
if(parents.length) pupilGroup("parents", T("amParents"), parents);
|
||
const loose = USERS.filter(isLoose);
|
||
if(loose.length) pupilGroup("noschool", T("amNoSchoolGroup"), loose);
|
||
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);
|
||
}
|
||
})();
|