/* teach - beheerpaneel (👥): gebruikers, klassen en scholen. Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */ "use strict"; (function(){ /* het beheerpaneel is een tabblad binnen Instellingen (settingsWrap/settingsModal), geen los venster meer - modal is alleen nog de content-container voor render() */ const modal = document.getElementById("adminModal"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; /* schoolbeheerders van alle scholen, voor het Systeem-tabblad (super) */ let SYS_ADMINS = []; /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ let ASSIGNMENTS = {}; /* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */ let LINK_REQUESTS = []; /* resultaat van een laatste multireset (klas/school): blijft zichtbaar totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet meteen weer verdwijnen na een reload() */ let resetResults = null; /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ let adminTab = "gebruikers"; /* live zoekfilter voor de gebruikerslijst, blijft ook behouden over her-renders */ let userFilter = ""; /* uitgeklapte leerling-groepen en gebruikersrijen (standaard alles dicht voor een rustig overzicht), blijven behouden over her-renders */ const openGroups = new Set(); const closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */ const openRows = new Set(); /* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */ const creatableRoles = () => [...new Set([currentUser.role, ...(currentUser.extraRoles||[])].flatMap(r => CREATABLE_ROLES[r] || []))]; const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ const el = document.createElement(tag); if(cls) el.className = cls; if(text != null) el.textContent = text; return el; }; /* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */ async function copyText(btn, text){ try{ await navigator.clipboard.writeText(text); } catch(e){ const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ } document.body.removeChild(ta); } const orig = btn.textContent; btn.textContent = "✓"; setTimeout(()=>{ btn.textContent = orig; }, 1200); } function copyBtn(text, cls){ const b = h("button", cls || "am-btn", T("amCopy")); b.type = "button"; b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); }); return b; } const msg = (t, copyValue) => { const m = modal.querySelector(".am-msg"); if(!m) return; m.innerHTML = ""; if(!t) return; m.appendChild(document.createTextNode(t)); if(copyValue) m.appendChild(copyBtn(copyValue)); }; /* ---- één interactiesysteem voor het hele beheer --------------------------- field(): elk invoerveld/select krijgt een zichtbaar mini-label erboven (een placeholder alleen is geen toelichting). dangerBtn(): acties met gevolgen (wachtwoord/koppelcode vernieuwen, verwijderen, resetten) zien er anders uit dan gewone knoppen en vragen een tweede klik als bevestiging (zelfde arm-then-confirm als elders in de app); wapening loopt vanzelf af. Geen native confirm()-dialogen meer. rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij de gebruiker in de rij (met kopieerknop), niet bovenaan. */ function field(labelKey, control){ const w = h("label","am-field"); w.appendChild(h("span","am-field-lbl", T(labelKey))); w.appendChild(control); return w; } function dangerBtn(label, warnKey, fn){ const b = h("button","am-btn am-danger", label); b.type = "button"; if(warnKey) b.title = T(warnKey); let timer = null; b.addEventListener("click", async ()=>{ if(!timer){ b.classList.add("armed"); b.textContent = T("amSure"); timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); return; } clearTimeout(timer); timer = null; b.classList.remove("armed"); b.textContent = label; await fn(b); }); return b; } /* per gebruiker het laatste credential-resultaat, overleeft de re-render na de actie zodat het bij de rij zichtbaar is tot het paneel sluit */ const rowResults = new Map(); document.addEventListener("userchange", ()=>rowResults.clear()); function resultBadge(u){ const r = rowResults.get(u.id); if(!r) return null; const badge = h("div","am-result"); badge.appendChild(h("span", null, r.label + ":")); const code = h("code", null, r.value); badge.appendChild(code); badge.appendChild(copyBtn(r.value)); return badge; } /* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------ Kop (grijs icoon + titel + samenvatting-als-toggle + Bewerken) met daaronder optioneel uitklapbare compacte subrijen en/of een uitklapbaar bewerkblok. De open-status leeft in een Set die re-renders overleeft; andere modules (bv. de Opslag-tab in image-catalog.js) geven hun eigen Set mee. */ function groupCard(opts){ const openSet = opts.openSet || openGroups; const editSet = opts.editSet || openRows; const card = h("div","am-card"); const head = h("div","am-card-head"); card.appendChild(head); if(opts.icon) head.appendChild(h("span","am-ico", opts.icon)); head.appendChild(h("span","am-card-title", opts.title)); const sum = h("span","am-card-sum"); head.appendChild(sum); let tools = null; if(opts.edit){ tools = h("div","am-card-tools"); tools.appendChild(opts.edit.tools); const btn = h("button","am-edit", T("amEdit")); btn.type = "button"; const ekey = opts.edit.key; const esync = ()=>{ const open = editSet.has(ekey); tools.style.display = open ? "" : "none"; btn.classList.toggle("on", open); }; btn.addEventListener("click", ()=>{ if(editSet.has(ekey)) editSet.delete(ekey); else editSet.add(ekey); esync(); }); esync(); head.appendChild(btn); card.appendChild(tools); } if(opts.subs && opts.subs.length){ const subHost = h("div","am-card-subs"); opts.subs.forEach(el=>subHost.appendChild(el)); card.appendChild(subHost); sum.classList.add("am-card-toggle"); const sync = ()=>{ const open = openSet.has(opts.key); sum.textContent = `${opts.summary} ${open ? "▾" : "▸"}`; subHost.style.display = open ? "" : "none"; }; sum.addEventListener("click", ()=>{ if(openSet.has(opts.key)) openSet.delete(opts.key); else openSet.add(opts.key); sync(); }); sync(); }else if(opts.summary){ sum.textContent = opts.summary; } return card; } window.amGroupCard = groupCard; /* ---- beheer-secties in het instellingen-register (settings.js) ------------ Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Per rol alleen de essentie: groepsleiding ziet gebruikers/toewijzingen/voortgang, schoolbeheer ook klassen, de systeemmanager ook scholen en opslag. */ const ADMIN_SECTIONS = [ { tab:"gebruikers", icon:"👥", group:"school", order:10, labelKey:"amUsers", subKey:"stUsersSub", show:()=>true, keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"), T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password", "koppelcode","aanmelding","enrolment"], search:q=>USERS .filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q)) .map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫", label:u.displayName, sub:T(roleKey(u.role)), go:()=>goSettingsSection("gebruikers", { filter:u.displayName }) })) }, { tab:"klassen", icon:"🏷", group:"school", order:11, labelKey:"amClasses", subKey:"stKlassenSub", show:()=>can("classes.manage"), keywords:()=>[T("amNewClass"), T("amResetClass"), "klas","class","groep","group"], search:q=>CLASSES .filter(c=>c.name.toLowerCase().includes(q)) .map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>goSettingsSection("klassen") })) }, { tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignments", subKey:"stToewijzSub", show:()=>can("assignments.manage"), keywords:()=>[T("amModeWatch"), T("amModeWork"), "bord","board","delen","share","toewijzen","assign","widget"] }, { tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub", show:()=>can("progress.view"), keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] }, { tab:"scholen", icon:"🏫", group:"system", order:30, labelKey:"amSchools", subKey:"stScholenSub", show:()=>currentUser.role==="super", keywords:()=>[T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"], search:q=>SCHOOLS .filter(s=>s.name.toLowerCase().includes(q)) .map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>goSettingsSection("scholen") })) }, { tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", show:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, ]; const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]); /* data één keer per bezoek aan Instellingen laden; secties onderling hergebruiken de lading (in-flight promise voorkomt dubbel laden) */ let loadPromise = null; function ensureLoaded(force){ if(force) loadPromise = null; if(!loadPromise){ loadPromise = (async ()=>{ /* super: selSchool null = nog niet gekozen (reload() zet dan de eerste school als default zodat klassen/gebruikers meteen een concrete school tonen); "" = bewust "alle scholen". Andere rollen: eigen school. */ if(currentUser.role!=="super") selSchool = currentUser.schoolId; await reload(); })(); } return loadPromise; } /* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */ window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); }; ADMIN_SECTIONS.forEach(d=>{ registerSettingsSection({ id:d.tab, icon:d.icon, group:d.group, order:d.order, label:()=>T(d.labelKey), sub:()=>T(d.subKey), visible:()=>!!currentUser && can("admin.access") && d.show(), async activate(payload){ await ensureLoaded(); if(d.tab==="gebruikers" && payload && payload.filter !== undefined) userFilter = payload.filter; showAdminHost(); switchAdminTab(d.tab); if(d.tab==="gebruikers" && payload && payload.filter !== undefined){ const panel = modal.querySelector('[data-panel="gebruikers"]'); const inp = panel && panel.querySelector(".am-filter"); if(inp){ inp.value = userFilter; applyUserFilter(panel); } } }, keywords:d.keywords, searchItems:d.search, }); }); document.addEventListener("userchange", ()=>{ if(!currentUser) resetResults = null; loadPromise = null; selectedUserKey = null; selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ }); async function reload(){ let reloadError = null; try{ if(currentUser.role==="super"){ SCHOOLS = await api("/schools"); /* eerste bezoek: kies meteen een concrete school i.p.v. het verwarrende "alle scholen" (klassen en per-school-lijsten zijn dan direct gevuld) */ if(selSchool == null) selSchool = SCHOOLS.length ? String(SCHOOLS[0].id) : ""; } const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; USERS = (await api("/admin/users"+q)).users; /* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als de contextbalk op één school staat; zonder schoolfilter is USERS al compleet en is de extra call overbodig */ if(currentUser.role==="super"){ SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin") : USERS.filter(u=>u.role==="admin"); } const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; ASSIGNMENTS = {}; if(CLASSES.length){ const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); } LINK_REQUESTS = []; if(currentUser.role!=="super" || selSchool){ const lq = currentUser.role==="super" ? "?school="+selSchool : ""; LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || []; } }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; reloadError = e; } render(); /* render() bouwt de modal (incl. .am-msg) helemaal opnieuw op, dus de melding pas ná render() zetten - anders wordt hij meteen weer gewist. Zonder dit bleef het paneel bij een fout stilletjes helemaal leeg staan zonder enige aanwijzing waarom. */ if(reloadError) msg(T("amLoadError") + ": " + reloadError.message); } function classSel(current){ const sel = h("select","am-sel"); sel.appendChild(new Option(T("amNoClass"), "")); CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id))); sel.value = current ?? ""; return sel; } /* ---- gebruikers: master-detail --------------------------------------------- Links een compacte tabel (naam | functie | klas | status), rechts een vast detailpaneel waarin de geselecteerde gebruiker bewerkt wordt. De selectie overleeft reload(); credential-acties selecteren de gebruiker zodat het eenmalige wachtwoord/de code direct in het paneel zichtbaar is. */ let selectedUserKey = null; /* "u:" | "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)); } if(isStaff && can("users.role.change") && u.id!==currentUser.id){ /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super); het aparte extra-functies-systeem is uit de UI gehaald */ const rs = h("select","am-sel"); ["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r))); rs.value = u.role; rs.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); } catch(e){ note(e.message); } }); tools.appendChild(field("amFieldRole", rs)); } /* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding, schoolbeheerder én systeemmanager): huidige klassen als chips met ✕, en een select om een klas toe te voegen */ if(isStaff && can("classes.manage") && CLASSES.length){ const wrap = h("div","am-classes"); (u.classIds||[]).forEach(cid=>{ const c = CLASSES.find(x=>Number(x.id)===Number(cid)); if(!c) return; const chip = h("span","am-chip on", c.name); const x = h("button","am-chipx","✕"); x.type = "button"; x.title = T("amClassRemove"); x.addEventListener("click", async ()=>{ try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); } catch(e){ note(e.message); } }); chip.appendChild(x); wrap.appendChild(chip); }); const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id))); if(rest.length){ const addSel = h("select","am-sel"); addSel.appendChild(new Option(T("amClassAdd"), "")); rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id))); addSel.addEventListener("change", async ()=>{ if(!addSel.value) return; try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); } catch(e){ note(e.message); } }); wrap.appendChild(addSel); } tools.appendChild(field("amFieldClasses", wrap)); } if(u.role==="pupil"){ /* klas (uitwisselen met andere klassen) */ const cs = classSel(u.classId); cs.addEventListener("change", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); reload(); } catch(e){ note(e.message); } }); tools.appendChild(field("amFieldClass", cs)); /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ const pwWrap = h("div","am-inline"); const pw = h("input","am-pw"); pw.value = u.password || ""; pw.placeholder = u.everLoggedIn ? T("amPwHidden") : T("amPwTypePh"); pw.addEventListener("keydown", ev=>ev.stopPropagation()); pw.addEventListener("change", async ()=>{ if(pw.value.length < 4) return; try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); } catch(e){ note(e.message); } }); pwWrap.appendChild(pw); const pwCopy = h("button","am-btn", T("amCopy")); pwCopy.type = "button"; pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); pwWrap.appendChild(pwCopy); tools.appendChild(field("amFieldPw", pwWrap)); if(u.everLoggedIn){ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); rowResults.set(u.id, { label: T("amResultPw"), value: password }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); } /* eenmalige ouder-code waarmee een ouder dit kind aan het eigen ouderaccount koppelt (ouderportaal); een eerdere code vervalt */ actions.appendChild(dangerBtn(T("amParentCode"), "amParentCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); }else{ if(u.pending && u.linkCode){ tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); } if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){ actions.appendChild(dangerBtn(T("amNewCode"), "amNewCodeWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); } } if(u.id !== currentUser.id){ const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ try{ await api("/admin/users/"+u.id, {method:"DELETE"}); selectedUserKey = null; reload(); } catch(e){ note(e.message); } }); actions.appendChild(del); } if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); return tools; } /* inline-variant voor het Systeem-tabblad (schoolbeheerder-subrijen): naam + rol met een "Bewerken"-tekstknop die het formulier uitklapt */ function userRow(u, opts){ const compact = !!(opts && opts.compact); const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "") + (compact ? " am-card-sub" : "")); row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); if(compact){ /* compacte kaart-subrij: status als glyph met uitleg-title i.p.v. tekst */ row.appendChild(h("span","am-role", T(roleKey(u.role)))); const glyph = h("span","am-glyph" + (u.pending ? "" : " ok"), u.pending ? "○" : "●"); glyph.title = u.pending ? T("amPending") : T("amStatusActive"); row.appendChild(glyph); }else{ row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); } const tools = h("div","am-row-tools"); const badge = resultBadge(u); if(badge) tools.appendChild(badge); tools.appendChild(userTools(u)); const edit = h("button","am-edit", T("amEdit")); edit.type = "button"; const sync = ()=>{ const open = openRows.has(u.id); tools.style.display = open ? "" : "none"; edit.classList.toggle("on", open); }; edit.addEventListener("click", ()=>{ if(openRows.has(u.id)) openRows.delete(u.id); else openRows.add(u.id); sync(); }); sync(); row.appendChild(edit); row.appendChild(tools); return row; } /* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ function syncSelection(){ if(!masterHost || !detailHost) return; /* selectie valideren tegen de actuele data (na reload kan hij weg zijn) */ if(selectedUserKey && selectedUserKey.startsWith("u:") && !USERS.some(u=>"u:"+u.id===selectedUserKey)) selectedUserKey = null; if(selectedUserKey && selectedUserKey.startsWith("req:") && !LINK_REQUESTS.some(r=>"req:"+r.id===selectedUserKey)) selectedUserKey = null; masterHost.querySelectorAll(".am-tr").forEach(r=>r.classList.toggle("sel", r.dataset.selkey===selectedUserKey)); const split = masterHost.closest(".am-split"); if(split) split.classList.toggle("has-sel", !!selectedUserKey); renderDetail(); } function detailHead(title, subtitle){ const head = h("div","am-detail-head"); head.appendChild(avatar(title, true)); const txt = h("div","am-detail-title"); txt.appendChild(h("h4", null, title)); if(subtitle) txt.appendChild(h("span","am-role", subtitle)); head.appendChild(txt); const close = h("button","am-btn am-detail-close", T("close")); close.type = "button"; close.addEventListener("click", ()=>{ selectedUserKey = null; syncSelection(); }); head.appendChild(close); return head; } function renderDetail(){ if(!detailHost) return; detailHost.innerHTML = ""; if(selectedUserKey === "new"){ detailHost.appendChild(detailHead(T("amNewUser"), "")); detailHost.appendChild(newUserForm()); return; } if(selectedUserKey && selectedUserKey.startsWith("u:")){ const u = USERS.find(x=>"u:"+x.id===selectedUserKey); detailHost.appendChild(detailHead(u.displayName, T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : ""))); const badge = resultBadge(u); if(badge) detailHost.appendChild(badge); /* fouten (bv. verwijderen mislukt) tonen we hier, waar de beheerder kijkt, niet alleen bovenaan het paneel (daar valt het buiten beeld) */ const note = h("div","am-detail-msg formMsg"); detailHost.appendChild(note); detailHost.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; })); return; } if(selectedUserKey && selectedUserKey.startsWith("req:")){ const r = LINK_REQUESTS.find(x=>"req:"+x.id===selectedUserKey); detailHost.appendChild(detailHead(r.pupil.displayName, `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`)); detailHost.appendChild(requestTools(r)); return; } detailHost.appendChild(h("div","am-detail-empty", T("amDetailEmpty"))); } /* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de "+ …"-knop, pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */ function addForm(role, presetClassId){ const label = T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"); const wrap = h("div","am-add-wrap"); const toggle = h("button","tbtn ghost", label); toggle.type = "button"; wrap.appendChild(toggle); const f = h("div","am-add"); f.style.display = "none"; const name = h("input","am-inp"); name.placeholder = T("amUserPh"); name.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", name)); let cs = null; if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(field("amFieldClass", cs)); } let pwInp = null; if(role!=="pupil"){ /* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode (die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */ const pwWrap = h("div","am-inline"); pwInp = h("input","am-inp am-pwinp"); pwInp.type = "password"; pwInp.autocomplete = "new-password"; pwInp.placeholder = T("amPwOptional"); pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); pwWrap.appendChild(pwInp); const gen = h("button","am-btn", T("amGenerate")); gen.type = "button"; gen.addEventListener("click", async ()=>{ try{ const { password } = await api("/admin/genpw"); pwInp.type = "text"; pwInp.value = password; }catch(e){ msg(e.message); } }); pwWrap.appendChild(gen); f.appendChild(field("amFieldPw", pwWrap)); } /* een systeemmanager aanmaken is de meest ingrijpende actie in dit beheer (school-onafhankelijk, alle rechten overal) - vereist daarom als extra stap het eigen wachtwoord van de aanmaker zelf, ter bevestiging. De server controleert dit ook; dit is niet louter cosmetisch. */ let confirmInp = null; if(role==="super"){ confirmInp = h("input","am-inp"); confirmInp.type = "password"; confirmInp.autocomplete = "current-password"; confirmInp.placeholder = T("amOwnPasswordPh"); confirmInp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amOwnPassword", confirmInp)); } const go = h("button","tbtn", label); go.type = "button"; go.disabled = true; const sync = ()=>{ go.disabled = name.value.trim().length < 2 || (confirmInp && !confirmInp.value); }; name.addEventListener("input", sync); if(confirmInp) confirmInp.addEventListener("input", sync); /* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */ const errEl = h("div","am-form-err"); go.addEventListener("click", async ()=>{ errEl.textContent = ""; const body = { role, username: name.value.trim(), displayName: name.value.trim() }; if(role==="pupil" && cs && cs.value) body.classId = +cs.value; if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim(); if(currentUser.role==="super" && role!=="super") body.school = +selSchool; if(confirmInp) body.confirmPassword = confirmInp.value; try{ const r = await api("/admin/users", { body }); name.value = ""; if(pwInp) pwInp.value = ""; if(confirmInp) confirmInp.value = ""; /* het wachtwoord of de koppelcode verschijnt direct bij de nieuwe gebruiker in de lijst (rij en groep klappen vanzelf open) */ rowResults.set(r.user.id, { label: r.user.password ? T("amResultPw") : T("amResultCode"), value: r.user.password || r.user.linkCode, }); openRows.add(r.user.id); if(role==="pupil"){ openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); openGroups.add("pupils"); } reload(); }catch(e){ errEl.textContent = e.message; } }); f.appendChild(go); f.appendChild(errEl); wrap.appendChild(f); toggle.addEventListener("click", ()=>{ toggle.style.display = "none"; f.style.display = "flex"; name.focus(); }); return wrap; } /* nieuw-gebruiker-formulier in het detailpaneel: eerst de rol kiezen (super aanmaken blijft op het Systeem-tabblad - die rol hoort niet bij één school), daarna de velden die bij die rol horen */ function newUserForm(){ const wrap = h("div","am-tools"); const roles = creatableRoles().filter(r=>r!=="super"); const roleSel = h("select","am-sel"); roles.forEach(r=>roleSel.appendChild(new Option(T(roleKey(r)), r))); roleSel.value = roles.includes("pupil") ? "pupil" : roles[0]; wrap.appendChild(field("amFieldRole", roleSel)); const body = h("div","am-tools"); wrap.appendChild(body); const build = ()=>{ body.innerHTML = ""; const role = roleSel.value; const name = h("input","am-inp"); name.placeholder = T("amUserPh"); name.addEventListener("keydown", ev=>ev.stopPropagation()); body.appendChild(field("amFieldName", name)); let cs = null; if(role==="pupil"){ cs = classSel(""); body.appendChild(field("amFieldClass", cs)); } let pwInp = null; if(role!=="pupil"){ const pwWrap = h("div","am-inline"); pwInp = h("input","am-inp am-pwinp"); pwInp.type = "password"; pwInp.autocomplete = "new-password"; pwInp.placeholder = T("amPwOptional"); pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); pwWrap.appendChild(pwInp); const gen = h("button","am-btn", T("amGenerate")); gen.type = "button"; gen.addEventListener("click", async ()=>{ try{ const { password } = await api("/admin/genpw"); pwInp.type = "text"; pwInp.value = password; }catch(e){ msg(e.message); } }); pwWrap.appendChild(gen); body.appendChild(field("amFieldPw", pwWrap)); } const go = h("button","am-btn am-primary", T("amNewUser")); go.type = "button"; go.disabled = true; name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); const errEl = h("div","am-form-err"); go.addEventListener("click", async ()=>{ errEl.textContent = ""; const reqBody = { role, username: name.value.trim(), displayName: name.value.trim() }; if(role==="pupil" && cs && cs.value) reqBody.classId = +cs.value; if(pwInp && pwInp.value.trim()) reqBody.password = pwInp.value.trim(); if(currentUser.role==="super") reqBody.school = +selSchool; try{ const r = await api("/admin/users", { body: reqBody }); rowResults.set(r.user.id, { label: r.user.password ? T("amResultPw") : T("amResultCode"), value: r.user.password || r.user.linkCode, }); if(role==="pupil"){ openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); openGroups.add("pupils"); } /* de nieuwe gebruiker wordt direct geselecteerd: het eenmalige wachtwoord of de koppelcode staat dan in het detailpaneel */ selectedUserKey = "u:"+r.user.id; reload(); }catch(e){ errEl.textContent = e.message; } }); body.appendChild(go); body.appendChild(errEl); setTimeout(()=>name.focus(), 0); }; roleSel.addEventListener("change", build); build(); return wrap; } /* paneel met het resultaat van een multireset (klas/school): een lijst nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */ function resetPanel(){ if(!resetResults) return null; const box = h("div","am-reset"); const head = h("div","am-reset-head"); head.appendChild(h("strong",null, resetResults.title)); const close = h("button","wclose","✕"); close.addEventListener("click", ()=>{ resetResults = null; render(); }); head.appendChild(close); box.appendChild(head); if(!resetResults.entries.length){ box.appendChild(h("div","am-msg", T("amResetNone"))); return box; } const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n"); const copyAll = h("button","am-btn", T("amCopyAll")); copyAll.type = "button"; copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); }); box.appendChild(copyAll); const list = h("div","am-reset-list"); resetResults.entries.forEach(en=>{ const row = h("div","am-reset-row"); row.appendChild(h("span","am-name", en.displayName)); row.appendChild(h("span","am-reset-pw", en.password)); row.appendChild(copyBtn(`${en.displayName}: ${en.password}`)); list.appendChild(row); }); box.appendChild(list); return box; } /* systeem: overzicht van alle scholen + nieuwe school + systeemmanagers. De schoolkiezer-contextbalk is hier verborgen (zie switchAdminTab), want niets op dit tabblad hangt van een gekozen school af. Toevoegen zit achter een bewuste extra stap (+-knop klapt het formulier uit, zoals bij gebruikers) en bevestigt daarna nog met de naam erin. */ function renderScholenPanel(panel){ SCHOOLS.forEach(s=>{ const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id)); /* bewerk-gereedschap van de school zelf (hernoemen/verwijderen) */ const tools = h("div","am-tools"); const nameInp = h("input","am-inp"); nameInp.value = s.name; nameInp.maxLength = 80; nameInp.addEventListener("keydown", ev=>ev.stopPropagation()); nameInp.addEventListener("change", async ()=>{ const name = nameInp.value.trim(); if(name.length < 2 || name === s.name) return; try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); } catch(e){ msg(e.message); } }); tools.appendChild(field("amFieldName", nameInp)); const acts = h("div","am-actions"); acts.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{ try{ await api("/admin/schools/"+s.id, {method:"DELETE"}); if(String(selSchool)===String(s.id)) selSchool = ""; reload(); }catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", acts)); /* één kader om de school en haar beheerders; beheerders standaard ingeklapt achter de teller, als compacte subrijen */ panel.appendChild(groupCard({ icon:"🏫", title:s.name, summary:`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`, key:"schooladmins:"+s.id, edit:{ key:"school:"+s.id, tools }, subs: admins.map(a=>userRow(a, { sub:true, compact:true })), })); }); const wrap = h("div","am-add-wrap"); const toggle = h("button","tbtn ghost", T("amNewSchool")); toggle.type = "button"; wrap.appendChild(toggle); const f = h("div","am-add"); f.style.display = "none"; const inp = h("input","am-inp"); inp.placeholder = T("amSchoolNamePh"); inp.maxLength = 80; inp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", inp)); const add = h("button","tbtn", T("amNewSchool")); add.type = "button"; add.disabled = true; let armTimer = null; const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); }; inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); }); add.addEventListener("click", async ()=>{ const name = inp.value.trim(); if(name.length < 2) return; if(!armTimer){ add.classList.add("danger"); add.textContent = `${T("amNewSchoolConfirm")} “${name}”?`; armTimer = setTimeout(disarm, 5000); return; } disarm(); try{ const r = await api("/admin/schools", { body:{ name } }); selSchool = r.school.id; inp.value = ""; reload(); }catch(e){ msg(e.message); } }); f.appendChild(add); wrap.appendChild(f); toggle.addEventListener("click", ()=>{ toggle.style.display = "none"; f.style.display = "flex"; inp.focus(); }); panel.appendChild(wrap); } /* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij een school; de systeemmanager kan een klas naar een andere school verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete school om klassen bij te tonen of aan te maken. */ function renderKlassenPanel(panel){ if(currentUser.role==="super" && !selSchool){ panel.appendChild(h("div","guestnote", T("amPickSchoolFirst"))); return; } const canResetPw = can("schools.resetPasswords"); const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool)); CLASSES.forEach(c=>{ const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length; let edit = null; if(canResetPw){ const tools = h("div","am-tools"); /* systeemmanager: klas naar een andere school verplaatsen */ if(currentUser.role==="super" && otherSchools.length){ const ss = h("select","am-sel"); ss.appendChild(new Option(T("amMoveToSchool"), "")); otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); ss.addEventListener("change", async ()=>{ if(!ss.value) return; try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); } catch(e){ msg(e.message); } }); tools.appendChild(field("amFieldMoveSchool", ss)); } const acts = h("div","am-actions"); acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ try{ const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); await reload(); resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; render(); }catch(e){ msg(e.message); } })); acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", acts)); edit = { key:"klas:"+c.id, tools }; } panel.appendChild(groupCard({ title:c.name, summary:`${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`, edit, })); }); /* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat bestaande klassen bij een andere school kunnen horen */ if(!CLASSES.length){ panel.appendChild(h("div","guestnote", T("amNoClassesHere"))); } if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); inp.placeholder = T("amClassNamePh"); inp.maxLength = 80; inp.addEventListener("keydown", ev=>ev.stopPropagation()); f.appendChild(field("amFieldName", inp)); const go = h("button","tbtn", T("amNewClass")); go.type = "button"; go.disabled = true; inp.addEventListener("input", ()=>{ go.disabled = inp.value.trim().length < 2; }); go.addEventListener("click", async ()=>{ if(inp.value.trim().length < 2) return; try{ await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } }); reload(); }catch(e){ msg(e.message); } }); f.appendChild(go); panel.appendChild(f); /* multireset: alle leerlingwachtwoorden van de hele school in één keer */ panel.appendChild(dangerBtn(T("amResetSchool"), "amConfirmResetSchool", async ()=>{ const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId; if(!schoolId) return; try{ const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"}); await reload(); resetResults = { title: T("amResetSchool"), entries: r.users }; render(); }catch(e){ msg(e.message); } })); } } /* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw toe te wijzen. Een expliciete "Toewijzen"-knop i.p.v. direct bij het kiezen van een bord opslaan, zodat een klas-brede wijziging niet per ongeluk gebeurt. */ /* widgets van een eigen bord (voor de één-widget-keuze in assignmentRow); widgets zonder wid komen uit borden van vóór v0.3.13 en verschijnen pas na de eerstvolgende save van dat bord */ function boardWidgets(boardId){ let found = null; BS.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(bo.id===boardId) found = bo; })); return ((found && found.data && found.data.widgets) || []).filter(w=>w.wid); } function describeCurrent(current){ if(!current) return ""; let s = `${T("amAssignedAs")} “${current.boardName || current.boardId}”`; if(current.widgetId){ const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId; s += ` › ${wName} (${current.mode==="kijken" ? T("amModeWatch") : T("amModeWork")})`; } return s; } function assignmentRow(target, boardOptions, current){ const sel = h("select","am-sel"); sel.appendChild(new Option(T("amNoAssignment"), "")); boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id))); sel.value = current ? current.boardId : ""; /* keuze: heel bord (default, bestaand gedrag) of één widget + modus */ const widgetSel = h("select","am-sel am-widget-sel"); const modeSel = h("select","am-sel am-mode-sel"); modeSel.appendChild(new Option(T("amModeWork"), "werken")); modeSel.appendChild(new Option(T("amModeWatch"), "kijken")); const syncWidgetSel = ()=>{ widgetSel.innerHTML = ""; widgetSel.appendChild(new Option(T("amWholeBoard"), "")); boardWidgets(sel.value).forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid))); widgetSel.value = (current && current.boardId===sel.value && current.widgetId) ? current.widgetId : ""; widgetSel.style.display = sel.value ? "" : "none"; syncModeSel(); }; const syncModeSel = ()=>{ modeSel.style.display = (sel.value && widgetSel.value) ? "" : "none"; }; modeSel.value = current && current.mode ? current.mode : "werken"; sel.addEventListener("change", syncWidgetSel); widgetSel.addEventListener("change", syncModeSel); const info = h("span","am-code", describeCurrent(current)); syncWidgetSel(); const apply = h("button","am-btn", T("amAssign")); apply.type = "button"; apply.addEventListener("click", async ()=>{ try{ if(sel.value){ const body = { boardId: sel.value }; if(widgetSel.value){ body.widgetId = widgetSel.value; body.mode = modeSel.value; } await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body}); } else await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); if(target.type==="class"){ await reload(); } else{ const a = (await api("/assignments/pupil/"+target.id)).assignment; info.textContent = describeCurrent(a); msg(T("amAssignSaved")); } }catch(e){ msg(e.message); } }); /* kop toont compact de huidige stand; de keuzes klappen uit via Bewerken */ const tools = h("div","am-tools"); const inline = h("div","am-inline"); inline.appendChild(sel); inline.appendChild(widgetSel); inline.appendChild(modeSel); inline.appendChild(apply); tools.appendChild(inline); tools.appendChild(info); return groupCard({ title: target.label, summary: describeCurrent(current) || T("amNoAssignment"), edit: { key: "assign:"+target.type+":"+target.id, tools }, }); } /* vult een