All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- Een schoolbeheerder (of systeemmanager) die aan een klas gekoppeld werd, kwam daar niet zichtbaar te staan: de klas-detailweergave en de staf-telling op de klastegel filterden op role==="teacher", terwijl de server al sinds v0.3.38 elke stafrol (teacher/admin/super) toestaat in class_teachers. Puur een weergavebug op de client - de koppeling zelf werkte al goed. Nieuwe isClassStaff()-helper matcht nu exact de serverkant se toegestane rollenset. - De "+" in de A+-zoomknop (toolbar én het per-widget contentzoom-knopje) gebruikte de CJK-volle-breedte "+" (U+FF0B) naast de gewone letter "A" - dat teken valt vermoedelijk terug op een ander lettertype dan Quicksand, waardoor de relatieve positie t.o.v. de "A" verspringt zodra document.body.style.zoom verandert. Vervangen door de gewone ASCII "+" (net als de A−-knop al een normaal minteken gebruikt, niet de volle- breedte variant).
1341 lines
60 KiB
JavaScript
1341 lines
60 KiB
JavaScript
/* teach - beheerpaneel (👥): scholen, klassen en gebruikers als één drill-down
|
||
navigator (tegel-overzicht → gefocust detail, i.p.v. alles tegelijk
|
||
uitgeklapt op één lange pagina). 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 de schooltegels op "alle scholen" */
|
||
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";
|
||
|
||
/* navigatie binnen "School": welk niveau van de boom je ziet.
|
||
{type:"overview"} - tegels (scholen op "alle scholen", anders
|
||
klassen+platte groepen van de gekozen school)
|
||
{type:"class", id} - gefocust detail van één klas
|
||
{type:"flat", kind} - gefocust detail van één platte groep
|
||
(admins/supers/parents/noschool/unassigned/requests)
|
||
Blijft behouden over her-renders, reset bij het wisselen van school en bij
|
||
in-/uitloggen - anders belandt een systeemmanager na het kiezen van een
|
||
andere school midden in de klas van de vórige school. */
|
||
let navLevel = { type:"overview" };
|
||
/* welke sheet (persoon of aanmelding) open staat; blijft ook over reload()
|
||
heen zodat een actie ín de sheet (nieuw wachtwoord, klas koppelen) de
|
||
sheet niet laat verdwijnen maar met verse data laat herladen - zelfde
|
||
reden als waarom rowResults/resetResults hieronder blijven staan. */
|
||
let openSheet = null;
|
||
/* uitgeklapt "⋯"-actiepaneel (klas- of schoolbeheer) op het huidige niveau;
|
||
hoort bij navLevel, dus reset zodra dat wijzigt */
|
||
let kebabOpen = false;
|
||
/* systeembrede zoekopdracht: vult, op het tegel-niveau, het scherm met een
|
||
platte resultatenlijst i.p.v. tegels */
|
||
let searchQuery = "";
|
||
|
||
/* 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] || []))];
|
||
|
||
/* wie er als groepsleiding aan een klas gekoppeld kan staan - zelfde rollenset
|
||
als de server accepteert in POST /admin/classes/:id/teachers (niet alleen
|
||
"teacher": een schoolbeheerder of systeemmanager mag ook aan een klas
|
||
gekoppeld worden, en moet dan ook als zodanig getoond worden). */
|
||
const isClassStaff = u => ["teacher","admin","super"].includes(u.role);
|
||
|
||
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<");
|
||
const h = (tag, cls, text)=>{
|
||
const el = document.createElement(tag);
|
||
if(cls) el.className = cls;
|
||
if(text != null) el.textContent = text;
|
||
return el;
|
||
};
|
||
/* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop
|
||
zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */
|
||
async function copyText(btn, text){
|
||
try{ await navigator.clipboard.writeText(text); }
|
||
catch(e){
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
||
document.body.appendChild(ta); ta.select();
|
||
try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ }
|
||
document.body.removeChild(ta);
|
||
}
|
||
const orig = btn.textContent;
|
||
btn.textContent = "✓";
|
||
setTimeout(()=>{ btn.textContent = orig; }, 1200);
|
||
}
|
||
function copyBtn(text, cls){
|
||
const b = h("button", cls || "am-btn", T("amCopy"));
|
||
b.type = "button";
|
||
b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); });
|
||
return b;
|
||
}
|
||
const msg = (t, copyValue) => {
|
||
const m = modal.querySelector(".am-msg");
|
||
if(!m) return;
|
||
m.innerHTML = "";
|
||
if(!t) return;
|
||
m.appendChild(document.createTextNode(t));
|
||
if(copyValue) m.appendChild(copyBtn(copyValue));
|
||
};
|
||
|
||
/* ---- één interactiesysteem voor het hele beheer ---------------------------
|
||
field(): elk invoerveld/select krijgt een zichtbaar mini-label erboven
|
||
(een placeholder alleen is geen toelichting).
|
||
dangerBtn(): acties met gevolgen (wachtwoord/koppelcode vernieuwen,
|
||
verwijderen, resetten) zien er anders uit dan gewone knoppen
|
||
en vragen een tweede klik als bevestiging (zelfde
|
||
arm-then-confirm als elders in de app); wapening loopt
|
||
vanzelf af. Geen native confirm()-dialogen meer.
|
||
rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij
|
||
de gebruiker in de sheet (met kopieerknop). */
|
||
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 ------------------------
|
||
Nog gebruikt door assignmentRow (Toewijzingen) en de Opslag-tab
|
||
(image-catalog.js) - niet meer door dit bestand zelf. */
|
||
function groupCard(opts){
|
||
const openSet = opts.openSet || new Set();
|
||
const editSet = opts.editSet || new Set();
|
||
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") die intern een eigen
|
||
drill-down navigator opbouwt (zie renderSchoolNavigator). */
|
||
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", { openPerson:u.id }) })),
|
||
...CLASSES
|
||
.filter(c=>c.name.toLowerCase().includes(q))
|
||
.map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"),
|
||
go:()=>{ navLevel={type:"class", id:c.id}; kebabOpen=false; goSettingsSection("school"); } })),
|
||
...SCHOOLS
|
||
.filter(s=>s.name.toLowerCase().includes(q))
|
||
.map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"),
|
||
go:()=>{ selSchool=String(s.id); navLevel={type:"overview"}; 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 "alle
|
||
scholen" als default). 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.openPerson !== undefined) openSheet = { type:"person", id:payload.openPerson };
|
||
showAdminHost();
|
||
switchAdminTab(d.tab);
|
||
if(d.tab==="school") render();
|
||
},
|
||
keywords:d.keywords,
|
||
searchItems:d.search,
|
||
});
|
||
});
|
||
document.addEventListener("userchange", ()=>{
|
||
if(!currentUser) resetResults = null;
|
||
loadPromise = null;
|
||
selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */
|
||
navLevel = { type:"overview" };
|
||
openSheet = null;
|
||
kebabOpen = false;
|
||
searchQuery = "";
|
||
});
|
||
|
||
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;
|
||
/* schooltegels op "alle scholen" tonen schoolbeheerders van élke school;
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
/* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op
|
||
eerste login/activatie. */
|
||
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;
|
||
}
|
||
|
||
/* het bewerkformulier van één gebruiker; wordt in de sheet getoond zodra
|
||
iemand op een personRow tikt. */
|
||
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 });
|
||
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 });
|
||
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 });
|
||
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 });
|
||
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"}); openSheet = null; reload(); }
|
||
catch(e){ note(e.message); }
|
||
});
|
||
actions.appendChild(del);
|
||
}
|
||
if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions));
|
||
return tools;
|
||
}
|
||
|
||
/* 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 = "";
|
||
rowResults.set(r.user.id, {
|
||
label: r.user.password ? T("amResultPw") : T("amResultCode"),
|
||
value: r.user.password || r.user.linkCode,
|
||
});
|
||
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;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* toewijzingsrij: overzicht van de huidige toewijzing (aangemaakt via de
|
||
Cast-knop op het whiteboard, cast.js) + loskoppelen. Aanmaken/wijzigen
|
||
gebeurt niet meer hier - zie cast.js. */
|
||
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, current){
|
||
const info = h("span","am-code", describeCurrent(current) || T("amNoAssignment"));
|
||
const tools = h("div","am-tools");
|
||
if(current){
|
||
const del = dangerBtn(T("amUnlink"), null, async ()=>{
|
||
try{
|
||
await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"});
|
||
if(target.type==="class"){ await reload(); }
|
||
else{
|
||
info.textContent = T("amNoAssignment");
|
||
del.remove();
|
||
msg(T("amAssignSaved"));
|
||
}
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
tools.appendChild(del);
|
||
}
|
||
tools.appendChild(info);
|
||
return groupCard({
|
||
title: target.label,
|
||
summary: describeCurrent(current) || T("amNoAssignment"),
|
||
edit: { key: "assign:"+target.type+":"+target.id, tools },
|
||
});
|
||
}
|
||
|
||
/* vult een <select> met leerlingen gegroepeerd per klas (optgroup), gedeeld
|
||
tussen de Toewijzingen- en Voortgang-tab die beide één leerling kiezen */
|
||
function fillPupilSelect(sel, pupils){
|
||
sel.appendChild(new Option(T("amPickPupil"), ""));
|
||
const byClass = new Map();
|
||
pupils.forEach(u=>{
|
||
const key = u.classId || "none";
|
||
if(!byClass.has(key)) byClass.set(key, []);
|
||
byClass.get(key).push(u);
|
||
});
|
||
const addGroup = (label, us)=>{
|
||
if(!us || !us.length) return;
|
||
const og = document.createElement("optgroup");
|
||
og.label = label;
|
||
us.forEach(u=>og.appendChild(new Option(u.displayName, u.id)));
|
||
sel.appendChild(og);
|
||
};
|
||
CLASSES.forEach(c=>addGroup(c.name, byClass.get(c.id)));
|
||
addGroup(T("amNoClass"), byClass.get("none"));
|
||
}
|
||
|
||
/* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele
|
||
leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */
|
||
function renderToewijzingenPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
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}, 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}, current));
|
||
});
|
||
}
|
||
|
||
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
|
||
function progressRow(ev){
|
||
const row = h("div","am-row");
|
||
row.appendChild(h("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType));
|
||
const scoreParts = [];
|
||
if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`);
|
||
if(ev.stars) scoreParts.push("★".repeat(ev.stars));
|
||
row.appendChild(h("span","am-code", scoreParts.join(" · ")));
|
||
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
|
||
return row;
|
||
}
|
||
|
||
/* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als
|
||
Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde
|
||
widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */
|
||
function renderVoortgangPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
const pupilSel = h("select","am-sel");
|
||
fillPupilSelect(pupilSel, pupils);
|
||
panel.appendChild(pupilSel);
|
||
const eventsHolder = h("div","am-list");
|
||
panel.appendChild(eventsHolder);
|
||
const moreBtn = h("button","am-btn", T("amShowMore"));
|
||
moreBtn.type = "button";
|
||
moreBtn.style.display = "none";
|
||
panel.appendChild(moreBtn);
|
||
pupilSel.addEventListener("change", async ()=>{
|
||
eventsHolder.innerHTML = "";
|
||
moreBtn.style.display = "none";
|
||
if(!pupilSel.value) return;
|
||
let events = [];
|
||
try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
|
||
catch(e){ msg(e.message); return; }
|
||
if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
|
||
/* in porties van 30: de tijdlijn kan 200 regels lang zijn */
|
||
let shown = 0;
|
||
const showMore = ()=>{
|
||
events.slice(shown, shown+30).forEach(ev=>eventsHolder.appendChild(progressRow(ev)));
|
||
shown = Math.min(shown+30, events.length);
|
||
moreBtn.style.display = shown < events.length ? "" : "none";
|
||
moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`;
|
||
};
|
||
moreBtn.onclick = showMore;
|
||
showMore();
|
||
});
|
||
}
|
||
|
||
/* =========================================================================
|
||
"School" - drill-down navigator: scholen/klassen/gebruikers in één geheel.
|
||
Ieder niveau toont hetzelfde patroon: tegel-overzicht met kerngetallen,
|
||
tikken toont een gefocust detailscherm met een terugknop. Personen en
|
||
aanmeldingen openen in een sheet i.p.v. een los detailpaneel.
|
||
========================================================================= */
|
||
|
||
/* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */
|
||
function personRow(u){
|
||
const row = h("div","am-row am-person");
|
||
row.appendChild(avatar(u.displayName));
|
||
const info = h("div","am-person-info");
|
||
info.appendChild(h("span","am-name", u.displayName));
|
||
info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
|
||
row.appendChild(info);
|
||
const badge = resultBadge(u);
|
||
if(badge) row.appendChild(badge);
|
||
row.appendChild(userStatusGlyph(u));
|
||
row.addEventListener("click", ()=>{ openSheet = { type:"person", id:u.id }; render(); });
|
||
return row;
|
||
}
|
||
/* rij voor één aanmeldverzoek (thuiskind -> deze school) */
|
||
function requestRow(r){
|
||
const row = h("div","am-row am-person");
|
||
row.appendChild(avatar(r.pupil.displayName));
|
||
const info = h("div","am-person-info");
|
||
info.appendChild(h("span","am-name", r.pupil.displayName));
|
||
info.appendChild(h("span","am-role", `${T("amRequestVia")} ${r.parent.displayName}`));
|
||
row.appendChild(info);
|
||
row.appendChild(h("span","am-chip on", T("amReqNew")));
|
||
row.addEventListener("click", ()=>{ openSheet = { type:"request", id:r.id }; render(); });
|
||
return row;
|
||
}
|
||
function peopleList(people){
|
||
const list = h("div","am-list am-people");
|
||
people.forEach(u=>list.appendChild(personRow(u)));
|
||
return list;
|
||
}
|
||
|
||
/* aanmeldverzoek afhandelen: adopteren (thuisaccount wordt schoolleerling,
|
||
evt. met klas en - bij naamconflict - een andere gebruikersnaam),
|
||
koppelen aan een bestaande leerling, of afwijzen. In de sheet getoond. */
|
||
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 }); openSheet = 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 } }); openSheet = 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" } }); openSheet = null; reload(); }
|
||
catch(e){ msg(e.message); }
|
||
}));
|
||
tools.appendChild(field("amFieldActions", actions));
|
||
return tools;
|
||
}
|
||
|
||
/* tegel: één cel in het overzicht (school/klas/platte groep), of de "+"
|
||
variant die een toevoegformulier opent (zie newSchoolTile/newClassTile). */
|
||
function tile(opts){
|
||
const cls = "am-tile" + (opts.wide?" wide":"") + (opts.warn?" warn":"") + (opts.add?" add":"");
|
||
const btn = h("button", cls);
|
||
btn.type = "button";
|
||
if(opts.add){
|
||
btn.textContent = opts.label;
|
||
}else{
|
||
btn.appendChild(h("div","am-tile-name", opts.label));
|
||
if(opts.stats && opts.stats.length){
|
||
const chips = h("div","am-tile-chips");
|
||
opts.stats.forEach(s=>chips.appendChild(h("span","am-chip" + (s.flag?" flag":""), s.text)));
|
||
btn.appendChild(chips);
|
||
}
|
||
}
|
||
if(opts.onClick) btn.addEventListener("click", opts.onClick);
|
||
return btn;
|
||
}
|
||
|
||
/* "+ School" als tegel: toevoegen zit achter een bewuste extra stap (tik
|
||
opent het veld) en bevestigt daarna nog met de naam erin. */
|
||
function newSchoolTile(){
|
||
const wrap = h("div","am-add-wrap");
|
||
const toggle = tile({ label:T("amNewSchool"), add:true });
|
||
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 = String(r.school.id); inp.value = ""; navLevel = { type:"overview" };
|
||
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();
|
||
});
|
||
return wrap;
|
||
}
|
||
/* "+ Klas" als tegel, zelfde opzet als newSchoolTile hierboven */
|
||
function newClassTile(){
|
||
const wrap = h("div","am-add-wrap");
|
||
const toggle = tile({ label:T("amNewClass"), add:true });
|
||
wrap.appendChild(toggle);
|
||
const f = h("div","am-add");
|
||
f.style.display = "none";
|
||
f.appendChild(h("div","am-add-title", T("amNewClass")));
|
||
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);
|
||
wrap.appendChild(f);
|
||
toggle.addEventListener("click", ()=>{
|
||
toggle.style.display = "none";
|
||
f.style.display = "flex";
|
||
inp.focus();
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
/* "⋯"-actiepaneel van de gekozen school: naam wijzigen, verwijderen.
|
||
Alleen systeemmanager - schoolbeheerder/leerkracht zien dit nooit. */
|
||
function schoolActionsPanel(school){
|
||
const box = h("div","am-kebab-panel");
|
||
const nameInp = h("input","am-inp");
|
||
nameInp.value = school.name;
|
||
nameInp.maxLength = 80;
|
||
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
nameInp.addEventListener("change", async ()=>{
|
||
const name = nameInp.value.trim();
|
||
if(name.length < 2 || name === school.name) return;
|
||
try{ await api("/admin/schools/"+school.id, {method:"PATCH", body:{ name }}); reload(); }
|
||
catch(e){ msg(e.message); }
|
||
});
|
||
box.appendChild(field("amFieldName", nameInp));
|
||
box.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{
|
||
try{
|
||
await api("/admin/schools/"+school.id, {method:"DELETE"});
|
||
selSchool = ""; navLevel = { type:"overview" }; kebabOpen = false;
|
||
reload();
|
||
}catch(e){ msg(e.message); }
|
||
}));
|
||
return box;
|
||
}
|
||
/* "⋯"-actiepaneel van de gekozen klas: verplaatsen naar andere school
|
||
(alleen systeemmanager), wachtwoorden resetten, verwijderen. */
|
||
function classActionsPanel(c, otherSchools){
|
||
const box = h("div","am-kebab-panel");
|
||
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); }
|
||
});
|
||
box.appendChild(field("amFieldMoveSchool", ss));
|
||
}
|
||
box.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); }
|
||
}));
|
||
box.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{
|
||
try{
|
||
await api("/admin/classes/"+c.id, {method:"DELETE"});
|
||
navLevel = { type:"overview" }; kebabOpen = false;
|
||
reload();
|
||
}catch(e){ msg(e.message); }
|
||
}));
|
||
return box;
|
||
}
|
||
|
||
/* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school +
|
||
Systeemmanagers/Ouders/Zonder school + "+ School". */
|
||
function renderRootOverview(panel){
|
||
const stats = h("div","am-stat-strip");
|
||
[[SCHOOLS.length, T("amSchools")],
|
||
[USERS.filter(u=>["admin","teacher","super"].includes(u.role)).length, T("amStaff")],
|
||
[USERS.filter(u=>u.role==="pupil").length, T("amPupils")]].forEach(([n,label])=>{
|
||
const s = h("div","am-stat");
|
||
s.appendChild(h("b",null,String(n)));
|
||
s.appendChild(h("span",null,label));
|
||
stats.appendChild(s);
|
||
});
|
||
panel.appendChild(stats);
|
||
|
||
const grid = h("div","am-tilegrid");
|
||
SCHOOLS.forEach(sc=>{
|
||
const staff = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && ["admin","teacher"].includes(u.role)).length;
|
||
const pupils = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && u.role==="pupil").length;
|
||
grid.appendChild(tile({
|
||
label: sc.name,
|
||
stats: [{text:`🧑🏫 ${staff}`}, {text:`🧒 ${pupils}`}],
|
||
onClick: ()=>{ selSchool = String(sc.id); navLevel = { type:"overview" }; kebabOpen = false; reload(); },
|
||
}));
|
||
});
|
||
const supers = USERS.filter(u=>u.role==="super");
|
||
grid.appendChild(tile({ label:T("amSupers"), wide:true, stats:[{text:String(supers.length)}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"supers" }; render(); } }));
|
||
const parents = USERS.filter(u=>u.role==="parent");
|
||
if(parents.length) grid.appendChild(tile({ label:T("amParents"), wide:true, stats:[{text:String(parents.length)}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"parents" }; render(); } }));
|
||
const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null);
|
||
if(loose.length) grid.appendChild(tile({ label:T("amNoSchoolGroup"), wide:true, warn:true,
|
||
stats:[{text:String(loose.length), flag:true}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"noschool" }; render(); } }));
|
||
if(can("schools.manage")) grid.appendChild(newSchoolTile());
|
||
panel.appendChild(grid);
|
||
}
|
||
|
||
/* niveau 1: één school - tegel per klas + Schoolbeheerder/Niet ingedeeld/
|
||
Ouders/Aanmeldingen + "+ Klas". "⋯" toont schoolbeheer (alleen super). */
|
||
function renderSchoolOverview(panel){
|
||
const school = SCHOOLS.find(s=>String(s.id)===String(selSchool));
|
||
const canManageClasses = can("classes.manage");
|
||
|
||
if(currentUser.role==="super" && school){
|
||
const titleRow = h("div","am-kebab-row");
|
||
titleRow.appendChild(h("h2","am-nav-title", school.name));
|
||
const kebab = h("button","am-kebab","⋯");
|
||
kebab.type = "button";
|
||
kebab.addEventListener("click", ()=>{ kebabOpen = !kebabOpen; render(); });
|
||
titleRow.appendChild(kebab);
|
||
panel.appendChild(titleRow);
|
||
if(kebabOpen) panel.appendChild(schoolActionsPanel(school));
|
||
}
|
||
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
const stats = h("div","am-stat-strip");
|
||
[[CLASSES.length, T("amClasses")],
|
||
[USERS.filter(u=>["admin","teacher"].includes(u.role)).length, T("amStaff")],
|
||
[pupils.length, T("amPupils")]].forEach(([n,label])=>{
|
||
const s = h("div","am-stat");
|
||
s.appendChild(h("b",null,String(n)));
|
||
s.appendChild(h("span",null,label));
|
||
stats.appendChild(s);
|
||
});
|
||
panel.appendChild(stats);
|
||
|
||
const grid = h("div","am-tilegrid");
|
||
if(LINK_REQUESTS.length){
|
||
grid.appendChild(tile({ label:T("amLinkRequests"), wide:true, warn:true,
|
||
stats:[{text:String(LINK_REQUESTS.length), flag:true}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"requests" }; render(); } }));
|
||
}
|
||
CLASSES.forEach(c=>{
|
||
const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id))).length;
|
||
const cp = pupils.filter(u=>u.classId===c.id).length;
|
||
grid.appendChild(tile({ label:c.name, stats:[{text:`🧑🏫 ${staff}`},{text:`🧒 ${cp}`}],
|
||
onClick: ()=>{ navLevel = { type:"class", id:c.id }; kebabOpen = false; render(); } }));
|
||
});
|
||
const admins = USERS.filter(u=>u.role==="admin");
|
||
grid.appendChild(tile({ label:T("roleAdmin"), wide:true, stats:[{text:String(admins.length)}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"admins" }; render(); } }));
|
||
const unassigned = [
|
||
...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length),
|
||
...pupils.filter(u=>!u.classId),
|
||
];
|
||
grid.appendChild(tile({ label:T("amUnassigned"), wide:true, warn:unassigned.length>0,
|
||
stats:[{text:String(unassigned.length), flag:unassigned.length>0}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"unassigned" }; render(); } }));
|
||
const parents = USERS.filter(u=>u.role==="parent");
|
||
if(parents.length) grid.appendChild(tile({ label:T("amParents"), wide:true, stats:[{text:String(parents.length)}],
|
||
onClick: ()=>{ navLevel = { type:"flat", kind:"parents" }; render(); } }));
|
||
if(canManageClasses) grid.appendChild(newClassTile());
|
||
panel.appendChild(grid);
|
||
}
|
||
|
||
/* niveau 2a: één klas - Groepsleiding en Leerlingen als twee sublijstjes,
|
||
elk met een eigen "+"; "⋯" toont klasbeheer. */
|
||
function renderClassDetail(panel, classId){
|
||
const c = CLASSES.find(x=>Number(x.id)===Number(classId));
|
||
if(!c){ navLevel = { type:"overview" }; renderSchoolOverview(panel); return; }
|
||
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
|
||
const canManageClasses = can("classes.manage");
|
||
|
||
const titleRow = h("div","am-kebab-row");
|
||
titleRow.appendChild(h("h2","am-nav-title", c.name));
|
||
if(canManageClasses){
|
||
const kebab = h("button","am-kebab","⋯");
|
||
kebab.type = "button";
|
||
kebab.addEventListener("click", ()=>{ kebabOpen = !kebabOpen; render(); });
|
||
titleRow.appendChild(kebab);
|
||
}
|
||
panel.appendChild(titleRow);
|
||
if(kebabOpen && canManageClasses) panel.appendChild(classActionsPanel(c, otherSchools));
|
||
|
||
const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id)));
|
||
panel.appendChild(h("p","am-section-label", T("roleTeacher")));
|
||
panel.appendChild(peopleList(staff));
|
||
if(creatableRoles().includes("teacher")) panel.appendChild(addForm("teacher", c.id));
|
||
|
||
const pupils = USERS.filter(u=>u.role==="pupil" && u.classId===c.id);
|
||
panel.appendChild(h("p","am-section-label", T("amPupils")));
|
||
panel.appendChild(peopleList(pupils));
|
||
if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil", c.id));
|
||
}
|
||
|
||
/* niveau 2b: één platte groep (Schoolbeheerder/Systeemmanagers/Ouders/
|
||
Zonder school/Niet ingedeeld/Aanmeldingen) - één lijst, evt. met "+". */
|
||
function renderFlatDetail(panel, kind){
|
||
if(kind==="requests"){
|
||
const titleRow = h("div","am-kebab-row");
|
||
titleRow.appendChild(h("h2","am-nav-title", T("amLinkRequests")));
|
||
panel.appendChild(titleRow);
|
||
const list = h("div","am-list am-people");
|
||
LINK_REQUESTS.forEach(r=>list.appendChild(requestRow(r)));
|
||
panel.appendChild(list);
|
||
return;
|
||
}
|
||
const pupils = USERS.filter(u=>u.role==="pupil");
|
||
let title, people, addRole, presetClassId = null;
|
||
if(kind==="admins"){ title = T("roleAdmin"); people = USERS.filter(u=>u.role==="admin"); addRole = creatableRoles().includes("admin") ? "admin" : null; }
|
||
else if(kind==="supers"){ title = T("amSupers"); people = USERS.filter(u=>u.role==="super"); addRole = creatableRoles().includes("super") ? "super" : null; }
|
||
else if(kind==="parents"){ title = T("amParents"); people = USERS.filter(u=>u.role==="parent"); addRole = null; }
|
||
else if(kind==="noschool"){ title = T("amNoSchoolGroup"); people = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); addRole = null; }
|
||
else{ /* unassigned */
|
||
title = T("amUnassigned");
|
||
people = [
|
||
...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length),
|
||
...pupils.filter(u=>!u.classId),
|
||
];
|
||
addRole = creatableRoles().includes("pupil") ? "pupil" : null;
|
||
}
|
||
const titleRow = h("div","am-kebab-row");
|
||
titleRow.appendChild(h("h2","am-nav-title", title));
|
||
panel.appendChild(titleRow);
|
||
panel.appendChild(peopleList(people));
|
||
if(addRole) panel.appendChild(addForm(addRole, presetClassId));
|
||
}
|
||
|
||
/* zoekresultaten: systeembreed op naam, vervangt de tegels zolang er getypt is */
|
||
function renderSearchResults(panel, q){
|
||
const matches = USERS.filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q));
|
||
if(!matches.length){ panel.appendChild(h("div","guestnote", T("amNoResults"))); return; }
|
||
panel.appendChild(peopleList(matches));
|
||
}
|
||
|
||
/* de sheet: personen- of aanmeldingsdetail, altijd bovenop het huidige
|
||
niveau. validateSheet() controleert eerst of de selectie nog bestaat
|
||
(na reload() kan iemand weg zijn, bv. net verwijderd). */
|
||
function validateSheet(){
|
||
if(openSheet && openSheet.type==="person" && !USERS.some(u=>u.id===openSheet.id)) openSheet = null;
|
||
if(openSheet && openSheet.type==="request" && !LINK_REQUESTS.some(r=>r.id===openSheet.id)) openSheet = null;
|
||
}
|
||
function renderSheetInto(panel){
|
||
const backdrop = h("div","am-sheet-backdrop" + (openSheet ? " open" : ""));
|
||
backdrop.addEventListener("click", (e)=>{ if(e.target===backdrop){ openSheet = null; render(); } });
|
||
if(openSheet){
|
||
const sheet = h("div","am-sheet");
|
||
const closeBtn = h("button","am-btn am-detail-close", T("close"));
|
||
closeBtn.type = "button";
|
||
closeBtn.addEventListener("click", ()=>{ openSheet = null; render(); });
|
||
if(openSheet.type==="person"){
|
||
const u = USERS.find(x=>x.id===openSheet.id);
|
||
const head = h("div","am-detail-head");
|
||
head.appendChild(avatar(u.displayName, true));
|
||
const txt = h("div","am-detail-title");
|
||
txt.appendChild(h("h4", null, u.displayName));
|
||
txt.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : "")));
|
||
head.appendChild(txt);
|
||
head.appendChild(closeBtn);
|
||
sheet.appendChild(head);
|
||
const badge = resultBadge(u);
|
||
if(badge) sheet.appendChild(badge);
|
||
const note = h("div","am-detail-msg formMsg");
|
||
sheet.appendChild(note);
|
||
sheet.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; }));
|
||
}else if(openSheet.type==="request"){
|
||
const r = LINK_REQUESTS.find(x=>x.id===openSheet.id);
|
||
const head = h("div","am-detail-head");
|
||
head.appendChild(avatar(r.pupil.displayName, true));
|
||
const txt = h("div","am-detail-title");
|
||
txt.appendChild(h("h4", null, r.pupil.displayName));
|
||
txt.appendChild(h("span","am-role", `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`));
|
||
head.appendChild(txt);
|
||
head.appendChild(closeBtn);
|
||
sheet.appendChild(head);
|
||
sheet.appendChild(requestTools(r));
|
||
}
|
||
backdrop.appendChild(sheet);
|
||
}
|
||
panel.appendChild(backdrop);
|
||
}
|
||
|
||
/* het "School"-tabblad zelf: bepaalt o.b.v. selSchool/navLevel welk niveau
|
||
te tonen. Een leerkracht (groepsleiding) heeft geen klassenbeheer en
|
||
krijgt de eenvoudiger platte leerlingenlijst, ongewijzigd t.o.v. eerder -
|
||
server-side toch al beperkt tot de eigen klas(sen), dus geen boom nodig. */
|
||
function renderSchoolNavigator(panel){
|
||
validateSheet();
|
||
panel.classList.add("am-nav-panel");
|
||
|
||
if(currentUser.role==="teacher"){
|
||
const header = h("div","am-nav-header");
|
||
const searchInp = h("input","am-inp am-filter");
|
||
searchInp.type = "search";
|
||
searchInp.placeholder = T("amSearchPh");
|
||
searchInp.value = searchQuery;
|
||
searchInp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); });
|
||
header.appendChild(searchInp);
|
||
panel.appendChild(header);
|
||
const q = searchQuery.trim().toLowerCase();
|
||
const pupils = USERS.filter(u=>u.role==="pupil" && (!q || `${u.displayName} ${u.username}`.toLowerCase().includes(q)));
|
||
panel.appendChild(h("p","am-section-label", T("amPupils")));
|
||
panel.appendChild(peopleList(pupils));
|
||
if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil"));
|
||
renderSheetInto(panel);
|
||
return;
|
||
}
|
||
|
||
const atRoot = currentUser.role==="super" && !selSchool;
|
||
const header = h("div","am-nav-header");
|
||
if(navLevel.type!=="overview"){
|
||
const back = h("button","am-crumb-back", "← " + T("amBack"));
|
||
back.type = "button";
|
||
back.addEventListener("click", ()=>{ navLevel = { type:"overview" }; kebabOpen = false; render(); });
|
||
header.appendChild(back);
|
||
}else{
|
||
const searchInp = h("input","am-inp am-filter");
|
||
searchInp.type = "search";
|
||
searchInp.placeholder = T("amSearchPh");
|
||
searchInp.value = searchQuery;
|
||
searchInp.addEventListener("keydown", ev=>ev.stopPropagation());
|
||
searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); });
|
||
header.appendChild(searchInp);
|
||
}
|
||
panel.appendChild(header);
|
||
|
||
if(navLevel.type==="overview" && searchQuery.trim()){
|
||
renderSearchResults(panel, searchQuery.trim().toLowerCase());
|
||
}else if(navLevel.type==="overview"){
|
||
if(atRoot) renderRootOverview(panel); else renderSchoolOverview(panel);
|
||
}else if(navLevel.type==="class"){
|
||
renderClassDetail(panel, navLevel.id);
|
||
}else if(navLevel.type==="flat"){
|
||
renderFlatDetail(panel, navLevel.kind);
|
||
}
|
||
|
||
renderSheetInto(panel);
|
||
}
|
||
|
||
function switchAdminTab(tab){
|
||
adminTab = tab;
|
||
modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; });
|
||
/* Opslag is school-onafhankelijk: daar is de schoolkiezer alleen maar
|
||
verwarrend, dus die verbergen we er. "school" toont 'm juist wél: de
|
||
kiezer bepaalt daarbinnen ook echt wat je ziet (scholentegels bij
|
||
"alle scholen", klassentegels bij een gekozen school). */
|
||
const sb = modal.querySelector(".am-schoolbar");
|
||
if(sb) sb.style.display = tab==="opslag" ? "none" : "";
|
||
}
|
||
|
||
/* vaste contextbalk voor de systeemmanager: op elk beheer-tabblad direct
|
||
de school kiezen, zonder eerst naar het Systeem-tabblad te moeten */
|
||
function schoolContextBar(){
|
||
if(currentUser.role !== "super") return null;
|
||
const bar = h("div","am-schoolbar");
|
||
bar.appendChild(h("span","am-schoolbar-lbl", T("amFieldSchool")));
|
||
const sel = h("select","am-sel");
|
||
sel.appendChild(new Option(T("amAllSchools"), ""));
|
||
SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id)));
|
||
sel.value = selSchool ?? "";
|
||
sel.setAttribute("aria-label", T("amPickSchool"));
|
||
sel.addEventListener("change", ()=>{
|
||
selSchool = sel.value; navLevel = { type:"overview" }; openSheet = null; kebabOpen = false;
|
||
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 = { school: renderSchoolNavigator, 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);
|
||
}
|
||
})();
|