All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m23s
1760 lines
80 KiB
JavaScript
1760 lines
80 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";
|
||
/* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden
|
||
over her-renders heen zoals adminTab hierboven. */
|
||
let kmClass = null, kmSubject = "rekenen";
|
||
const KM_SUBJECTS = ["taal", "rekenen", "world"];
|
||
|
||
/* 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(activeRoles().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:"klassenmanagement", icon:"🎓", group:"school", order:14, labelKey:"amLevels", subKey:"amLevelsSub",
|
||
show:()=>can("assignments.manage"),
|
||
keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep",
|
||
"leerjaar","year group","differentiatie"] },
|
||
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
|
||
show:()=>activeRole()==="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(activeRole()==="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);
|
||
/* export als uitdeelvel - alleen mogelijk zolang dit scherm openstaat: de
|
||
wachtwoorden zijn hierna nergens meer in leesbare vorm te achterhalen
|
||
(alleen het gehashte wachtwoord blijft in de database), dus dit is
|
||
bewust "exporteer wat nu net gegenereerd is", niet "haal wachtwoorden
|
||
van bestaande accounts op". Gebruikersnaam (het echte inlog-veld) staat
|
||
er wél bij, ook al toont de schermlijst hierboven alleen de naam. */
|
||
const exportBtn = h("button","am-btn", T("amExportCsv"));
|
||
exportBtn.type = "button";
|
||
exportBtn.addEventListener("click", e=>{
|
||
e.preventDefault();
|
||
const header = [T("amExportColPupil"), T("amExportColUsername"), T("amResultPw")];
|
||
const rows = [header, ...resetResults.entries.map(en=>[en.displayName, en.username, en.password])];
|
||
const safeName = resetResults.title.replace(/[^a-z0-9]+/gi, "-");
|
||
downloadCsv(`${safeName}.csv`, rows);
|
||
});
|
||
box.appendChild(exportBtn);
|
||
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");
|
||
|
||
panel.appendChild(h("div","am-group", T("amDashboardTitle")));
|
||
const dashboardClass = classSel();
|
||
panel.appendChild(dashboardClass);
|
||
const dashboardBody = h("div","teacher-dashboard");
|
||
panel.appendChild(dashboardBody);
|
||
async function loadDashboard(){
|
||
dashboardBody.innerHTML = "";
|
||
if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; }
|
||
dashboardBody.appendChild(h("div","guestnote","…"));
|
||
try{
|
||
const data = await api("/progress/dashboard/class/"+dashboardClass.value);
|
||
dashboardBody.innerHTML = "";
|
||
const summary = h("div","dashboard-summary");
|
||
["complete","active","support","notStarted"].forEach(status=>{
|
||
const chip = h("div","dashboard-summary-chip "+status);
|
||
chip.appendChild(h("strong",null,String(data.summary?.[status]||0)));
|
||
chip.appendChild(h("span",null,T("amDashboardStatus_"+status)));
|
||
summary.appendChild(chip);
|
||
});
|
||
dashboardBody.appendChild(summary);
|
||
if(!(data.pupils||[]).length){ dashboardBody.appendChild(h("div","guestnote",T("castNoPupils"))); return; }
|
||
const grid = h("div","dashboard-grid");
|
||
(data.pupils||[]).forEach(pupil=>{
|
||
const card = h("button","dashboard-card "+pupil.status);
|
||
card.type = "button";
|
||
card.appendChild(h("span","dashboard-status",T("amDashboardStatus_"+pupil.status)));
|
||
card.appendChild(h("strong","dashboard-name",pupil.pupilName));
|
||
if(pupil.sequence){
|
||
card.appendChild(h("span","dashboard-path",pupil.sequence.title));
|
||
const bar = h("span","dashboard-path-bar");
|
||
const fill = h("i"); fill.style.width = Math.round((pupil.sequence.completed/pupil.sequence.total)*100)+"%";
|
||
bar.appendChild(fill); card.appendChild(bar);
|
||
card.appendChild(h("span","am-role",T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total)));
|
||
}
|
||
card.appendChild(h("span","dashboard-score",pupil.attempts ? pupil.accuracy+"% · "+pupil.correct+"/"+pupil.attempts : T("amDashboardNoActivity")));
|
||
card.addEventListener("click",()=>{ pupilSel.value=String(pupil.pupilId); pupilSel.dispatchEvent(new Event("change")); });
|
||
grid.appendChild(card);
|
||
});
|
||
dashboardBody.appendChild(grid);
|
||
}catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); }
|
||
}
|
||
dashboardClass.addEventListener("change",loadDashboard);
|
||
loadDashboard();
|
||
|
||
panel.appendChild(h("div","am-group", T("amProgressDetail")));
|
||
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));
|
||
});
|
||
}
|
||
|
||
/* CSV-download zonder library: waarden met komma/quote/newline krijgen
|
||
quotes (Excel-conventie), een BOM vooraan zodat Excel-NL accenten en de
|
||
komma-scheiding correct herkent. Eerste CSV-export in deze codebase -
|
||
bewust hier lokaal gehouden (nog maar één gebruiker); als de geplande
|
||
wachtwoorden-export dit ook nodig heeft, dan pas verplaatsen. */
|
||
function csvValue(v){
|
||
const s = String(v ?? "");
|
||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
|
||
}
|
||
function downloadCsv(filename, rows){
|
||
const csv = rows.map(r=>r.map(csvValue).join(",")).join("\r\n");
|
||
const blob = new Blob([""+csv], {type:"text/csv;charset=utf-8;"});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url; a.download = filename;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/* eenvoudige, met de hand getekende SVG-grafiekjes voor de Voortgang-tab -
|
||
geen library nodig (zelfde aanpak als avatar.js dit sessie), puur
|
||
client-side berekend uit de al opgehaalde events. */
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
function svgEl(tag, attrs){
|
||
const el = document.createElementNS(SVG_NS, tag);
|
||
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,v));
|
||
return el;
|
||
}
|
||
function progressAccuracyRing(correct, total){
|
||
const pct = total ? Math.round((correct/total)*100) : 0;
|
||
const r = 40, c = 2*Math.PI*r, offset = c*(1-pct/100);
|
||
const svg = svgEl("svg", {viewBox:"0 0 100 100", class:"progress-ring"});
|
||
svg.appendChild(svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:"var(--surface-3)", "stroke-width":10}));
|
||
if(total){
|
||
const color = pct>=80 ? "var(--green)" : pct>=50 ? "var(--yellow)" : "var(--red)";
|
||
const arc = svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:color,
|
||
"stroke-width":10, "stroke-dasharray":c, "stroke-dashoffset":offset, "stroke-linecap":"round"});
|
||
arc.setAttribute("transform", "rotate(-90 50 50)");
|
||
svg.appendChild(arc);
|
||
}
|
||
const text = svgEl("text", {x:50, y:56, "text-anchor":"middle", class:"progress-ring-pct"});
|
||
text.textContent = total ? pct+"%" : "–";
|
||
svg.appendChild(text);
|
||
return svg;
|
||
}
|
||
/* goed/fout per dag, laatste 14 dagen - attempts/correct i.p.v. een simpele
|
||
goed/fout-vlag per event, zodat dit ook klopt voor oudere geaggregeerde
|
||
events (bv. een pre-upgrade rekenblok van 10 sommen: attempts:10,
|
||
correct:7 telt als 7 goed + 3 fout, niet als één enkel "fout"-moment). */
|
||
function progressDailyBars(events){
|
||
const days = [];
|
||
const byDay = new Map();
|
||
const now = new Date();
|
||
for(let i=13; i>=0; i--){
|
||
const d = new Date(now); d.setDate(d.getDate()-i);
|
||
const key = d.toISOString().slice(0,10);
|
||
byDay.set(key, {correct:0, wrong:0});
|
||
days.push(key);
|
||
}
|
||
events.forEach(ev=>{
|
||
const key = new Date(ev.createdAt).toISOString().slice(0,10);
|
||
const bucket = byDay.get(key);
|
||
if(!bucket) return;
|
||
bucket.correct += ev.correct || 0;
|
||
bucket.wrong += Math.max(0, (ev.attempts||0) - (ev.correct||0));
|
||
});
|
||
const w = 280, h = 70, barW = w/days.length;
|
||
const max = Math.max(1, ...days.map(k=>{ const b = byDay.get(k); return b.correct+b.wrong; }));
|
||
const svg = svgEl("svg", {viewBox:`0 0 ${w} ${h}`, class:"progress-bars"});
|
||
days.forEach((key,i)=>{
|
||
const b = byDay.get(key);
|
||
const total = b.correct+b.wrong;
|
||
const totalH = (total/max)*(h-4);
|
||
const correctH = total ? (b.correct/total)*totalH : 0;
|
||
const wrongH = totalH-correctH;
|
||
const x = i*barW+barW*0.22, bw = barW*0.56;
|
||
if(wrongH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH, width:bw, height:wrongH, fill:"var(--red)", rx:1}));
|
||
if(correctH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH+wrongH, width:bw, height:correctH, fill:"var(--green)", rx:1}));
|
||
});
|
||
return svg;
|
||
}
|
||
/* welk vakgebied hoort bij een widgetType - zelfde categorieën als
|
||
REGISTRY's cat in board.js, ook gebruikt door klassenmanagement. */
|
||
function widgetSubject(widgetType){
|
||
const def = REGISTRY.find(d=>d.id===widgetType);
|
||
return def ? def.cat : null;
|
||
}
|
||
|
||
/* éé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 en een vakgebied, ziet meteen
|
||
het toegewezen niveau (klassenmanagement-koppeling), een grafiek
|
||
(nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke
|
||
opdracht het was (waar een widget dat detail meegeeft) - de volledige
|
||
ruwe tijdlijn blijft eronder beschikbaar, niets verdwijnt. */
|
||
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 subjectRow = h("div","am-inline");
|
||
panel.appendChild(subjectRow);
|
||
const body = h("div");
|
||
panel.appendChild(body);
|
||
|
||
let allEvents = [];
|
||
let pvSubject = "rekenen";
|
||
|
||
function renderBody(){
|
||
body.innerHTML = "";
|
||
const events = allEvents.filter(ev=>widgetSubject(ev.widgetType)===pvSubject);
|
||
if(!events.length){ body.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; }
|
||
|
||
const totals = events.reduce((acc,ev)=>({
|
||
correct: acc.correct + (ev.correct||0),
|
||
attempts: acc.attempts + (ev.attempts||0),
|
||
}), {correct:0, attempts:0});
|
||
|
||
const summary = h("div","progress-summary");
|
||
summary.appendChild(progressAccuracyRing(totals.correct, totals.attempts));
|
||
const summaryText = h("div","progress-summary-text");
|
||
summaryText.appendChild(h("div","progress-summary-count",
|
||
`${totals.correct} / ${totals.attempts} ${T("mGoed")}`));
|
||
summaryText.appendChild(h("div","progress-summary-hint", T("amProgressLast14Days")));
|
||
summary.append(summaryText, progressDailyBars(events));
|
||
body.appendChild(summary);
|
||
|
||
const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10);
|
||
if(mistakes.length){
|
||
body.appendChild(h("div","am-group", T("amProgressMistakes")));
|
||
const list = h("div","am-list");
|
||
mistakes.forEach(ev=>{
|
||
const row = h("div","am-row");
|
||
row.appendChild(h("span","am-name", ev.detail.question||""));
|
||
row.appendChild(h("span","am-code",
|
||
T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " +
|
||
T("amProgressExpected").replace("{answer}", ev.detail.expected)));
|
||
row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
|
||
list.appendChild(row);
|
||
});
|
||
body.appendChild(list);
|
||
}
|
||
|
||
body.appendChild(h("div","am-group", T("amProgressTimeline")));
|
||
const eventsHolder = h("div","am-list");
|
||
body.appendChild(eventsHolder);
|
||
const moreBtn = h("button","am-btn", T("amShowMore"));
|
||
moreBtn.type = "button";
|
||
body.appendChild(moreBtn);
|
||
/* 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})`;
|
||
};
|
||
showMore();
|
||
moreBtn.onclick = showMore;
|
||
}
|
||
|
||
async function renderLevelBadge(){
|
||
if(!pupilSel.value) return;
|
||
let lvl = null;
|
||
try{ lvl = (await api(`/levels/pupil/${pupilSel.value}?subject=${pvSubject}`)).level; }catch(e){}
|
||
const old = body.querySelector(".progress-level-badge");
|
||
if(old) old.remove();
|
||
const badge = h("div","progress-level-badge",
|
||
lvl ? T("amYearGroupN").replace("{n}", lvl.yearGroup) + " · " + T("lvl"+lvl.level) : T("amLevelNone"));
|
||
body.prepend(badge);
|
||
}
|
||
|
||
async function loadPupil(){
|
||
body.innerHTML = "";
|
||
subjectRow.innerHTML = "";
|
||
if(!pupilSel.value) return;
|
||
KM_SUBJECTS.forEach(s=>{
|
||
const b = h("button","am-btn", T("amSubject_"+s));
|
||
b.type = "button";
|
||
b.classList.toggle("active", s===pvSubject);
|
||
b.addEventListener("click", ()=>{
|
||
pvSubject = s;
|
||
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
|
||
b.classList.add("active");
|
||
renderBody();
|
||
renderLevelBadge();
|
||
});
|
||
subjectRow.appendChild(b);
|
||
});
|
||
body.appendChild(h("div","guestnote", "…"));
|
||
try{ allEvents = (await api("/progress/pupil/"+pupilSel.value)).events || []; }
|
||
catch(e){ body.innerHTML = ""; body.appendChild(h("div","am-msg", e.message)); return; }
|
||
renderBody();
|
||
renderLevelBadge();
|
||
}
|
||
pupilSel.addEventListener("change", loadPupil);
|
||
|
||
panel.appendChild(h("div","am-group", T("amExportClass")));
|
||
const classSelExport = classSel();
|
||
panel.appendChild(classSelExport);
|
||
const exportBtn = h("button","am-btn", T("amExportCsv"));
|
||
exportBtn.type = "button";
|
||
panel.appendChild(exportBtn);
|
||
exportBtn.addEventListener("click", async ()=>{
|
||
if(!classSelExport.value){ msg(T("amPickClassHint")); return; }
|
||
let events = [];
|
||
try{ events = (await api("/progress/class/"+classSelExport.value+"?limit=5000")).events || []; }
|
||
catch(e){ msg(e.message); return; }
|
||
if(!events.length){ msg(T("amProgressEmpty")); return; }
|
||
const cls = CLASSES.find(c=>String(c.id)===classSelExport.value);
|
||
const header = [T("amExportColPupil"), T("amExportColWidget"), T("amExportColAttempts"),
|
||
T("amExportColCorrect"), T("amExportColStars"), T("amExportColDate")];
|
||
const rows = [header, ...events.map(ev=>[
|
||
ev.pupilName,
|
||
T("wg_"+ev.widgetType) || ev.widgetType,
|
||
ev.attempts, ev.correct, ev.stars,
|
||
new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"),
|
||
])];
|
||
const safeName = (cls ? cls.name : classSelExport.value).replace(/[^a-z0-9]+/gi, "-");
|
||
downloadCsv(`voortgang-${safeName}.csv`, rows);
|
||
msg(T("amExportDone"));
|
||
});
|
||
}
|
||
|
||
/* =========================================================================
|
||
"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");
|
||
/* eigen personage vervangt de initialen-cirkel zodra iemand er één heeft
|
||
ingesteld (Instellingen > "Mijn personage") - zo ziet een leerkracht de
|
||
personages van de klas via het scherm dat toch al gebruikt wordt. */
|
||
row.appendChild(u.avatar ? renderAvatarFace(u.avatar, "sm") : 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(activeRole()==="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;
|
||
}
|
||
|
||
function yearGroupSel(current){
|
||
const sel = h("select","am-sel");
|
||
for(let g=1; g<=8; g++) sel.appendChild(new Option(T("amYearGroupN").replace("{n}", g), g));
|
||
sel.value = current ?? 4;
|
||
return sel;
|
||
}
|
||
function levelSel(current){
|
||
const sel = h("select","am-sel");
|
||
[1,2,3].forEach(n=>sel.appendChild(new Option(
|
||
typeof learningLevelLabel==="function" ? learningLevelLabel(n) : T("lvl"+n), n)));
|
||
sel.value = current ?? 1;
|
||
return sel;
|
||
}
|
||
function describeLevel(effective, isOverride){
|
||
if(!effective) return T("amLevelNone");
|
||
const base = `${T("amYearGroupN").replace("{n}", effective.yearGroup)} · ${T("lvl"+effective.level)}`;
|
||
return isOverride ? base : `${base} (${T("amLevelInherited")})`;
|
||
}
|
||
|
||
/* klas-standaard: geldt voor iedereen in de klas tenzij een leerling een
|
||
eigen afwijking heeft (zie renderPupilLevelRow hieronder). */
|
||
function renderClassDefaultCard(classId, subject, current, onSaved){
|
||
const box = h("div","am-card");
|
||
box.appendChild(h("div","am-card-title", T("amLevelClassDefault")));
|
||
const row = h("div","am-inline");
|
||
const ygSel = yearGroupSel(current && current.yearGroup);
|
||
const lvlSel = levelSel(current && current.level);
|
||
row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
|
||
const summary = h("div","guestnote learning-level-summary");
|
||
const updateSummary=()=>{
|
||
const p=typeof learningProfile==="function" ? learningProfile(subject,+ygSel.value,+lvlSel.value) : null;
|
||
summary.textContent=p ? p.title+" — "+p.goals.join(" · ") : "";
|
||
};
|
||
ygSel.addEventListener("change",updateSummary);lvlSel.addEventListener("change",updateSummary);updateSummary();
|
||
const saveBtn = h("button","am-btn", T("save"));
|
||
saveBtn.type = "button";
|
||
saveBtn.addEventListener("click", async ()=>{
|
||
try{
|
||
await api(`/levels/class/${classId}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
|
||
msg(T("amLevelSaved"));
|
||
await onSaved();
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
row.appendChild(saveBtn);
|
||
box.appendChild(row);
|
||
box.appendChild(summary);
|
||
return box;
|
||
}
|
||
|
||
/* één leerling: toont de EFFECTIEVE groep/niveau (eigen afwijking, anders de
|
||
klas-standaard, gedempt met "erft van klas"). Instellen van een eigen
|
||
afwijking is even laagdrempelig als de klas-standaard - het
|
||
speciaal-onderwijs-geval waar dat vanaf het begin de norm is, mag geen
|
||
verstopt uitzonderingspad zijn. */
|
||
function renderPupilLevelRow(pupil, subject, override, classDefault, onSaved){
|
||
const effective = override || classDefault;
|
||
const wrap = h("div");
|
||
const row = h("div","am-row am-person");
|
||
const info = h("div","am-person-info");
|
||
info.appendChild(h("span","am-name", pupil.displayName));
|
||
info.appendChild(h("span","am-role", describeLevel(effective, !!override)));
|
||
row.appendChild(info);
|
||
|
||
const editBox = h("div","am-inline");
|
||
editBox.style.display = "none";
|
||
const ygSel = yearGroupSel(effective && effective.yearGroup);
|
||
const lvlSel = levelSel(effective && effective.level);
|
||
editBox.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel));
|
||
const saveBtn = h("button","am-btn", T("save"));
|
||
saveBtn.type = "button";
|
||
saveBtn.addEventListener("click", async ()=>{
|
||
try{
|
||
await api(`/levels/pupil/${pupil.id}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}});
|
||
msg(T("amLevelSaved"));
|
||
await onSaved();
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
editBox.appendChild(saveBtn);
|
||
|
||
const tools = h("div","am-tools");
|
||
const editBtn = h("button","am-btn", T("amEdit"));
|
||
editBtn.type = "button";
|
||
editBtn.addEventListener("click", ()=>{
|
||
editBox.style.display = editBox.style.display==="none" ? "flex" : "none";
|
||
});
|
||
tools.appendChild(editBtn);
|
||
if(override){
|
||
const resetBtn = h("button","am-btn", T("amLevelReset"));
|
||
resetBtn.type = "button";
|
||
resetBtn.addEventListener("click", async ()=>{
|
||
try{
|
||
await api(`/levels/pupil/${pupil.id}?subject=${subject}`, {method:"DELETE"});
|
||
msg(T("amLevelSaved"));
|
||
await onSaved();
|
||
}catch(e){ msg(e.message); }
|
||
});
|
||
tools.appendChild(resetBtn);
|
||
}
|
||
row.appendChild(tools);
|
||
wrap.append(row, editBox);
|
||
return wrap;
|
||
}
|
||
|
||
/* klassenmanagement: per vakgebied een niveaugroep (leerjaar 1-8, waaraan
|
||
de leerdoelen gekoppeld zijn) + niveau (1-3) instellen, met een
|
||
klas-standaard en individuele afwijkingen - zie roadmap-memo. */
|
||
function renderKlassenmanagementPanel(panel){
|
||
const schoolChosen = currentUser.role!=="super" || !!selSchool;
|
||
if(!schoolChosen){
|
||
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
|
||
return;
|
||
}
|
||
panel.appendChild(h("div","am-group", T("amLevels")));
|
||
const clsSel = classSel(kmClass ?? "");
|
||
panel.appendChild(clsSel);
|
||
|
||
const subjectRow = h("div","am-inline");
|
||
panel.appendChild(subjectRow);
|
||
const bodyHolder = h("div");
|
||
panel.appendChild(bodyHolder);
|
||
|
||
async function loadBody(){
|
||
bodyHolder.innerHTML = "";
|
||
if(!kmClass) return;
|
||
bodyHolder.appendChild(h("div","guestnote", "…"));
|
||
let data;
|
||
try{ data = await api(`/levels/class/${kmClass}?subject=${kmSubject}`); }
|
||
catch(e){ bodyHolder.innerHTML = ""; bodyHolder.appendChild(h("div","am-msg", e.message)); return; }
|
||
bodyHolder.innerHTML = "";
|
||
bodyHolder.appendChild(renderClassDefaultCard(kmClass, kmSubject, data.classDefault, loadBody));
|
||
bodyHolder.appendChild(h("div","am-group", T("amLevelPupils")));
|
||
const list = h("div","am-list");
|
||
const overrideMap = new Map(data.pupilOverrides.map(o=>[o.pupilId, o]));
|
||
const pupilsInClass = USERS.filter(u=>u.role==="pupil" && String(u.classId)===String(kmClass));
|
||
if(!pupilsInClass.length) list.appendChild(h("div","guestnote", T("amNoClass")));
|
||
pupilsInClass.forEach(u=>{
|
||
list.appendChild(renderPupilLevelRow(u, kmSubject, overrideMap.get(u.id), data.classDefault, loadBody));
|
||
});
|
||
bodyHolder.appendChild(list);
|
||
}
|
||
|
||
KM_SUBJECTS.forEach(s=>{
|
||
const b = h("button","am-btn", T("amSubject_"+s));
|
||
b.type = "button";
|
||
b.classList.toggle("active", s===kmSubject);
|
||
b.addEventListener("click", ()=>{
|
||
kmSubject = s;
|
||
subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active"));
|
||
b.classList.add("active");
|
||
loadBody();
|
||
});
|
||
subjectRow.appendChild(b);
|
||
});
|
||
|
||
clsSel.addEventListener("change", ()=>{ kmClass = clsSel.value || null; loadBody(); });
|
||
if(kmClass) loadBody();
|
||
}
|
||
|
||
/* 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(activeRole()==="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(activeRole()==="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, klassenmanagement: renderKlassenmanagementPanel, opslag: renderImageStoragePanel };
|
||
tabDefs.forEach(([t])=>{
|
||
const panel = h("div","am-tabpanel");
|
||
panel.dataset.panel = t;
|
||
panels[t](panel);
|
||
modal.appendChild(panel);
|
||
});
|
||
|
||
switchAdminTab(adminTab);
|
||
}
|
||
})();
|