/* 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 = "school"; /* 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(); /* apart van openRows: klas-id's en gebruiker-id's zijn losse reeksen in de database en kunnen dus samenvallen (klas 3 én gebruiker 3) - een gedeelde Set zou dan per ongeluk de verkeerde bewerktools open/dicht klappen */ const openClassTools = new Set(); const openSchoolTools = new Set(); /* zelfde reden als hierboven */ /* 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(/{ 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. Scholen, klassen en gebruikers zijn hier bewust ÉÉN sectie ("school"): voor een schoolbeheerder/leerkracht is dat toch al hun ene eigen school, en een systeemmanager ziet binnen die ene sectie alsnog alles (zie renderSchoolPanel hieronder, dat de drie oorspronkelijke deelpanelen ongewijzigd naast elkaar zet). Per rol alleen de essentie: groepsleiding ziet hierbinnen alleen gebruikers, schoolbeheer ook klassen, de systeemmanager ook scholen (en daarnaast losstaand nog opslag). */ const ADMIN_SECTIONS = [ { tab:"school", icon:"🏫", group:"school", order:10, labelKey:"amSchoolMenu", subKey:"stSchoolMenuSub", show:()=>true, keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"), T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password", "koppelcode","aanmelding","enrolment", T("amNewClass"), T("amResetClass"), "klas","class","groep","group", T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"], search:q=>[ ...USERS .filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q)) .map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫", label:u.displayName, sub:T(roleKey(u.role)), go:()=>goSettingsSection("school", { filter:u.displayName }) })), ...CLASSES .filter(c=>c.name.toLowerCase().includes(q)) .map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>goSettingsSection("school") })), ...SCHOOLS .filter(s=>s.name.toLowerCase().includes(q)) .map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>goSettingsSection("school") })), ] }, { 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:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", show:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, ]; const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]); /* data één keer per bezoek aan Instellingen laden; secties onderling hergebruiken de lading (in-flight promise voorkomt dubbel laden) */ let loadPromise = null; function ensureLoaded(force){ if(force) loadPromise = null; if(!loadPromise){ loadPromise = (async ()=>{ /* super: selSchool null = nog niet gekozen (reload() zet dan de eerste school als default zodat klassen/gebruikers meteen een concrete school tonen); "" = bewust "alle scholen". Andere rollen: eigen school. */ if(currentUser.role!=="super") selSchool = currentUser.schoolId; await reload(); })(); } return loadPromise; } /* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */ window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); }; ADMIN_SECTIONS.forEach(d=>{ registerSettingsSection({ id:d.tab, icon:d.icon, group:d.group, order:d.order, label:()=>T(d.labelKey), sub:()=>T(d.subKey), visible:()=>!!currentUser && can("admin.access") && d.show(), async activate(payload){ await ensureLoaded(); if(d.tab==="school" && payload && payload.filter !== undefined) userFilter = payload.filter; showAdminHost(); switchAdminTab(d.tab); if(d.tab==="school" && payload && payload.filter !== undefined){ const panel = modal.querySelector('[data-panel="school"]'); const inp = panel && panel.querySelector(".am-filter"); if(inp){ inp.value = userFilter; applyUserFilter(panel); } } }, keywords:d.keywords, searchItems:d.search, }); }); document.addEventListener("userchange", ()=>{ if(!currentUser) resetResults = null; loadPromise = null; selectedUserKey = null; selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ }); async function reload(){ let reloadError = null; try{ if(currentUser.role==="super"){ SCHOOLS = await api("/schools"); /* eerste bezoek: standaard "alle scholen" (het scholenoverzicht) i.p.v. meteen een school te kiezen - de systeemmanager beheert immers over scholen heen; een specifieke school kiezen is een bewuste stap. */ if(selSchool == null) selSchool = ""; } const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; USERS = (await api("/admin/users"+q)).users; /* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als de contextbalk op één school staat; zonder schoolfilter is USERS al compleet en is de extra call overbodig */ if(currentUser.role==="super"){ SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin") : USERS.filter(u=>u.role==="admin"); } const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; ASSIGNMENTS = {}; if(CLASSES.length){ const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); } LINK_REQUESTS = []; if(currentUser.role!=="super" || selSchool){ const lq = currentUser.role==="super" ? "?school="+selSchool : ""; LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || []; } }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; reloadError = e; } render(); /* render() bouwt de modal (incl. .am-msg) helemaal opnieuw op, dus de melding pas ná render() zetten - anders wordt hij meteen weer gewist. Zonder dit bleef het paneel bij een fout stilletjes helemaal leeg staan zonder enige aanwijzing waarom. */ if(reloadError) msg(T("amLoadError") + ": " + reloadError.message); } function classSel(current){ const sel = h("select","am-sel"); sel.appendChild(new Option(T("amNoClass"), "")); CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id))); sel.value = current ?? ""; return sel; } /* ---- gebruikers: master-detail --------------------------------------------- Links een compacte tabel (naam | functie | klas | status), rechts een vast detailpaneel waarin de geselecteerde gebruiker bewerkt wordt. De selectie overleeft reload(); credential-acties selecteren de gebruiker zodat het eenmalige wachtwoord/de code direct in het paneel zichtbaar is. */ let selectedUserKey = null; /* "u:" | "req:" | "new" | null */ let masterHost = null, detailHost = null; function avatar(nameStr, big){ const parts = String(nameStr || "?").trim().split(/\s+/); const ini = ((parts[0][0] || "?") + (parts[1] ? parts[1][0] : "")).toUpperCase(); return h("span", "am-avatar" + (big ? " big" : ""), ini); } function userKlasText(u){ if(u.role==="pupil"){ const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); return cls ? cls.name : ""; } if(u.role==="parent") return (u.childNames||[]).join(", "); return (u.classIds||[]) .map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name) .filter(Boolean).join(", "); } /* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op eerste login/activatie. Tekst clipte in de smalle statuskolom. */ function userStatusGlyph(u){ const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn); const label = u.pending ? T("amPending") : (u.role==="pupil" && !u.everLoggedIn) ? T("amNeverLoggedIn") : T("amStatusActive"); const g = h("span","am-glyph" + (waiting ? "" : " ok"), waiting ? "○" : "●"); g.title = label; g.setAttribute("aria-label", label); return g; } function nameCellFor(display, username){ const cell = h("span","am-name"); cell.appendChild(avatar(display)); const nm = h("span","am-name-txt", display); if(username && display!==username) nm.appendChild(h("small","am-username"," · "+username)); cell.appendChild(nm); return cell; } /* tabelrij in de gebruikerslijst: klik selecteert (geen her-render, zodat het zoekveld focus houdt); bewerken gebeurt in het detailpaneel */ function userCells(u){ const row = h("div","am-row am-tr"); row.dataset.selkey = "u:"+u.id; row.appendChild(nameCellFor(u.displayName, u.username)); row.appendChild(h("span","am-cell", T(roleKey(u.role)))); row.appendChild(h("span","am-cell", userKlasText(u) || "—")); const stCell = h("span","am-cell am-status"); stCell.appendChild(userStatusGlyph(u)); row.appendChild(stCell); row.addEventListener("click", ()=>{ selectedUserKey = "u:"+u.id; syncSelection(); }); return row; } /* het bewerkformulier van één gebruiker; wordt gebruikt door het detailpaneel (gebruikers) én inline door het Systeem-tabblad (subrijen) */ function userTools(u, note){ note = note || msg; const tools = h("div","am-tools"); const actions = h("div","am-actions"); const isStaff = ["teacher","admin","super"].includes(u.role); /* school koppelen/losmaken (systeemmanager): losse accounts terugzetten naar hun school, of bewust losmaken - super/ouder zijn per definitie schoolloos en vallen hierbuiten */ if(currentUser.role==="super" && ["teacher","admin","pupil"].includes(u.role)){ const ss = h("select","am-sel"); ss.appendChild(new Option(T("amNoSchool"), "")); SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); ss.value = u.schoolId ?? ""; ss.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); } catch(e){ note(e.message); ss.value = u.schoolId ?? ""; } }); tools.appendChild(field("amFieldSchool", ss)); } /* rolwijziging: alleen tussen groepsleiding/schoolbeheerder onderling. Systeemmanager is hier bewust nooit een optie - die rol ontstaat alleen via de aparte "Nieuwe systeemmanager"-aanmaak (met bevestiging van het eigen wachtwoord), nooit door een bestaand account te promoveren. Een bestaande systeemmanager toont dit veld daarom ook niet: er is geen veilige weg om die rol via dit simpele dropdown-veld te wijzigen. */ if(isStaff && u.role!=="super" && can("users.role.change") && u.id!==currentUser.id){ const rs = h("select","am-sel"); ["teacher","admin"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r))); rs.value = u.role; rs.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); } catch(e){ note(e.message); } }); tools.appendChild(field("amFieldRole", rs)); } /* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding, schoolbeheerder én systeemmanager): huidige klassen als chips met ✕, en een select om een klas toe te voegen */ if(isStaff && can("classes.manage") && CLASSES.length){ const wrap = h("div","am-classes"); (u.classIds||[]).forEach(cid=>{ const c = CLASSES.find(x=>Number(x.id)===Number(cid)); if(!c) return; const chip = h("span","am-chip on", c.name); const x = h("button","am-chipx","✕"); x.type = "button"; x.title = T("amClassRemove"); x.addEventListener("click", async ()=>{ try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); } catch(e){ note(e.message); } }); chip.appendChild(x); wrap.appendChild(chip); }); const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id))); if(rest.length){ const addSel = h("select","am-sel"); addSel.appendChild(new Option(T("amClassAdd"), "")); rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id))); addSel.addEventListener("change", async ()=>{ if(!addSel.value) return; try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); } catch(e){ note(e.message); } }); wrap.appendChild(addSel); } tools.appendChild(field("amFieldClasses", wrap)); } if(u.role==="pupil"){ /* klas (uitwisselen met andere klassen) */ const cs = classSel(u.classId); cs.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); reload(); } catch(e){ note(e.message); } }); tools.appendChild(field("amFieldClass", cs)); /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ const pwWrap = h("div","am-inline"); const pw = h("input","am-pw"); pw.value = u.password || ""; pw.placeholder = u.everLoggedIn ? T("amPwHidden") : T("amPwTypePh"); pw.addEventListener("keydown", ev=>ev.stopPropagation()); pw.addEventListener("change", async ()=>{ if(pw.value.length < 4) return; try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); } catch(e){ note(e.message); } }); pwWrap.appendChild(pw); const pwCopy = h("button","am-btn", T("amCopy")); pwCopy.type = "button"; pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); pwWrap.appendChild(pwCopy); tools.appendChild(field("amFieldPw", pwWrap)); if(u.everLoggedIn){ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); rowResults.set(u.id, { label: T("amResultPw"), value: password }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); } /* eenmalige ouder-code waarmee een ouder dit kind aan het eigen ouderaccount koppelt (ouderportaal); een eerdere code vervalt */ actions.appendChild(dangerBtn(T("amParentCode"), "amParentCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); }else{ if(u.pending && u.linkCode){ tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); } if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){ actions.appendChild(dangerBtn(T("amNewCode"), "amNewCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); } } if(u.id !== currentUser.id){ const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"DELETE"}); selectedUserKey = null; reload(); } catch(e){ note(e.message); } }); actions.appendChild(del); } if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); return tools; } /* herbruikbaar "Bewerken"-knopje dat een tools-blok in-/uitklapt, gekoppeld aan een gegeven Set + id. Gebruikers, klassen en scholen hebben elk hun eigen Set (openRows/openClassTools/openSchoolTools) - hun id's zijn losse reeksen in de database en kunnen samenvallen, dus nooit delen. Eén gedeelde implementatie zorgt dat elke "Bewerken"-rij er overal identiek uitziet en hetzelfde gedraagt, i.p.v. drie keer bijna-hetzelfde stukje code met kleine, ongemerkt geslopen verschillen. */ function editToggle(tools, set, id){ const btn = h("button","am-edit", T("amEdit")); btn.type = "button"; const sync = ()=>{ const open = set.has(id); tools.style.display = open ? "" : "none"; btn.classList.toggle("on", open); }; btn.addEventListener("click", ()=>{ if(set.has(id)) set.delete(id); else set.add(id); sync(); }); sync(); return btn; } /* 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)); row.appendChild(editToggle(tools, openRows, u.id)); row.appendChild(tools); return row; } /* klasrij (naam + "Bewerken") binnen de gebruikerslijst: klasbeheer (verplaatsen naar een andere school, wachtwoorden resetten, verwijderen) staat zo letterlijk bij de klasleden eronder, i.p.v. in een los klassenpaneel. Bewust een gewone rij (geen eigen kleur/kader) - alleen de naam is iets zwaarder (zie css), zodat klas/school/gebruiker-rijen overal dezelfde visuele taal spreken en de hiërarchie via inspringing loopt i.p.v. via steeds een andere kleur per niveau. */ function classHeadRow(c, otherSchools, canManage){ const row = h("div","am-row am-classhead"); 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(!canManage) return row; const tools = h("div","am-row-tools"); if(currentUser.role==="super" && otherSchools.length){ const ss = h("select","am-sel"); ss.appendChild(new Option(T("amMoveToSchool"), "")); otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); ss.addEventListener("change", async ()=>{ if(!ss.value) return; try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); } catch(e){ msg(e.message); } }); tools.appendChild(field("amFieldMoveSchool", ss)); } const acts = h("div","am-actions"); acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ try{ const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); await reload(); resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; render(); }catch(e){ msg(e.message); } })); acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", acts)); row.appendChild(editToggle(tools, openClassTools, c.id)); row.appendChild(tools); return row; } /* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ function syncSelection(){ if(!masterHost || !detailHost) return; /* selectie valideren tegen de actuele data (na reload kan hij weg zijn) */ if(selectedUserKey && selectedUserKey.startsWith("u:") && !USERS.some(u=>"u:"+u.id===selectedUserKey)) selectedUserKey = null; if(selectedUserKey && selectedUserKey.startsWith("req:") && !LINK_REQUESTS.some(r=>"req:"+r.id===selectedUserKey)) selectedUserKey = null; masterHost.querySelectorAll(".am-tr").forEach(r=>r.classList.toggle("sel", r.dataset.selkey===selectedUserKey)); const split = masterHost.closest(".am-split"); if(split) split.classList.toggle("has-sel", !!selectedUserKey); renderDetail(); } function detailHead(title, subtitle){ const head = h("div","am-detail-head"); head.appendChild(avatar(title, true)); const txt = h("div","am-detail-title"); txt.appendChild(h("h4", null, title)); if(subtitle) txt.appendChild(h("span","am-role", subtitle)); head.appendChild(txt); const close = h("button","am-btn am-detail-close", T("close")); close.type = "button"; close.addEventListener("click", ()=>{ selectedUserKey = null; syncSelection(); }); head.appendChild(close); return head; } function renderDetail(){ if(!detailHost) return; detailHost.innerHTML = ""; if(selectedUserKey === "new"){ detailHost.appendChild(detailHead(T("amNewUser"), "")); detailHost.appendChild(newUserForm()); return; } if(selectedUserKey && selectedUserKey.startsWith("u:")){ const u = USERS.find(x=>"u:"+x.id===selectedUserKey); detailHost.appendChild(detailHead(u.displayName, T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : ""))); const badge = resultBadge(u); if(badge) detailHost.appendChild(badge); /* fouten (bv. verwijderen mislukt) tonen we hier, waar de beheerder kijkt, niet alleen bovenaan het paneel (daar valt het buiten beeld) */ const note = h("div","am-detail-msg formMsg"); detailHost.appendChild(note); detailHost.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; })); return; } if(selectedUserKey && selectedUserKey.startsWith("req:")){ const r = LINK_REQUESTS.find(x=>"req:"+x.id===selectedUserKey); detailHost.appendChild(detailHead(r.pupil.displayName, `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`)); detailHost.appendChild(requestTools(r)); return; } detailHost.appendChild(h("div","am-detail-empty", T("amDetailEmpty"))); } /* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de "+ …"-knop, pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */ function addForm(role, presetClassId){ const label = T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"); const wrap = h("div","am-add-wrap"); const toggle = h("button","tbtn ghost", label); toggle.type = "button"; wrap.appendChild(toggle); const f = h("div","am-add"); f.style.display = "none"; /* het formulier zelf herhaalt wat je aan het toevoegen bent, zodat het nooit met een bestaande-gebruikersrij te verwarren is zodra de knoptekst erboven uit beeld is (zie ook .am-add's eigen kader in css) */ f.appendChild(h("div","am-add-title", label)); const name = h("input","am-inp"); name.placeholder = T("amUserPh"); name.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", name)); let cs = null; if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(field("amFieldClass", cs)); } let pwInp = null; if(role!=="pupil"){ /* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode (die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */ const pwWrap = h("div","am-inline"); pwInp = h("input","am-inp am-pwinp"); pwInp.type = "password"; pwInp.autocomplete = "new-password"; pwInp.placeholder = T("amPwOptional"); pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); pwWrap.appendChild(pwInp); const gen = h("button","am-btn", T("amGenerate")); gen.type = "button"; gen.addEventListener("click", async ()=>{ try{ const { password } = await api("/admin/genpw"); pwInp.type = "text"; pwInp.value = password; }catch(e){ msg(e.message); } }); pwWrap.appendChild(gen); f.appendChild(field("amFieldPw", pwWrap)); } /* een systeemmanager aanmaken is de meest ingrijpende actie in dit beheer (school-onafhankelijk, alle rechten overal) - vereist daarom als extra stap het eigen wachtwoord van de aanmaker zelf, ter bevestiging. De server controleert dit ook; dit is niet louter cosmetisch. */ let confirmInp = null; if(role==="super"){ confirmInp = h("input","am-inp"); confirmInp.type = "password"; confirmInp.autocomplete = "current-password"; confirmInp.placeholder = T("amOwnPasswordPh"); confirmInp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amOwnPassword", confirmInp)); } const go = h("button","tbtn", label); go.type = "button"; go.disabled = true; const sync = ()=>{ go.disabled = name.value.trim().length < 2 || (confirmInp && !confirmInp.value); }; name.addEventListener("input", sync); if(confirmInp) confirmInp.addEventListener("input", sync); /* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */ const errEl = h("div","am-form-err"); go.addEventListener("click", async ()=>{ errEl.textContent = ""; const body = { role, username: name.value.trim(), displayName: name.value.trim() }; if(role==="pupil" && cs && cs.value) body.classId = +cs.value; if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim(); if(currentUser.role==="super" && role!=="super") body.school = +selSchool; if(confirmInp) body.confirmPassword = confirmInp.value; try{ const r = await api("/admin/users", { body }); /* groepsleiding die vanuit een klasrij wordt aangemaakt, hoort ook meteen aan die klas gekoppeld te zijn - anders staat de nieuwe leerkracht wel in "Groepsleiding" toegevoegd, maar (nog) niet echt bij die klas. Hergebruikt de al bestaande koppel-route. */ if(role==="teacher" && presetClassId){ await api("/admin/classes/"+presetClassId+"/teachers", { body:{ userId: r.user.id } }); } name.value = ""; if(pwInp) pwInp.value = ""; if(confirmInp) confirmInp.value = ""; /* 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"); } if(role==="teacher" && presetClassId) openGroups.add("classteachers:"+presetClassId); 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. */ /* Scholen als platte rijenlijst - zelfde .am-list/.am-row-taal als de klassen/gebruikers-boom eronder, i.p.v. een eigen kaartenstijl. De beheerders van een school staan al in Gebruikers (gegroepeerd bij "alle scholen"); ze hier óók als inklapbare subrijen tonen was een tweede, overbodige plek voor dezelfde informatie en droeg bij aan het rommelige gevoel van te veel verschillende niveaus. */ 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"); row.appendChild(h("span","am-name", s.name)); row.appendChild(h("span","am-role", `${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`)); 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)); row.appendChild(editToggle(tools, openSchoolTools, s.id)); row.appendChild(tools); list.appendChild(row); }); const wrap = h("div","am-add-wrap"); const toggle = h("button","tbtn ghost", T("amNewSchool")); toggle.type = "button"; wrap.appendChild(toggle); const f = h("div","am-add"); f.style.display = "none"; f.appendChild(h("div","am-add-title", T("amNewSchool"))); const inp = h("input","am-inp"); inp.placeholder = T("amSchoolNamePh"); inp.maxLength = 80; inp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", inp)); const add = h("button","tbtn", T("amNewSchool")); add.type = "button"; add.disabled = true; let armTimer = null; const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); }; inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); }); add.addEventListener("click", async ()=>{ const name = inp.value.trim(); if(name.length < 2) return; if(!armTimer){ add.classList.add("danger"); add.textContent = `${T("amNewSchoolConfirm")} “${name}”?`; armTimer = setTimeout(disarm, 5000); return; } disarm(); try{ const r = await api("/admin/schools", { body:{ name } }); selSchool = r.school.id; inp.value = ""; reload(); }catch(e){ msg(e.message); } }); f.appendChild(add); wrap.appendChild(f); toggle.addEventListener("click", ()=>{ toggle.style.display = "none"; f.style.display = "flex"; inp.focus(); }); list.appendChild(wrap); panel.appendChild(list); } /* Klassenbeheer zit niet meer in een los paneel: klasrij (classHeadRow) + klasleden staan nu samen in renderGebruikersPanel's schoolboom (zie daar), zodat klasbeheer en klasleden letterlijk bij elkaar staan. */ /* 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