All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
- Gebruikers staan in een echte tabel (initiaal-avatar + naam | functie | klas | status) met kolomkoppen en uitgelijnde kolommen; klikken op een rij selecteert (accent-markering) zonder her-render, dus het zoekveld houdt focus - Rechts een vast detailpaneel: bewerken (functie, klaskoppeling, klas, wachtwoord, acties), aanmeldverzoeken afhandelen én nieuwe gebruikers aanmaken gebeuren allemaal dáár - één plek, één patroon - Eenmalige wachtwoorden/codes verschijnen als badge in het paneel; na aanmaken is de nieuwe gebruiker direct geselecteerd; selectie overleeft herladen en wordt gevalideerd tegen de data - Aanmeldverzoeken zijn gewone tabelrijen met een "nieuw"-chip; de losse toevoeg-formulieren per klas zijn vervangen door één "Nieuwe gebruiker"-knop in de werkbalk (met rolkeuze) - Smal scherm: het detailpaneel wordt een overlay met sluitknop (onder de tabel stapelen is onbruikbaar bij grote scholen) - Menu-items herzien: de sectie heet "Scholen" (geen "Systeem > Systeem"-verdubbeling meer) en de Systeem-groep is geordend als Scholen / Inhoud / Opslag - Eerste emoji-sweep in de omgebouwde delen (actieknoppen zijn tekstknoppen); zoekfilter en inklapbare klasgroepen werken ongewijzigd - Geverifieerd in headless Chromium (19 checks, incl. focusbehoud, overlay-gedrag en selectie-na-reload) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
1282 lines
57 KiB
JavaScript
1282 lines
57 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 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;
|
||
}
|
||
|
||
/* ---- 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=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; });
|
||
}
|
||
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(", ");
|
||
}
|
||
function userStatus(u){
|
||
if(u.pending) return T("amPending");
|
||
if(u.role==="pupil" && !u.everLoggedIn) return T("amNeverLoggedIn");
|
||
return "";
|
||
}
|
||
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");
|
||
const st = userStatus(u);
|
||
if(st) stCell.appendChild(h("span","am-chip", st));
|
||
else stCell.textContent = "—";
|
||
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);
|
||
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 row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : ""));
|
||
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
|
||
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","🎲");
|
||
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; });
|
||
/* 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){
|
||
const list = h("div","am-list");
|
||
SCHOOLS.forEach(s=>{
|
||
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
|
||
const row = h("div","am-row am-row-school");
|
||
row.appendChild(h("span","am-name", "🏫 " + s.name));
|
||
row.appendChild(h("span","am-role",
|
||
`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`));
|
||
/* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen
|
||
zitten achter het gereedschapsblok, de rij zelf blijft rustig */
|
||
const tools = h("div","am-row-tools");
|
||
const nameInp = h("input","am-inp");
|
||
nameInp.value = s.name;
|
||
nameInp.maxLength = 80;
|
||
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
nameInp.addEventListener("change", async ()=>{
|
||
const name = nameInp.value.trim();
|
||
if(name.length < 2 || name === s.name) return;
|
||
try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); }
|
||
catch(e){ msg(e.message); }
|
||
});
|
||
tools.appendChild(field("amFieldName", nameInp));
|
||
const acts = h("div","am-actions");
|
||
acts.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{
|
||
try{
|
||
await api("/admin/schools/"+s.id, {method:"DELETE"});
|
||
if(String(selSchool)===String(s.id)) selSchool = "";
|
||
reload();
|
||
}catch(e){ msg(e.message); }
|
||
}));
|
||
tools.appendChild(field("amFieldActions", acts));
|
||
const edit = h("button","am-edit","✏");
|
||
edit.type = "button";
|
||
edit.title = T("amEdit");
|
||
const key = "school:"+s.id;
|
||
const sync = ()=>{
|
||
const open = openRows.has(key);
|
||
tools.style.display = open ? "" : "none";
|
||
edit.classList.toggle("on", open);
|
||
};
|
||
edit.addEventListener("click", ()=>{
|
||
if(openRows.has(key)) openRows.delete(key); else openRows.add(key);
|
||
sync();
|
||
});
|
||
sync();
|
||
row.appendChild(edit);
|
||
row.appendChild(tools);
|
||
list.appendChild(row);
|
||
/* de schoolbeheerders van deze school, duidelijk onderscheiden van de
|
||
schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */
|
||
admins.forEach(a=>list.appendChild(userRow(a, { sub:true })));
|
||
});
|
||
panel.appendChild(list);
|
||
|
||
const wrap = h("div","am-add-wrap");
|
||
const toggle = h("button","tbtn ghost", T("amNewSchool"));
|
||
toggle.type = "button";
|
||
wrap.appendChild(toggle);
|
||
const f = h("div","am-add");
|
||
f.style.display = "none";
|
||
const inp = h("input","am-inp");
|
||
inp.placeholder = T("amSchoolNamePh");
|
||
inp.maxLength = 80;
|
||
inp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
f.appendChild(field("amFieldName", inp));
|
||
const add = h("button","tbtn", T("amNewSchool"));
|
||
add.type = "button";
|
||
add.disabled = true;
|
||
let armTimer = null;
|
||
const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); };
|
||
inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); });
|
||
add.addEventListener("click", async ()=>{
|
||
const name = inp.value.trim();
|
||
if(name.length < 2) return;
|
||
if(!armTimer){
|
||
add.classList.add("danger");
|
||
add.textContent = `${T("amNewSchoolConfirm")} “${name}”?`;
|
||
armTimer = setTimeout(disarm, 5000);
|
||
return;
|
||
}
|
||
disarm();
|
||
try{
|
||
const r = await api("/admin/schools", { body:{ name } });
|
||
selSchool = r.school.id; inp.value = "";
|
||
reload();
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
f.appendChild(add);
|
||
wrap.appendChild(f);
|
||
toggle.addEventListener("click", ()=>{
|
||
toggle.style.display = "none";
|
||
f.style.display = "flex";
|
||
inp.focus();
|
||
});
|
||
panel.appendChild(wrap);
|
||
|
||
/* systeemmanagers horen niet bij één school, dus het aanmaken ervan
|
||
staat hier i.p.v. tussen de school-gebonden gebruikers in de Gebruikers-tab
|
||
(de lijst van bestaande supers blijft wel gewoon in Gebruikers staan) */
|
||
if(creatableRoles().includes("super")) panel.appendChild(addForm("super"));
|
||
}
|
||
|
||
/* klassen (admin/super binnen een school) */
|
||
function renderKlassenPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
const canResetPw = can("schools.resetPasswords");
|
||
/* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken),
|
||
met de gevaarlijke acties als aparte, bevestigde knoppen */
|
||
const list = h("div","am-list");
|
||
CLASSES.forEach(c=>{
|
||
const row = h("div","am-row");
|
||
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
|
||
row.appendChild(h("span","am-name", c.name));
|
||
row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`));
|
||
if(canResetPw){
|
||
const acts = h("div","am-actions");
|
||
acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{
|
||
try{
|
||
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
|
||
await reload();
|
||
resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users };
|
||
render();
|
||
}catch(e){ msg(e.message); }
|
||
}));
|
||
acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{
|
||
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
|
||
catch(e){ msg(e.message); }
|
||
}));
|
||
row.appendChild(acts);
|
||
}
|
||
list.appendChild(row);
|
||
});
|
||
panel.appendChild(list);
|
||
if(canResetPw){
|
||
const f = h("div","am-add");
|
||
const inp = h("input","am-inp");
|
||
inp.placeholder = T("amClassNamePh");
|
||
inp.maxLength = 80;
|
||
inp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
f.appendChild(field("amFieldName", inp));
|
||
const go = h("button","tbtn", T("amNewClass"));
|
||
go.type = "button";
|
||
go.disabled = true;
|
||
inp.addEventListener("input", ()=>{ go.disabled = inp.value.trim().length < 2; });
|
||
go.addEventListener("click", async ()=>{
|
||
if(inp.value.trim().length < 2) return;
|
||
try{
|
||
await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } });
|
||
reload();
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
f.appendChild(go);
|
||
panel.appendChild(f);
|
||
|
||
/* multireset: alle leerlingwachtwoorden van de hele school in één keer */
|
||
panel.appendChild(dangerBtn(T("amResetSchool"), "amConfirmResetSchool", async ()=>{
|
||
const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId;
|
||
if(!schoolId) return;
|
||
try{
|
||
const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"});
|
||
await reload();
|
||
resetResults = { title: T("amResetSchool"), entries: r.users };
|
||
render();
|
||
}catch(e){ msg(e.message); }
|
||
}));
|
||
}
|
||
}
|
||
|
||
/* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of
|
||
een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord
|
||
live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw
|
||
toe te wijzen. Een expliciete "Toewijzen"-knop i.p.v. direct bij het kiezen van
|
||
een bord opslaan, zodat een klas-brede wijziging niet per ongeluk gebeurt. */
|
||
/* widgets van een eigen bord (voor de één-widget-keuze in assignmentRow);
|
||
widgets zonder wid komen uit borden van vóór v0.3.13 en verschijnen pas
|
||
na de eerstvolgende save van dat bord */
|
||
function boardWidgets(boardId){
|
||
let found = null;
|
||
BS.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(bo.id===boardId) found = bo; }));
|
||
return ((found && found.data && found.data.widgets) || []).filter(w=>w.wid);
|
||
}
|
||
function describeCurrent(current){
|
||
if(!current) return "";
|
||
let s = `${T("amAssignedAs")} “${current.boardName || current.boardId}”`;
|
||
if(current.widgetId){
|
||
const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId;
|
||
s += ` › ${wName} (${current.mode==="kijken" ? T("amModeWatch") : T("amModeWork")})`;
|
||
}
|
||
return s;
|
||
}
|
||
function assignmentRow(target, boardOptions, current){
|
||
const row = h("div","am-row");
|
||
row.appendChild(h("span","am-name", target.label));
|
||
const sel = h("select","am-sel");
|
||
sel.appendChild(new Option(T("amNoAssignment"), ""));
|
||
boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id)));
|
||
sel.value = current ? current.boardId : "";
|
||
/* keuze: heel bord (default, bestaand gedrag) of één widget + modus */
|
||
const widgetSel = h("select","am-sel am-widget-sel");
|
||
const modeSel = h("select","am-sel am-mode-sel");
|
||
modeSel.appendChild(new Option(T("amModeWork"), "werken"));
|
||
modeSel.appendChild(new Option(T("amModeWatch"), "kijken"));
|
||
const syncWidgetSel = ()=>{
|
||
widgetSel.innerHTML = "";
|
||
widgetSel.appendChild(new Option(T("amWholeBoard"), ""));
|
||
boardWidgets(sel.value).forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid)));
|
||
widgetSel.value = (current && current.boardId===sel.value && current.widgetId) ? current.widgetId : "";
|
||
widgetSel.style.display = sel.value ? "" : "none";
|
||
syncModeSel();
|
||
};
|
||
const syncModeSel = ()=>{ modeSel.style.display = (sel.value && widgetSel.value) ? "" : "none"; };
|
||
modeSel.value = current && current.mode ? current.mode : "werken";
|
||
sel.addEventListener("change", syncWidgetSel);
|
||
widgetSel.addEventListener("change", syncModeSel);
|
||
const info = h("span","am-code", describeCurrent(current));
|
||
row.appendChild(sel);
|
||
row.appendChild(widgetSel);
|
||
row.appendChild(modeSel);
|
||
row.appendChild(info);
|
||
syncWidgetSel();
|
||
const apply = h("button","am-btn", T("amAssign"));
|
||
apply.type = "button";
|
||
apply.addEventListener("click", async ()=>{
|
||
try{
|
||
if(sel.value){
|
||
const body = { boardId: sel.value };
|
||
if(widgetSel.value){ body.widgetId = widgetSel.value; body.mode = modeSel.value; }
|
||
await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body});
|
||
}
|
||
else await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"});
|
||
if(target.type==="class"){ await reload(); }
|
||
else{
|
||
const a = (await api("/assignments/pupil/"+target.id)).assignment;
|
||
info.textContent = describeCurrent(a);
|
||
msg(T("amAssignSaved"));
|
||
}
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
row.appendChild(apply);
|
||
return row;
|
||
}
|
||
|
||
/* vult een <select> met leerlingen gegroepeerd per klas (optgroup), gedeeld
|
||
tussen de Toewijzingen- en Voortgang-tab die beide één leerling kiezen */
|
||
function fillPupilSelect(sel, pupils){
|
||
sel.appendChild(new Option(T("amPickPupil"), ""));
|
||
const byClass = new Map();
|
||
pupils.forEach(u=>{
|
||
const key = u.classId || "none";
|
||
if(!byClass.has(key)) byClass.set(key, []);
|
||
byClass.get(key).push(u);
|
||
});
|
||
const addGroup = (label, us)=>{
|
||
if(!us || !us.length) return;
|
||
const og = document.createElement("optgroup");
|
||
og.label = label;
|
||
us.forEach(u=>og.appendChild(new Option(u.displayName, u.id)));
|
||
sel.appendChild(og);
|
||
};
|
||
CLASSES.forEach(c=>addGroup(c.name, byClass.get(c.id)));
|
||
addGroup(T("amNoClass"), byClass.get("none"));
|
||
}
|
||
|
||
/* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele
|
||
leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */
|
||
function renderToewijzingenPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
const boardOptions = [];
|
||
BS.folders.forEach(fo=>fo.boards.forEach(bo=>boardOptions.push({ id: bo.id, label: `${fo.name} / ${bo.name}` })));
|
||
|
||
panel.appendChild(h("div","am-group", T("amAssignClasses")));
|
||
const classList = h("div","am-list");
|
||
if(!CLASSES.length) classList.appendChild(h("div","guestnote", T("amNoClass")));
|
||
CLASSES.forEach(c=>classList.appendChild(assignmentRow({type:"class", id:c.id, label:c.name}, boardOptions, ASSIGNMENTS[c.id])));
|
||
panel.appendChild(classList);
|
||
|
||
panel.appendChild(h("div","am-group", T("amAssignPupils")));
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
const pupilSel = h("select","am-sel");
|
||
fillPupilSelect(pupilSel, pupils);
|
||
panel.appendChild(pupilSel);
|
||
const pupilRowHolder = h("div");
|
||
panel.appendChild(pupilRowHolder);
|
||
pupilSel.addEventListener("change", async ()=>{
|
||
pupilRowHolder.innerHTML = "";
|
||
if(!pupilSel.value) return;
|
||
const u = pupils.find(x=>String(x.id)===pupilSel.value);
|
||
let current = null;
|
||
try{ current = (await api("/assignments/pupil/"+u.id)).assignment; }catch(e){}
|
||
pupilRowHolder.appendChild(assignmentRow({type:"pupil", id:u.id, label:u.displayName}, boardOptions, current));
|
||
});
|
||
}
|
||
|
||
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
|
||
function progressRow(ev){
|
||
const row = h("div","am-row");
|
||
row.appendChild(h("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType));
|
||
const scoreParts = [];
|
||
if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`);
|
||
if(ev.stars) scoreParts.push("★".repeat(ev.stars));
|
||
row.appendChild(h("span","am-code", scoreParts.join(" · ")));
|
||
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
|
||
return row;
|
||
}
|
||
|
||
/* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als
|
||
Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde
|
||
widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */
|
||
function renderVoortgangPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
const pupilSel = h("select","am-sel");
|
||
fillPupilSelect(pupilSel, pupils);
|
||
panel.appendChild(pupilSel);
|
||
const eventsHolder = h("div","am-list");
|
||
panel.appendChild(eventsHolder);
|
||
pupilSel.addEventListener("change", async ()=>{
|
||
eventsHolder.innerHTML = "";
|
||
if(!pupilSel.value) return;
|
||
let events = [];
|
||
try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
|
||
catch(e){ msg(e.message); return; }
|
||
if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
|
||
events.forEach(ev=>eventsHolder.appendChild(progressRow(ev)));
|
||
});
|
||
}
|
||
|
||
/* zoekfilter + inklapbare groepen voor de gebruikerslijst: één plek die de
|
||
zichtbaarheid bepaalt (style.display i.p.v. her-render, zodat het zoekveld
|
||
focus houdt). Ingeklapte groepen verbergen hun rijen; zodra er gezocht
|
||
wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */
|
||
function applyUserFilter(panel){
|
||
const q = userFilter.trim().toLowerCase();
|
||
/* 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");
|
||
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]];
|
||
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(userCells(u)));
|
||
});
|
||
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
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); else openGroups.add(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);
|
||
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);
|
||
}
|
||
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","🏫"));
|
||
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);
|
||
}
|
||
})();
|