feat: Klassen en Gebruikers samengevoegd tot één boom per school (v0.4.11-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 51s

- klas, Groepsleiding en Leerlingen staan nu letterlijk bij elkaar: elke klas
  krijgt een eigen rij (naam + "Bewerken" voor verplaatsen/resetten/
  verwijderen, overgenomen uit het oude losse Klassen-paneel) met daaronder
  twee eigen sublijstjes - Groepsleiding (leerkrachten gekoppeld aan die
  klas) en Leerlingen - elk met een eigen "+"-knop
- "+ Groepsleiding" bij een klas maakt de nieuwe leerkracht niet alleen aan,
  maar koppelt die ook meteen aan die klas (hergebruikt de bestaande, al
  geteste POST /admin/classes/:id/teachers-route - geen serverwijziging)
- Schoolbeheerder blijft een eigen, vlakke groep los van de klassenboom (ook
  als een schoolbeheerder toevallig aan een klas gekoppeld is, om dubbele
  vermeldingen te voorkomen); Niet ingedeeld bundelt leerkrachten zonder klas
  en leerlingen zonder klas
- renderKlassenPanel is verwijderd (zijn inhoud leeft nu in de klas-tak van
  renderGebruikersPanel); de losse "Klassen"-kop in het School-tabblad is weg
- nieuwe openClassTools-Set voor het in-/uitklappen van klasbeheer, apart van
  openRows (klas- en gebruiker-id's kunnen samenvallen)
- geverifieerd in een echte headless Chromium: klasrij + twee sublijsten per
  klas, Schoolbeheerder/Niet ingedeeld correct gevuld, en "+ Groepsleiding"
  koppelt de nieuwe leerkracht daadwerkelijk aan de klas (classIds bevestigd)
This commit is contained in:
Ramon 2026-07-18 05:31:00 +02:00
parent e088f83d1f
commit 5b4c3163a5
4 changed files with 146 additions and 116 deletions

View file

@ -1 +1 @@
0.4.10-beta 0.4.11-beta

View file

@ -1320,6 +1320,10 @@
/* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */ /* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */
.am-row-school{background:var(--accent-soft);} .am-row-school{background:var(--accent-soft);}
.am-row-school > .am-name{font-size:15px;} .am-row-school > .am-name{font-size:15px;}
/* klasrij (naam + klasbeheer) onderscheidt zich van de Groepsleiding/
Leerlingen-rijen eronder - zelfde "kop, geen lid"-taal als .am-row-school */
.am-classhead{background:var(--accent-soft); border-left:3px solid var(--accent);}
.am-classhead > .am-name{font-size:15px;}
.am-row-sub{ .am-row-sub{
margin-left:22px; background:var(--surface); margin-left:22px; background:var(--surface);
border-left:3px solid var(--line); border-radius:var(--radius-s); border-left:3px solid var(--line); border-radius:var(--radius-s);

View file

@ -26,6 +26,10 @@
const openGroups = new Set(); const openGroups = new Set();
const closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */ const closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */
const openRows = new Set(); const openRows = new Set();
/* apart van openRows: klas-id's en gebruiker-id's zijn losse reeksen in de
database en kunnen dus samenvallen (klas 3 én gebruiker 3) - een gedeelde
Set zou dan per ongeluk de verkeerde bewerktools open/dicht klappen */
const openClassTools = new Set();
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */ /* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
const creatableRoles = () => const creatableRoles = () =>
@ -555,6 +559,60 @@
return row; return row;
} }
/* klasrij (naam + "Bewerken") binnen de gebruikerslijst: klasbeheer
(verplaatsen naar een andere school, wachtwoorden resetten, verwijderen)
staat zo letterlijk bij de klasleden eronder, i.p.v. in een los
klassenpaneel. Zelfde opzet als userRow() hierboven, met een eigen
openClassTools-Set (zie daar voor waarom). */
function classHeadRow(c, otherSchools, canManage){
const row = h("div","am-row am-classhead");
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
row.appendChild(h("span","am-name", c.name));
row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`));
if(!canManage) return row;
const tools = h("div","am-row-tools");
if(currentUser.role==="super" && otherSchools.length){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amMoveToSchool"), ""));
otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.addEventListener("change", async ()=>{
if(!ss.value) return;
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldMoveSchool", ss));
}
const acts = h("div","am-actions");
acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{
try{
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", acts));
const edit = h("button","am-edit", T("amEdit"));
edit.type = "button";
const sync = ()=>{
const open = openClassTools.has(c.id);
tools.style.display = open ? "" : "none";
edit.classList.toggle("on", open);
};
edit.addEventListener("click", ()=>{
if(openClassTools.has(c.id)) openClassTools.delete(c.id); else openClassTools.add(c.id);
sync();
});
sync();
row.appendChild(edit);
row.appendChild(tools);
return row;
}
/* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding /* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding
afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */
function syncSelection(){ function syncSelection(){
@ -686,6 +744,13 @@
if(confirmInp) body.confirmPassword = confirmInp.value; if(confirmInp) body.confirmPassword = confirmInp.value;
try{ try{
const r = await api("/admin/users", { body }); 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 = ""; name.value = "";
if(pwInp) pwInp.value = ""; if(pwInp) pwInp.value = "";
if(confirmInp) confirmInp.value = ""; if(confirmInp) confirmInp.value = "";
@ -700,6 +765,7 @@
openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none");
openGroups.add("pupils"); openGroups.add("pupils");
} }
if(role==="teacher" && presetClassId) openGroups.add("classteachers:"+presetClassId);
reload(); reload();
}catch(e){ errEl.textContent = e.message; } }catch(e){ errEl.textContent = e.message; }
}); });
@ -912,96 +978,9 @@
panel.appendChild(wrap); panel.appendChild(wrap);
} }
/* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij /* Klassenbeheer zit niet meer in een los paneel: klasrij (classHeadRow) +
een school; de systeemmanager kan een klas naar een andere school klasleden staan nu samen in renderGebruikersPanel's schoolboom (zie
verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete daar), zodat klasbeheer en klasleden letterlijk bij elkaar staan. */
school om klassen bij te tonen of aan te maken. */
function renderKlassenPanel(panel){
if(currentUser.role==="super" && !selSchool){
panel.appendChild(h("div","guestnote", T("amPickSchoolFirst")));
return;
}
const canResetPw = can("schools.resetPasswords");
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
CLASSES.forEach(c=>{
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
let edit = null;
if(canResetPw){
const tools = h("div","am-tools");
/* systeemmanager: klas naar een andere school verplaatsen */
if(currentUser.role==="super" && otherSchools.length){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amMoveToSchool"), ""));
otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.addEventListener("change", async ()=>{
if(!ss.value) return;
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldMoveSchool", ss));
}
const acts = h("div","am-actions");
acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{
try{
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); }
catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", acts));
edit = { key:"klas:"+c.id, tools };
}
panel.appendChild(groupCard({
title:c.name,
summary:`${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`,
edit,
}));
});
/* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat
bestaande klassen bij een andere school kunnen horen */
if(!CLASSES.length){
panel.appendChild(h("div","guestnote", T("amNoClassesHere")));
}
if(canResetPw){
const f = h("div","am-add");
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);
panel.appendChild(f);
/* multireset: alle leerlingwachtwoorden van de hele school in één keer */
panel.appendChild(dangerBtn(T("amResetSchool"), "amConfirmResetSchool", async ()=>{
const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId;
if(!schoolId) return;
try{
const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: T("amResetSchool"), entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
}
}
/* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of /* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of
een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord
@ -1212,6 +1191,13 @@
} }
const collapsed = curGroup && curGroup.dataset.collapsible const collapsed = curGroup && curGroup.dataset.collapsible
&& !openGroups.has(curGroup.dataset.gkey) && !q; && !openGroups.has(curGroup.dataset.gkey) && !q;
if(el.classList.contains("am-classhead")){
/* een klasrij is een kop voor de groepen erna (Groepsleiding/
Leerlingen), geen filterbare gebruikersrij en geen toevoegrij -
mag dus nooit verdwijnen door de invouwstatus van iets ervóór */
el.style.display = "";
return;
}
if(el.classList.contains("am-row")){ if(el.classList.contains("am-row")){
const name = el.querySelector(".am-name"); const name = el.querySelector(".am-name");
const match = !q || (name && name.textContent.toLowerCase().includes(q)); const match = !q || (name && name.textContent.toLowerCase().includes(q));
@ -1373,29 +1359,76 @@
const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null);
if(loose.length) group("gnoschool", T("amNoSchoolGroup"), loose); if(loose.length) group("gnoschool", T("amNoSchoolGroup"), loose);
}else{ }else{
/* één school gekozen: groepeer op rol, leerlingen per klas - elke groep
krijgt direct een "snel toevoegen"-formulier eronder (net als bij
systeemmanagers), zodat een nieuwe schoolbeheerder/leerkracht/
leerling niet via een ander tabblad hoeft. Een groep zonder leden
blijft zichtbaar zolang er wél iets aan toegevoegd kan worden -
anders is een lege klas nergens te vinden om iemand aan toe te voegen. */
[["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]].forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role);
if(us.length || creatableRoles().includes(role)) group("role:"+role, label, us, true);
if(creatableRoles().includes(role)) list.appendChild(addForm(role));
});
const pupils = USERS.filter(u=>u.role==="pupil"); const pupils = USERS.filter(u=>u.role==="pupil");
if(currentUser.role==="teacher"){ if(currentUser.role==="teacher"){
/* groepsleiding zelf: platte lijst - server-side toch al beperkt tot
de eigen klas(sen), en geen klassenbeheer, dus geen boom nodig */
if(pupils.length || creatableRoles().includes("pupil")) group("pupils", T("amPupils"), pupils, true); if(pupils.length || creatableRoles().includes("pupil")) group("pupils", T("amPupils"), pupils, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil")); if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil"));
}else{ }else{
/* schoolbeheerder/systeemmanager: klassen, gebruikers en klasbeheer
in één boom, in plaats van een los klassenpaneel - klas, dan
daarbinnen Groepsleiding en Leerlingen als twee eigen sublijstjes,
elk met een eigen "snel toevoegen". Een groep zonder leden blijft
zichtbaar zolang er wél iets aan toegevoegd kan worden - anders is
een lege klas nergens te vinden om iemand aan toe te voegen. */
const canManageClasses = can("classes.manage");
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
CLASSES.forEach(c=>{ CLASSES.forEach(c=>{
list.appendChild(classHeadRow(c, otherSchools, canManageClasses));
const staff = USERS.filter(u=>u.role==="teacher" && (u.classIds||[]).some(id=>Number(id)===Number(c.id)));
if(staff.length || creatableRoles().includes("teacher")) group("classteachers:"+c.id, T("roleTeacher"), staff, true);
if(creatableRoles().includes("teacher")) list.appendChild(addForm("teacher", c.id));
const us = pupils.filter(u=>u.classId===c.id); const us = pupils.filter(u=>u.classId===c.id);
if(us.length || creatableRoles().includes("pupil")) group("class:"+c.id, c.name, us, true); if(us.length || creatableRoles().includes("pupil")) group("class:"+c.id, T("amPupils"), us, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil", c.id)); if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil", c.id));
}); });
const noClass = pupils.filter(u=>!u.classId); if(canManageClasses){
if(noClass.length || creatableRoles().includes("pupil")) group("class:none", T("amNoClass"), noClass, true); const f = h("div","am-add");
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);
list.appendChild(f);
/* multireset: alle leerlingwachtwoorden van de hele school in één keer */
list.appendChild(dangerBtn(T("amResetSchool"), "amConfirmResetSchool", async ()=>{
const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId;
if(!schoolId) return;
try{
const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"});
await reload();
resetResults = { title: T("amResetSchool"), entries: r.users };
render();
}catch(e){ msg(e.message); }
}));
}
const admins = USERS.filter(u=>u.role==="admin");
if(admins.length || creatableRoles().includes("admin")) group("role:admin", T("roleAdmin"), admins, true);
if(creatableRoles().includes("admin")) list.appendChild(addForm("admin"));
/* niet ingedeeld: leerkrachten zonder klaskoppeling + leerlingen zonder
klas - een schoolbeheerder staat hier bewust nooit in, ook niet als
die toevallig aan geen enkele klas gekoppeld is: dat is geen "niet
ingedeeld"-toestand voor een schoolbeheerder, die hoort al bij
"Schoolbeheerder" hierboven */
const unassigned = [
...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length),
...pupils.filter(u=>!u.classId),
];
if(unassigned.length || creatableRoles().includes("pupil")) group("class:none", T("amNoClass"), unassigned, true);
if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil")); if(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil"));
} }
const parents = USERS.filter(u=>u.role==="parent"); const parents = USERS.filter(u=>u.role==="parent");
@ -1422,22 +1455,15 @@
function renderSchoolPanel(panel){ function renderSchoolPanel(panel){
/* de schoolkiezer erboven moet ook echt iets doen: "alle scholen" toont /* de schoolkiezer erboven moet ook echt iets doen: "alle scholen" toont
het scholenoverzicht (aanmaken/hernoemen/verwijderen, hoort niet bij het scholenoverzicht (aanmaken/hernoemen/verwijderen, hoort niet bij
één school), een gekozen school toont juist die school se klassen - één school); een gekozen school toont Gebruikers, dat zelf de volledige
nooit allebei tegelijk, anders lijkt Scholen de kiezer te negeren. klassen+gebruikers-boom voor die school opbouwt (zie renderGebruikersPanel) -
Gebruikers hangt hier bewust niet van af: dat paneel schakelt zelf al klassen hebben dus geen eigen kop/paneel meer hier. */
tussen een schooloverzicht en een school-specifieke weergave. */
if(currentUser.role==="super" && !selSchool){ if(currentUser.role==="super" && !selSchool){
panel.appendChild(h("h3","am-h3", T("amSchools"))); panel.appendChild(h("h3","am-h3", T("amSchools")));
const sub = h("div","am-section"); const sub = h("div","am-section");
renderScholenPanel(sub); renderScholenPanel(sub);
panel.appendChild(sub); panel.appendChild(sub);
} }
if(can("classes.manage") && (currentUser.role!=="super" || selSchool)){
panel.appendChild(h("h3","am-h3", T("amClasses")));
const sub = h("div","am-section");
renderKlassenPanel(sub);
panel.appendChild(sub);
}
panel.appendChild(h("h3","am-h3", T("amUsers"))); panel.appendChild(h("h3","am-h3", T("amUsers")));
const sub = h("div","am-section"); const sub = h("div","am-section");
renderGebruikersPanel(sub); renderGebruikersPanel(sub);

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.10-beta"; const VERSION = "0.4.11-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;