From 5b4c3163a55ea95ebe8779a89611f7697d653b42 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 18 Jul 2026 05:31:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Klassen=20en=20Gebruikers=20samengevoeg?= =?UTF-8?q?d=20tot=20=C3=A9=C3=A9n=20boom=20per=20school=20(v0.4.11-beta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- VERSION | 2 +- public/css/teach.css | 4 + public/js/admin.js | 254 ++++++++++++++++++++++++------------------- public/js/core.js | 2 +- 4 files changed, 146 insertions(+), 116 deletions(-) diff --git a/VERSION b/VERSION index ab75c70..f206c0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.10-beta +0.4.11-beta diff --git a/public/css/teach.css b/public/css/teach.css index 835a045..f6efeb9 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1320,6 +1320,10 @@ /* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */ .am-row-school{background:var(--accent-soft);} .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{ margin-left:22px; background:var(--surface); border-left:3px solid var(--line); border-radius:var(--radius-s); diff --git a/public/js/admin.js b/public/js/admin.js index 5b6303e..535af19 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -26,6 +26,10 @@ const openGroups = new Set(); const closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */ const openRows = new Set(); + /* apart van openRows: klas-id's en gebruiker-id's zijn losse reeksen in de + database en kunnen dus samenvallen (klas 3 én gebruiker 3) - een gedeelde + Set zou dan per ongeluk de verkeerde bewerktools open/dicht klappen */ + const openClassTools = new Set(); /* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */ const creatableRoles = () => @@ -555,6 +559,60 @@ 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 afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ function syncSelection(){ @@ -686,6 +744,13 @@ 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 = ""; @@ -700,6 +765,7 @@ openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); openGroups.add("pupils"); } + if(role==="teacher" && presetClassId) openGroups.add("classteachers:"+presetClassId); reload(); }catch(e){ errEl.textContent = e.message; } }); @@ -912,96 +978,9 @@ panel.appendChild(wrap); } - /* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij - een school; de systeemmanager kan een klas naar een andere school - verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete - school om klassen bij te tonen of aan te maken. */ - function renderKlassenPanel(panel){ - if(currentUser.role==="super" && !selSchool){ - panel.appendChild(h("div","guestnote", T("amPickSchoolFirst"))); - return; - } - const canResetPw = can("schools.resetPasswords"); - const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool)); - CLASSES.forEach(c=>{ - const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length; - let edit = null; - if(canResetPw){ - const tools = h("div","am-tools"); - /* systeemmanager: klas naar een andere school verplaatsen */ - if(currentUser.role==="super" && otherSchools.length){ - const ss = h("select","am-sel"); - ss.appendChild(new Option(T("amMoveToSchool"), "")); - otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); - ss.addEventListener("change", async ()=>{ - if(!ss.value) return; - try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); } - catch(e){ msg(e.message); } - }); - tools.appendChild(field("amFieldMoveSchool", ss)); - } - const acts = h("div","am-actions"); - acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ - try{ - const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); - await reload(); - resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; - render(); - }catch(e){ msg(e.message); } - })); - acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ - try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } - catch(e){ msg(e.message); } - })); - tools.appendChild(field("amFieldActions", acts)); - edit = { key:"klas:"+c.id, tools }; - } - panel.appendChild(groupCard({ - title:c.name, - summary:`${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`, - edit, - })); - }); - /* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat - bestaande klassen bij een andere school kunnen horen */ - if(!CLASSES.length){ - panel.appendChild(h("div","guestnote", T("amNoClassesHere"))); - } - if(canResetPw){ - const f = h("div","am-add"); - 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); } - })); - } - } + /* Klassenbeheer zit niet meer in een los paneel: klasrij (classHeadRow) + + klasleden staan nu samen in renderGebruikersPanel's schoolboom (zie + daar), zodat klasbeheer en klasleden letterlijk bij elkaar staan. */ /* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord @@ -1212,6 +1191,13 @@ } const collapsed = curGroup && curGroup.dataset.collapsible && !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")){ const name = el.querySelector(".am-name"); 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); if(loose.length) group("gnoschool", T("amNoSchoolGroup"), loose); }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"); 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(creatableRoles().includes("pupil")) list.appendChild(addForm("pupil")); }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=>{ + 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); - 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)); }); - const noClass = pupils.filter(u=>!u.classId); - if(noClass.length || creatableRoles().includes("pupil")) group("class:none", T("amNoClass"), noClass, true); + if(canManageClasses){ + 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")); } const parents = USERS.filter(u=>u.role==="parent"); @@ -1422,22 +1455,15 @@ function renderSchoolPanel(panel){ /* de schoolkiezer erboven moet ook echt iets doen: "alle scholen" toont het scholenoverzicht (aanmaken/hernoemen/verwijderen, hoort niet bij - één school), een gekozen school toont juist die school se klassen - - nooit allebei tegelijk, anders lijkt Scholen de kiezer te negeren. - Gebruikers hangt hier bewust niet van af: dat paneel schakelt zelf al - tussen een schooloverzicht en een school-specifieke weergave. */ + één school); een gekozen school toont Gebruikers, dat zelf de volledige + klassen+gebruikers-boom voor die school opbouwt (zie renderGebruikersPanel) - + klassen hebben dus geen eigen kop/paneel meer hier. */ if(currentUser.role==="super" && !selSchool){ panel.appendChild(h("h3","am-h3", T("amSchools"))); const sub = h("div","am-section"); renderScholenPanel(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"))); const sub = h("div","am-section"); renderGebruikersPanel(sub); diff --git a/public/js/core.js b/public/js/core.js index 60081c8..ef4f4f9 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* 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. */ -const VERSION = "0.4.10-beta"; +const VERSION = "0.4.11-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION;