From 3c3638d114b192dd11946f193514b285b3be3532 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 18 Jul 2026 09:47:16 +0200 Subject: [PATCH] feat: "School" herbouwd als drill-down navigator (v0.4.14-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volledige herbouw van het "School"-tabblad naar het goedgekeurde concept (tegel-overzicht -> gefocust detailscherm), i.p.v. drie secties op één lange pagina. Zelfde patroon op elk niveau: - "alle scholen" (systeemmanager): tegel per school + Systeemmanagers/Ouders/ Zonder school + "+ School" - een school: tegel per klas + Schoolbeheerder/Niet ingedeeld/Ouders/ Aanmeldingen + "+ Klas"; "⋯" toont schoolbeheer (naam/verwijderen, alleen systeemmanager) - een klas: Groepsleiding en Leerlingen als twee eigen sublijstjes, elk met een eigen "+"; "⋯" toont klasbeheer (verplaatsen/resetten/verwijderen) - een platte groep: één lijst, evt. met "+" Personen en aanmeldingen openen nu in een sheet (bestaande userTools()/ requestTools() vrijwel ongewijzigd hergebruikt) i.p.v. een los detailpaneel. Zoeken vult, op tegel-niveau, het scherm met een systeembrede resultatenlijst. Een leerkracht (groepsleiding) blijft de eenvoudige platte leerlingenlijst zien, ongewijzigd. Vervallen (overbodig dankzij drill-down, elke lijst is al kort en gefocust): het hele inklap-/inspring-mechanisme (group(), openGroups/closedGroups, editToggle, classHeadRow, applyUserFilter, .am-classhead, .am-indent), het oude master-detail-paneel (selectedUserKey/syncSelection/renderDetail), en de generieke "Nieuwe gebruiker"-knop (elke tegel se eigen "+" dekt dit al, rol+context-correct). Geen serverwijziging - pure client-herbouw van navigatie/weergave. Geverifieerd in een echte headless Chromium voor systeemmanager (root + school + klas + sheet + "+Groepsleiding" koppelt nog echt), schoolbeheerder (eigen school, geen schoolkiezer/kebab-op-schoolniveau) en groepsleiding (platte lijst, ongewijzigd). --- VERSION | 2 +- public/css/teach.css | 71 ++- public/js/admin.js | 1199 ++++++++++++++++++------------------------ public/js/core.js | 6 +- 4 files changed, 587 insertions(+), 691 deletions(-) diff --git a/VERSION b/VERSION index f038ebe..2a6aad5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.13-beta +0.4.14-beta diff --git a/public/css/teach.css b/public/css/teach.css index 9c4d293..daa269b 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1271,13 +1271,66 @@ sectie, dus niet boven de allereerste kop van de pagina) */ .am-section{margin-bottom:14px;} .am-section + .am-h3{margin-top:22px; padding-top:16px; border-top:1px solid var(--line);} -/* hiërarchie via inspringing, niet via een andere kleur per niveau: alles - wat bij een klas hoort (Groepsleiding/Leerlingen, hun rijen en +-knoppen) - krijgt dezelfde lichte inspringing i.p.v. een eigen kadertje - dat hield - eerder te veel verschillende "boxstijlen" tegelijk in beeld. Specificiteit - moet #settingsModal .am-row's eigen margin:0 kunnen verslaan, ongeacht - bestandsvolgorde. */ -#settingsModal .am-list .am-indent{margin-left:18px;} + +/* ---- "School" drill-down navigator: tegel-overzicht -> gefocust detail ---- */ +.am-nav-header{display:flex; gap:8px; margin-bottom:12px;} +.am-nav-header .am-filter{flex:1;} +.am-crumb-back{ + background:none; border:none; color:var(--accent-ink); font-weight:700; + font-size:14px; cursor:pointer; padding:8px 6px; font:inherit; border-radius:8px; +} +.am-crumb-back:hover{background:var(--surface-2);} +.am-stat-strip{display:flex; gap:10px; margin-bottom:14px;} +.am-stat{flex:1; background:var(--surface-2); border-radius:var(--radius-s); padding:8px 10px; text-align:center;} +.am-stat b{display:block; font-size:18px; font-variant-numeric:tabular-nums;} +.am-stat span{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.3px;} +.am-tilegrid{display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:6px;} +.am-tilegrid > .am-add-wrap{grid-column:1 / -1;} +.am-tile{ + background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); + padding:12px 14px; text-align:left; cursor:pointer; font:inherit; color:var(--ink); + display:flex; flex-direction:column; gap:8px; +} +.am-tile:hover{background:var(--surface-2);} +.am-tile-name{font-weight:700; font-size:14.5px;} +.am-tile-chips{display:flex; flex-wrap:wrap; gap:5px;} +.am-tile.wide{grid-column:1 / -1; flex-direction:row; align-items:center; justify-content:space-between;} +.am-tile.warn{border-color:color-mix(in srgb, var(--orange) 45%, var(--line));} +.am-tile .am-chip.flag{background:var(--orange); color:#1d1200; border-color:transparent;} +.am-tile.add{ + border-style:dashed; border-color:var(--accent); color:var(--accent-ink); background:var(--accent-soft); + align-items:center; justify-content:center; flex-direction:row; font-weight:700; +} +.am-kebab-row{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; gap:8px;} +.am-nav-title{font-family:"Quicksand","Segoe UI Rounded",ui-rounded,sans-serif; font-size:17px; margin:0;} +.am-kebab{ + border:1px solid var(--line); background:var(--surface); border-radius:999px; + width:32px; height:32px; cursor:pointer; color:var(--muted); font-size:16px; flex:none; +} +.am-kebab:hover{background:var(--surface-2);} +.am-kebab-panel{ + background:var(--surface-2); border-radius:var(--radius-s); padding:10px; + margin-bottom:14px; display:flex; flex-direction:column; gap:8px; +} +.am-section-label{ + font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; + color:var(--muted); margin:14px 0 6px; +} +.am-person{cursor:pointer;} +.am-person-info{flex:1; display:flex; flex-direction:column; min-width:0; gap:1px;} +.am-person-info .am-name{font-size:14px;} +.am-person-info .am-role{font-size:11.5px; color:var(--muted); font-weight:600;} +.am-sheet-backdrop{position:fixed; inset:0; background:rgba(15,22,38,.4); z-index:60; display:none;} +.am-sheet-backdrop.open{display:block;} +.am-sheet{ + position:absolute; left:0; right:0; bottom:0; max-width:520px; margin:0 auto; + background:var(--surface); border-radius:18px 18px 0 0; padding:16px 18px 24px; + max-height:82vh; overflow-y:auto; box-shadow:var(--shadow-2); +} +@media (min-width:560px){ + .am-sheet{border-radius:var(--radius-l); bottom:auto; top:50%; transform:translateY(-50%); max-height:76vh;} +} + .am-msg{ min-height:20px; font-size:13.5px; font-weight:700; color:var(--green); margin:4px 0; display:flex; align-items:center; gap:8px; flex-wrap:wrap; @@ -1330,10 +1383,6 @@ /* 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): bewust géén eigen kleur/kader - alleen de - naam iets zwaarder, zodat klas/school/gebruiker-rijen overal dezelfde - visuele taal spreken en de hiërarchie via .am-indent hieronder loopt */ -.am-classhead > .am-name{font-size:15px; font-weight:700;} .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 b84a5a1..1c98eb9 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,12 +1,14 @@ -/* teach - beheerpaneel (👥): gebruikers, klassen en scholen. - Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */ +/* 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 het Systeem-tabblad (super) */ + /* 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 */ @@ -19,18 +21,28 @@ let resetResults = null; /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ let adminTab = "school"; - /* live zoekfilter voor de gebruikerslijst, blijft ook behouden over her-renders */ - let userFilter = ""; - /* uitgeklapte leerling-groepen en gebruikersrijen (standaard alles dicht - voor een rustig overzicht), blijven behouden over her-renders */ - const openGroups = new Set(); - const closedGroups = new Set(); /* door de gebruiker bewust dichtgeklapt */ - const openRows = new Set(); - /* 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(); - const openSchoolTools = new Set(); /* zelfde reden als hierboven */ + + /* 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 = () => @@ -82,7 +94,7 @@ arm-then-confirm als elders in de app); wapening loopt vanzelf af. Geen native confirm()-dialogen meer. rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij - de gebruiker in de rij (met kopieerknop), niet bovenaan. */ + 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))); @@ -123,13 +135,11 @@ } /* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------ - Kop (grijs icoon + titel + samenvatting-als-toggle + Bewerken) met daaronder - optioneel uitklapbare compacte subrijen en/of een uitklapbaar bewerkblok. - De open-status leeft in een Set die re-renders overleeft; andere modules - (bv. de Opslag-tab in image-catalog.js) geven hun eigen Set mee. */ + 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 || openGroups; - const editSet = opts.editSet || openRows; + 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); @@ -182,13 +192,8 @@ /* ---- 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"): voor een - schoolbeheerder/leerkracht is dat toch al hun ene eigen school, en een - systeemmanager ziet binnen die ene sectie alsnog alles (zie - renderSchoolPanel hieronder, dat de drie oorspronkelijke deelpanelen - ongewijzigd naast elkaar zet). Per rol alleen de essentie: groepsleiding - ziet hierbinnen alleen gebruikers, schoolbeheer ook klassen, de - systeemmanager ook scholen (en daarnaast losstaand nog opslag). */ + 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, @@ -202,13 +207,15 @@ .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", { filter:u.displayName }) })), + go:()=>goSettingsSection("school", { openPerson:u.id }) })), ...CLASSES .filter(c=>c.name.toLowerCase().includes(q)) - .map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>goSettingsSection("school") })), + .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:()=>goSettingsSection("school") })), + .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"), @@ -228,9 +235,8 @@ if(force) loadPromise = null; if(!loadPromise){ loadPromise = (async ()=>{ - /* super: selSchool null = nog niet gekozen (reload() zet dan de eerste - school als default zodat klassen/gebruikers meteen een concrete - school tonen); "" = bewust "alle scholen". Andere rollen: eigen school. */ + /* 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(); })(); @@ -246,14 +252,10 @@ visible:()=>!!currentUser && can("admin.access") && d.show(), async activate(payload){ await ensureLoaded(); - if(d.tab==="school" && payload && payload.filter !== undefined) userFilter = payload.filter; + if(d.tab==="school" && payload && payload.openPerson !== undefined) openSheet = { type:"person", id:payload.openPerson }; showAdminHost(); switchAdminTab(d.tab); - if(d.tab==="school" && payload && payload.filter !== undefined){ - const panel = modal.querySelector('[data-panel="school"]'); - const inp = panel && panel.querySelector(".am-filter"); - if(inp){ inp.value = userFilter; applyUserFilter(panel); } - } + if(d.tab==="school") render(); }, keywords:d.keywords, searchItems:d.search, @@ -262,8 +264,11 @@ document.addEventListener("userchange", ()=>{ if(!currentUser) resetResults = null; loadPromise = null; - selectedUserKey = null; selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ + navLevel = { type:"overview" }; + openSheet = null; + kebabOpen = false; + searchQuery = ""; }); async function reload(){ @@ -278,9 +283,8 @@ } const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; USERS = (await api("/admin/users"+q)).users; - /* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als - de contextbalk op één school staat; zonder schoolfilter is USERS al - compleet en is de extra call overbodig */ + /* 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"); @@ -314,31 +318,13 @@ return sel; } - /* ---- gebruikers: master-detail --------------------------------------------- - Links een compacte tabel (naam | functie | klas | status), rechts een vast - detailpaneel waarin de geselecteerde gebruiker bewerkt wordt. De selectie - overleeft reload(); credential-acties selecteren de gebruiker zodat het - eenmalige wachtwoord/de code direct in het paneel zichtbaar is. */ - let selectedUserKey = null; /* "u:" | "req:" | "new" | null */ - let masterHost = null, detailHost = null; - function avatar(nameStr, big){ const parts = String(nameStr || "?").trim().split(/\s+/); const ini = ((parts[0][0] || "?") + (parts[1] ? parts[1][0] : "")).toUpperCase(); return h("span", "am-avatar" + (big ? " big" : ""), ini); } - function userKlasText(u){ - if(u.role==="pupil"){ - const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); - return cls ? cls.name : ""; - } - if(u.role==="parent") return (u.childNames||[]).join(", "); - return (u.classIds||[]) - .map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name) - .filter(Boolean).join(", "); - } /* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op - eerste login/activatie. Tekst clipte in de smalle statuskolom. */ + eerste login/activatie. */ function userStatusGlyph(u){ const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn); const label = u.pending ? T("amPending") @@ -349,31 +335,9 @@ g.setAttribute("aria-label", label); return g; } - function nameCellFor(display, username){ - const cell = h("span","am-name"); - cell.appendChild(avatar(display)); - const nm = h("span","am-name-txt", display); - if(username && display!==username) nm.appendChild(h("small","am-username"," · "+username)); - cell.appendChild(nm); - return cell; - } - /* tabelrij in de gebruikerslijst: klik selecteert (geen her-render, zodat - het zoekveld focus houdt); bewerken gebeurt in het detailpaneel */ - function userCells(u){ - const row = h("div","am-row am-tr"); - row.dataset.selkey = "u:"+u.id; - row.appendChild(nameCellFor(u.displayName, u.username)); - row.appendChild(h("span","am-cell", T(roleKey(u.role)))); - row.appendChild(h("span","am-cell", userKlasText(u) || "—")); - const stCell = h("span","am-cell am-status"); - stCell.appendChild(userStatusGlyph(u)); - row.appendChild(stCell); - row.addEventListener("click", ()=>{ selectedUserKey = "u:"+u.id; syncSelection(); }); - return row; - } - /* het bewerkformulier van één gebruiker; wordt gebruikt door het - detailpaneel (gebruikers) én inline door het Systeem-tabblad (subrijen) */ + /* 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"); @@ -474,7 +438,6 @@ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); rowResults.set(u.id, { label: T("amResultPw"), value: password }); - selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); @@ -485,7 +448,6 @@ try{ const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); - selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); @@ -498,7 +460,6 @@ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); - selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); @@ -507,7 +468,6 @@ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); - selectedUserKey = "u:"+u.id; reload(); }catch(e){ note(e.message); } })); @@ -515,7 +475,7 @@ } if(u.id !== currentUser.id){ const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ - try{ await api("/admin/users/"+u.id, {method:"DELETE"}); selectedUserKey = null; reload(); } + try{ await api("/admin/users/"+u.id, {method:"DELETE"}); openSheet = null; reload(); } catch(e){ note(e.message); } }); actions.appendChild(del); @@ -524,153 +484,6 @@ return tools; } - /* herbruikbaar "Bewerken"-knopje dat een tools-blok in-/uitklapt, gekoppeld - aan een gegeven Set + id. Gebruikers, klassen en scholen hebben elk hun - eigen Set (openRows/openClassTools/openSchoolTools) - hun id's zijn losse - reeksen in de database en kunnen samenvallen, dus nooit delen. Eén - gedeelde implementatie zorgt dat elke "Bewerken"-rij er overal identiek - uitziet en hetzelfde gedraagt, i.p.v. drie keer bijna-hetzelfde stukje - code met kleine, ongemerkt geslopen verschillen. */ - function editToggle(tools, set, id){ - const btn = h("button","am-edit", T("amEdit")); - btn.type = "button"; - const sync = ()=>{ - const open = set.has(id); - tools.style.display = open ? "" : "none"; - btn.classList.toggle("on", open); - }; - btn.addEventListener("click", ()=>{ - if(set.has(id)) set.delete(id); else set.add(id); - sync(); - }); - sync(); - return btn; - } - - /* inline-variant voor het Systeem-tabblad (schoolbeheerder-subrijen): - naam + rol met een "Bewerken"-tekstknop die het formulier uitklapt */ - function userRow(u, opts){ - const compact = !!(opts && opts.compact); - const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "") + (compact ? " am-card-sub" : "")); - row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); - if(compact){ - /* compacte kaart-subrij: status als glyph met uitleg-title i.p.v. tekst */ - row.appendChild(h("span","am-role", T(roleKey(u.role)))); - const glyph = h("span","am-glyph" + (u.pending ? "" : " ok"), u.pending ? "○" : "●"); - glyph.title = u.pending ? T("amPending") : T("amStatusActive"); - row.appendChild(glyph); - }else{ - row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); - } - const tools = h("div","am-row-tools"); - const badge = resultBadge(u); - if(badge) tools.appendChild(badge); - tools.appendChild(userTools(u)); - row.appendChild(editToggle(tools, openRows, u.id)); - row.appendChild(tools); - 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. Bewust een gewone rij (geen eigen kleur/kader) - alleen de - naam is iets zwaarder (zie css), zodat klas/school/gebruiker-rijen overal - dezelfde visuele taal spreken en de hiërarchie via inspringing loopt - i.p.v. via steeds een andere kleur per niveau. */ - 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)); - row.appendChild(editToggle(tools, openClassTools, c.id)); - row.appendChild(tools); - return row; - } - - /* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding - afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ - function syncSelection(){ - if(!masterHost || !detailHost) return; - /* selectie valideren tegen de actuele data (na reload kan hij weg zijn) */ - if(selectedUserKey && selectedUserKey.startsWith("u:") - && !USERS.some(u=>"u:"+u.id===selectedUserKey)) selectedUserKey = null; - if(selectedUserKey && selectedUserKey.startsWith("req:") - && !LINK_REQUESTS.some(r=>"req:"+r.id===selectedUserKey)) selectedUserKey = null; - masterHost.querySelectorAll(".am-tr").forEach(r=>r.classList.toggle("sel", r.dataset.selkey===selectedUserKey)); - const split = masterHost.closest(".am-split"); - if(split) split.classList.toggle("has-sel", !!selectedUserKey); - renderDetail(); - } - function detailHead(title, subtitle){ - const head = h("div","am-detail-head"); - head.appendChild(avatar(title, true)); - const txt = h("div","am-detail-title"); - txt.appendChild(h("h4", null, title)); - if(subtitle) txt.appendChild(h("span","am-role", subtitle)); - head.appendChild(txt); - const close = h("button","am-btn am-detail-close", T("close")); - close.type = "button"; - close.addEventListener("click", ()=>{ selectedUserKey = null; syncSelection(); }); - head.appendChild(close); - return head; - } - function renderDetail(){ - if(!detailHost) return; - detailHost.innerHTML = ""; - if(selectedUserKey === "new"){ - detailHost.appendChild(detailHead(T("amNewUser"), "")); - detailHost.appendChild(newUserForm()); - return; - } - if(selectedUserKey && selectedUserKey.startsWith("u:")){ - const u = USERS.find(x=>"u:"+x.id===selectedUserKey); - detailHost.appendChild(detailHead(u.displayName, T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : ""))); - const badge = resultBadge(u); - if(badge) detailHost.appendChild(badge); - /* fouten (bv. verwijderen mislukt) tonen we hier, waar de beheerder kijkt, - niet alleen bovenaan het paneel (daar valt het buiten beeld) */ - const note = h("div","am-detail-msg formMsg"); - detailHost.appendChild(note); - detailHost.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; })); - return; - } - if(selectedUserKey && selectedUserKey.startsWith("req:")){ - const r = LINK_REQUESTS.find(x=>"req:"+x.id===selectedUserKey); - detailHost.appendChild(detailHead(r.pupil.displayName, `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`)); - detailHost.appendChild(requestTools(r)); - return; - } - detailHost.appendChild(h("div","am-detail-empty", T("amDetailEmpty"))); - } - /* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de "+ …"-knop, pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */ @@ -756,18 +569,10 @@ name.value = ""; if(pwInp) pwInp.value = ""; if(confirmInp) confirmInp.value = ""; - /* het wachtwoord of de koppelcode verschijnt direct bij de nieuwe - gebruiker in de lijst (rij en groep klappen vanzelf open) */ rowResults.set(r.user.id, { label: r.user.password ? T("amResultPw") : T("amResultCode"), value: r.user.password || r.user.linkCode, }); - openRows.add(r.user.id); - if(role==="pupil"){ - openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); - openGroups.add("pupils"); - } - if(role==="teacher" && presetClassId) openGroups.add("classteachers:"+presetClassId); reload(); }catch(e){ errEl.textContent = e.message; } }); @@ -783,84 +588,6 @@ return wrap; } - /* nieuw-gebruiker-formulier in het detailpaneel: eerst de rol kiezen - (super aanmaken blijft op het Systeem-tabblad - die rol hoort niet bij - één school), daarna de velden die bij die rol horen */ - function newUserForm(){ - const wrap = h("div","am-tools"); - const roles = creatableRoles().filter(r=>r!=="super"); - const roleSel = h("select","am-sel"); - roles.forEach(r=>roleSel.appendChild(new Option(T(roleKey(r)), r))); - roleSel.value = roles.includes("pupil") ? "pupil" : roles[0]; - wrap.appendChild(field("amFieldRole", roleSel)); - const body = h("div","am-tools"); - wrap.appendChild(body); - const build = ()=>{ - body.innerHTML = ""; - const role = roleSel.value; - const name = h("input","am-inp"); - name.placeholder = T("amUserPh"); - name.addEventListener("keydown", ev=>ev.stopPropagation()); - body.appendChild(field("amFieldName", name)); - let cs = null; - if(role==="pupil"){ cs = classSel(""); body.appendChild(field("amFieldClass", cs)); } - let pwInp = null; - if(role!=="pupil"){ - const pwWrap = h("div","am-inline"); - pwInp = h("input","am-inp am-pwinp"); - pwInp.type = "password"; - pwInp.autocomplete = "new-password"; - pwInp.placeholder = T("amPwOptional"); - pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); - pwWrap.appendChild(pwInp); - const gen = h("button","am-btn", T("amGenerate")); - gen.type = "button"; - gen.addEventListener("click", async ()=>{ - try{ - const { password } = await api("/admin/genpw"); - pwInp.type = "text"; - pwInp.value = password; - }catch(e){ msg(e.message); } - }); - pwWrap.appendChild(gen); - body.appendChild(field("amFieldPw", pwWrap)); - } - const go = h("button","am-btn am-primary", T("amNewUser")); - go.type = "button"; - go.disabled = true; - name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); - const errEl = h("div","am-form-err"); - go.addEventListener("click", async ()=>{ - errEl.textContent = ""; - const reqBody = { role, username: name.value.trim(), displayName: name.value.trim() }; - if(role==="pupil" && cs && cs.value) reqBody.classId = +cs.value; - if(pwInp && pwInp.value.trim()) reqBody.password = pwInp.value.trim(); - if(currentUser.role==="super") reqBody.school = +selSchool; - try{ - const r = await api("/admin/users", { body: reqBody }); - rowResults.set(r.user.id, { - label: r.user.password ? T("amResultPw") : T("amResultCode"), - value: r.user.password || r.user.linkCode, - }); - if(role==="pupil"){ - openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); - openGroups.add("pupils"); - } - /* de nieuwe gebruiker wordt direct geselecteerd: het eenmalige - wachtwoord of de koppelcode staat dan in het detailpaneel */ - selectedUserKey = "u:"+r.user.id; - reload(); - }catch(e){ errEl.textContent = e.message; } - }); - body.appendChild(go); - body.appendChild(errEl); - setTimeout(()=>name.focus(), 0); - }; - roleSel.addEventListener("change", build); - build(); - return wrap; - } - /* paneel met het resultaat van een multireset (klas/school): een lijst nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */ @@ -894,99 +621,6 @@ return box; } - /* systeem: overzicht van alle scholen + nieuwe school + systeemmanagers. - De schoolkiezer-contextbalk is hier verborgen (zie switchAdminTab), want - niets op dit tabblad hangt van een gekozen school af. Toevoegen zit - achter een bewuste extra stap (+-knop klapt het formulier uit, zoals - bij gebruikers) en bevestigt daarna nog met de naam erin. */ - /* Scholen als platte rijenlijst - zelfde .am-list/.am-row-taal als de - klassen/gebruikers-boom eronder, i.p.v. een eigen kaartenstijl. De - beheerders van een school staan al in Gebruikers (gegroepeerd bij "alle - scholen"); ze hier óók als inklapbare subrijen tonen was een tweede, - overbodige plek voor dezelfde informatie en droeg bij aan het rommelige - gevoel van te veel verschillende niveaus. */ - function renderScholenPanel(panel){ - const list = h("div","am-list"); - SCHOOLS.forEach(s=>{ - const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id)); - const row = h("div","am-row"); - row.appendChild(h("span","am-name", s.name)); - row.appendChild(h("span","am-role", `${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`)); - const tools = h("div","am-row-tools"); - const nameInp = h("input","am-inp"); - nameInp.value = s.name; - nameInp.maxLength = 80; - nameInp.addEventListener("keydown", ev=>ev.stopPropagation()); - nameInp.addEventListener("change", async ()=>{ - const name = nameInp.value.trim(); - if(name.length < 2 || name === s.name) return; - try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); } - catch(e){ msg(e.message); } - }); - tools.appendChild(field("amFieldName", nameInp)); - const acts = h("div","am-actions"); - acts.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{ - try{ - await api("/admin/schools/"+s.id, {method:"DELETE"}); - if(String(selSchool)===String(s.id)) selSchool = ""; - reload(); - }catch(e){ msg(e.message); } - })); - tools.appendChild(field("amFieldActions", acts)); - row.appendChild(editToggle(tools, openSchoolTools, s.id)); - row.appendChild(tools); - list.appendChild(row); - }); - - const wrap = h("div","am-add-wrap"); - const toggle = h("button","tbtn ghost", T("amNewSchool")); - toggle.type = "button"; - wrap.appendChild(toggle); - const f = h("div","am-add"); - f.style.display = "none"; - 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 = r.school.id; inp.value = ""; - reload(); - }catch(e){ msg(e.message); } - }); - f.appendChild(add); - wrap.appendChild(f); - toggle.addEventListener("click", ()=>{ - toggle.style.display = "none"; - f.style.display = "flex"; - inp.focus(); - }); - list.appendChild(wrap); - panel.appendChild(list); - } - - /* 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 live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw @@ -1174,69 +808,48 @@ }); } - /* zoekfilter + inklapbare groepen voor de gebruikerslijst: één plek die de - zichtbaarheid bepaalt (style.display i.p.v. her-render, zodat het zoekveld - focus houdt). Ingeklapte groepen verbergen hun rijen; zodra er gezocht - wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */ - function applyUserFilter(panel){ - const q = userFilter.trim().toLowerCase(); - /* expliciet de gebruikerslijst: het Aanmeldingen-blok erboven is ook een - .am-list maar valt buiten het zoekfilter en de groepslogica */ - const list = panel.querySelector(".am-userlist") || panel.querySelector(".am-list"); - if(!list) return; - let curGroup = null, groupVisible = false; - const closeGroup = ()=>{ - if(curGroup) curGroup.style.display = (groupVisible || curGroup.dataset.collapsible) ? "" : "none"; - }; - [...list.children].forEach(el=>{ - if(el.classList.contains("am-group")){ - closeGroup(); - curGroup = el; groupVisible = false; - return; - } - 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)); - el.style.display = (match && !collapsed) ? "" : "none"; - if(match) groupVisible = true; - }else{ - /* toevoegrij (am-add-wrap): volgt de ingeklapte staat van zijn groep */ - el.style.display = collapsed ? "none" : ""; - } - }); - closeGroup(); - } + /* ========================================================================= + "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. + ========================================================================= */ - /* gebruikers: lijst (altijd, ook zonder gekozen school) + aanmaak-formulieren. - Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd - per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst - (die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */ - /* aanmeldverzoek van een ouder (thuiskind -> deze school): de school kiest - adopteren (thuisaccount wordt de schoolleerling, evt. met klas en - bij - naamconflict - een andere gebruikersnaam), koppelen aan een bestaande - leerling, of afwijzen */ - /* aanmeldverzoek als tabelrij; afhandelen gebeurt in het detailpaneel */ - function requestCells(r){ - const row = h("div","am-row am-tr"); - row.dataset.selkey = "req:"+r.id; - row.appendChild(nameCellFor(r.pupil.displayName, r.pupil.username)); - row.appendChild(h("span","am-cell", T("amRequestOne"))); - row.appendChild(h("span","am-cell", `${T("amRequestVia")} ${r.parent.displayName}`)); - const stCell = h("span","am-cell am-status"); - stCell.appendChild(h("span","am-chip on", T("amReqNew"))); - row.appendChild(stCell); - row.addEventListener("click", ()=>{ selectedUserKey = "req:"+r.id; syncSelection(); }); + /* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */ + function personRow(u){ + const row = h("div","am-row am-person"); + row.appendChild(avatar(u.displayName)); + const info = h("div","am-person-info"); + info.appendChild(h("span","am-name", u.displayName)); + info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); + row.appendChild(info); + const badge = resultBadge(u); + if(badge) row.appendChild(badge); + row.appendChild(userStatusGlyph(u)); + row.addEventListener("click", ()=>{ openSheet = { type:"person", id:u.id }; render(); }); return row; } + /* rij voor één aanmeldverzoek (thuiskind -> deze school) */ + function requestRow(r){ + const row = h("div","am-row am-person"); + row.appendChild(avatar(r.pupil.displayName)); + const info = h("div","am-person-info"); + info.appendChild(h("span","am-name", r.pupil.displayName)); + info.appendChild(h("span","am-role", `${T("amRequestVia")} ${r.parent.displayName}`)); + row.appendChild(info); + row.appendChild(h("span","am-chip on", T("amReqNew"))); + row.addEventListener("click", ()=>{ openSheet = { type:"request", id:r.id }; render(); }); + return row; + } + function peopleList(people){ + const list = h("div","am-list am-people"); + people.forEach(u=>list.appendChild(personRow(u))); + return list; + } + + /* aanmeldverzoek afhandelen: adopteren (thuisaccount wordt schoolleerling, + evt. met klas en - bij naamconflict - een andere gebruikersnaam), + koppelen aan een bestaande leerling, of afwijzen. In de sheet getoond. */ function requestTools(r){ const tools = h("div","am-tools"); @@ -1251,7 +864,7 @@ 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 }); selectedUserKey = null; reload(); } + 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 */ @@ -1271,7 +884,7 @@ 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 } }); selectedUserKey = null; reload(); } + 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); @@ -1280,211 +893,438 @@ 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" } }); selectedUserKey = null; reload(); } + 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; } - function renderGebruikersPanel(panel){ - const schoolChosenTop = currentUser.role!=="super" || !!selSchool; - /* werkbalk: zoeken links, nieuwe gebruiker rechts */ - const bar = h("div","am-toolbar"); - const filterInp = h("input","am-inp am-filter"); - filterInp.type = "search"; - filterInp.placeholder = T("amSearchPh"); - filterInp.value = userFilter; - filterInp.addEventListener("keydown", ev=>ev.stopPropagation()); - filterInp.addEventListener("input", ()=>{ userFilter = filterInp.value; applyUserFilter(panel); }); - bar.appendChild(filterInp); - if(schoolChosenTop && creatableRoles().some(r=>r!=="super")){ - const add = h("button","am-btn am-primary", T("amNewUser")); - add.type = "button"; - add.addEventListener("click", ()=>{ selectedUserKey = "new"; syncSelection(); }); - bar.appendChild(add); - } - panel.appendChild(bar); - - /* master (tabel) + detail (bewerkpaneel) */ - const split = h("div","am-split"); - const master = h("div","am-master"); - const detail = h("div","am-detail"); - masterHost = master; detailHost = detail; - - /* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */ - if(LINK_REQUESTS.length){ - master.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`)); - const rl = h("div","am-reqlist am-list"); - LINK_REQUESTS.forEach(r=>rl.appendChild(requestCells(r))); - master.appendChild(rl); - } - /* kolomkoppen boven de tabel */ - const thead = h("div","am-thead"); - ["amFieldName","amFieldRole","amFieldClass","amColStatus"].forEach(k=>thead.appendChild(h("span",null,T(k)))); - master.appendChild(thead); - - const list = h("div","am-list am-userlist"); - /* inklapbare groepskop; standaard dicht tenzij defaultOpen (dan onthoudt - closedGroups een bewuste dichtklap). Zoeken kijkt door alle groepen heen. */ - const group = (key, label, us, defaultOpen, indent)=>{ - if(defaultOpen && !closedGroups.has(key)) openGroups.add(key); - const g = h("div","am-group am-group-toggle" + (indent ? " am-indent" : "")); - g.dataset.collapsible = "1"; - g.dataset.gkey = key; - const sync = ()=>{ g.textContent = `${openGroups.has(key) ? "▾" : "▸"} ${label} (${us.length})`; }; - sync(); - g.addEventListener("click", ()=>{ - if(openGroups.has(key)){ openGroups.delete(key); closedGroups.add(key); } - else{ openGroups.add(key); closedGroups.delete(key); } - sync(); - applyUserFilter(panel); - }); - list.appendChild(g); - us.forEach(u=>{ - const row = userCells(u); - if(indent) row.classList.add("am-indent"); - list.appendChild(row); - }); - }; - - if(currentUser.role==="super" && !selSchool){ - /* ALLE SCHOLEN: groepeer per school zodat je meteen ziet wie waar hoort; - daarna systeemmanagers, ouders en losse accounts apart */ - const roleOrder = { admin:0, teacher:1, pupil:2 }; - SCHOOLS.forEach(sc=>{ - const us = USERS.filter(u=>Number(u.schoolId)===Number(sc.id)) - .sort((a,b)=>(roleOrder[a.role]??9)-(roleOrder[b.role]??9) || a.displayName.localeCompare(b.displayName)); - if(us.length) group("gschool:"+sc.id, sc.name, us); - }); - const supers = USERS.filter(u=>u.role==="super"); - if(supers.length) group("gsys", T("amSuperGroup"), supers); - /* systeemmanagers horen niet bij één school, dus het aanmaken ervan - staat hier (bij "alle scholen") i.p.v. tussen de school-gebonden - gebruikers - met een extra bevestigingsstap (eigen wachtwoord). */ - if(creatableRoles().includes("super")) list.appendChild(addForm("super")); - const parents = USERS.filter(u=>u.role==="parent"); - if(parents.length) group("gparents", T("amParents"), parents); - const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); - if(loose.length) group("gnoschool", T("amNoSchoolGroup"), loose); + /* 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{ - 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, true); - if(creatableRoles().includes("teacher")){ - const w = addForm("teacher", c.id); - w.classList.add("am-indent"); - list.appendChild(w); - } - const us = pupils.filter(u=>u.classId===c.id); - if(us.length || creatableRoles().includes("pupil")) group("class:"+c.id, T("amPupils"), us, true, true); - if(creatableRoles().includes("pupil")){ - const w = addForm("pupil", c.id); - w.classList.add("am-indent"); - list.appendChild(w); - } - }); - 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")); + 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); } - const parents = USERS.filter(u=>u.role==="parent"); - if(parents.length) group("parents", T("amParents"), parents); } - master.appendChild(list); - split.appendChild(master); - split.appendChild(detail); - panel.appendChild(split); - - applyUserFilter(panel); - syncSelection(); /* selectie valideren + detailpaneel vullen */ + if(opts.onClick) btn.addEventListener("click", opts.onClick); + return btn; } - /* "School": scholen, klassen en gebruikers samen in één tabblad i.p.v. drie - losse. De drie oorspronkelijke deelpanelen blijven ongewijzigd (elk met - hun eigen, al-bestaande rolcontrole) - dit stelt ze alleen na elkaar - samen, elk met een eigen kop, zodat de pagina scanbaar blijft: - - Scholen: alleen systeemmanager (die scholen kan aanmaken/beheren); - onafhankelijk van de gekozen school (was al zo). - - Klassen: alleen wie classes.manage heeft (super/admin) - een - leerkracht ziet dit blok dus helemaal niet. - - Gebruikers: altijd. */ - 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 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); + /* "+ School" als tegel: toevoegen zit achter een bewuste extra stap (tik + opent het veld) en bevestigt daarna nog met de naam erin. */ + function newSchoolTile(){ + const wrap = h("div","am-add-wrap"); + const toggle = tile({ label:T("amNewSchool"), add:true }); + wrap.appendChild(toggle); + const f = h("div","am-add"); + f.style.display = "none"; + f.appendChild(h("div","am-add-title", T("amNewSchool"))); + const inp = h("input","am-inp"); + inp.placeholder = T("amSchoolNamePh"); + inp.maxLength = 80; + inp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(field("amFieldName", inp)); + const add = h("button","tbtn", T("amNewSchool")); + add.type = "button"; + add.disabled = true; + let armTimer = null; + const disarm = ()=>{ clearTimeout(armTimer); armTimer = null; add.classList.remove("danger"); add.textContent = T("amNewSchool"); }; + inp.addEventListener("input", ()=>{ add.disabled = inp.value.trim().length < 2; disarm(); }); + add.addEventListener("click", async ()=>{ + const name = inp.value.trim(); + if(name.length < 2) return; + if(!armTimer){ + add.classList.add("danger"); + add.textContent = `${T("amNewSchoolConfirm")} “${name}”?`; + armTimer = setTimeout(disarm, 5000); + return; + } + disarm(); + try{ + const r = await api("/admin/schools", { body:{ name } }); + selSchool = String(r.school.id); inp.value = ""; navLevel = { type:"overview" }; + reload(); + }catch(e){ msg(e.message); } + }); + f.appendChild(add); + wrap.appendChild(f); + toggle.addEventListener("click", ()=>{ + toggle.style.display = "none"; + f.style.display = "flex"; + inp.focus(); + }); + return wrap; + } + /* "+ Klas" als tegel, zelfde opzet als newSchoolTile hierboven */ + function newClassTile(){ + const wrap = h("div","am-add-wrap"); + const toggle = tile({ label:T("amNewClass"), add:true }); + wrap.appendChild(toggle); + const f = h("div","am-add"); + f.style.display = "none"; + f.appendChild(h("div","am-add-title", T("amNewClass"))); + const inp = h("input","am-inp"); + inp.placeholder = T("amClassNamePh"); + inp.maxLength = 80; + inp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(field("amFieldName", inp)); + const go = h("button","tbtn", T("amNewClass")); + go.type = "button"; + go.disabled = true; + inp.addEventListener("input", ()=>{ go.disabled = inp.value.trim().length < 2; }); + go.addEventListener("click", async ()=>{ + if(inp.value.trim().length < 2) return; + try{ + await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } }); + reload(); + }catch(e){ msg(e.message); } + }); + f.appendChild(go); + wrap.appendChild(f); + toggle.addEventListener("click", ()=>{ + toggle.style.display = "none"; + f.style.display = "flex"; + inp.focus(); + }); + return wrap; + } + + /* "⋯"-actiepaneel van de gekozen school: naam wijzigen, verwijderen. + Alleen systeemmanager - schoolbeheerder/leerkracht zien dit nooit. */ + function schoolActionsPanel(school){ + const box = h("div","am-kebab-panel"); + const nameInp = h("input","am-inp"); + nameInp.value = school.name; + nameInp.maxLength = 80; + nameInp.addEventListener("keydown", ev=>ev.stopPropagation()); + nameInp.addEventListener("change", async ()=>{ + const name = nameInp.value.trim(); + if(name.length < 2 || name === school.name) return; + try{ await api("/admin/schools/"+school.id, {method:"PATCH", body:{ name }}); reload(); } + catch(e){ msg(e.message); } + }); + box.appendChild(field("amFieldName", nameInp)); + box.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{ + try{ + await api("/admin/schools/"+school.id, {method:"DELETE"}); + selSchool = ""; navLevel = { type:"overview" }; kebabOpen = false; + reload(); + }catch(e){ msg(e.message); } + })); + return box; + } + /* "⋯"-actiepaneel van de gekozen klas: verplaatsen naar andere school + (alleen systeemmanager), wachtwoorden resetten, verwijderen. */ + function classActionsPanel(c, otherSchools){ + const box = h("div","am-kebab-panel"); + if(currentUser.role==="super" && otherSchools.length){ + const ss = h("select","am-sel"); + ss.appendChild(new Option(T("amMoveToSchool"), "")); + otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); + ss.addEventListener("change", async ()=>{ + if(!ss.value) return; + try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: +ss.value }}); reload(); } + catch(e){ msg(e.message); } + }); + box.appendChild(field("amFieldMoveSchool", ss)); } - panel.appendChild(h("h3","am-h3", T("amUsers"))); - const sub = h("div","am-section"); - renderGebruikersPanel(sub); - panel.appendChild(sub); + box.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ + try{ + const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; + render(); + }catch(e){ msg(e.message); } + })); + box.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ + try{ + await api("/admin/classes/"+c.id, {method:"DELETE"}); + navLevel = { type:"overview" }; kebabOpen = false; + reload(); + }catch(e){ msg(e.message); } + })); + return box; + } + + /* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school + + Systeemmanagers/Ouders/Zonder school + "+ School". */ + function renderRootOverview(panel){ + const stats = h("div","am-stat-strip"); + [[SCHOOLS.length, T("amSchools")], + [USERS.filter(u=>["admin","teacher","super"].includes(u.role)).length, T("amStaff")], + [USERS.filter(u=>u.role==="pupil").length, T("amPupils")]].forEach(([n,label])=>{ + const s = h("div","am-stat"); + s.appendChild(h("b",null,String(n))); + s.appendChild(h("span",null,label)); + stats.appendChild(s); + }); + panel.appendChild(stats); + + const grid = h("div","am-tilegrid"); + SCHOOLS.forEach(sc=>{ + const staff = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && ["admin","teacher"].includes(u.role)).length; + const pupils = USERS.filter(u=>Number(u.schoolId)===Number(sc.id) && u.role==="pupil").length; + grid.appendChild(tile({ + label: sc.name, + stats: [{text:`🧑‍🏫 ${staff}`}, {text:`🧒 ${pupils}`}], + onClick: ()=>{ selSchool = String(sc.id); navLevel = { type:"overview" }; kebabOpen = false; reload(); }, + })); + }); + const supers = USERS.filter(u=>u.role==="super"); + grid.appendChild(tile({ label:T("amSupers"), wide:true, stats:[{text:String(supers.length)}], + onClick: ()=>{ navLevel = { type:"flat", kind:"supers" }; render(); } })); + const parents = USERS.filter(u=>u.role==="parent"); + if(parents.length) grid.appendChild(tile({ label:T("amParents"), wide:true, stats:[{text:String(parents.length)}], + onClick: ()=>{ navLevel = { type:"flat", kind:"parents" }; render(); } })); + const loose = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); + if(loose.length) grid.appendChild(tile({ label:T("amNoSchoolGroup"), wide:true, warn:true, + stats:[{text:String(loose.length), flag:true}], + onClick: ()=>{ navLevel = { type:"flat", kind:"noschool" }; render(); } })); + if(can("schools.manage")) grid.appendChild(newSchoolTile()); + panel.appendChild(grid); + } + + /* niveau 1: één school - tegel per klas + Schoolbeheerder/Niet ingedeeld/ + Ouders/Aanmeldingen + "+ Klas". "⋯" toont schoolbeheer (alleen super). */ + function renderSchoolOverview(panel){ + const school = SCHOOLS.find(s=>String(s.id)===String(selSchool)); + const canManageClasses = can("classes.manage"); + + if(currentUser.role==="super" && school){ + const titleRow = h("div","am-kebab-row"); + titleRow.appendChild(h("h2","am-nav-title", school.name)); + const kebab = h("button","am-kebab","⋯"); + kebab.type = "button"; + kebab.addEventListener("click", ()=>{ kebabOpen = !kebabOpen; render(); }); + titleRow.appendChild(kebab); + panel.appendChild(titleRow); + if(kebabOpen) panel.appendChild(schoolActionsPanel(school)); + } + + const pupils = USERS.filter(u=>u.role==="pupil"); + const stats = h("div","am-stat-strip"); + [[CLASSES.length, T("amClasses")], + [USERS.filter(u=>["admin","teacher"].includes(u.role)).length, T("amStaff")], + [pupils.length, T("amPupils")]].forEach(([n,label])=>{ + const s = h("div","am-stat"); + s.appendChild(h("b",null,String(n))); + s.appendChild(h("span",null,label)); + stats.appendChild(s); + }); + panel.appendChild(stats); + + const grid = h("div","am-tilegrid"); + if(LINK_REQUESTS.length){ + grid.appendChild(tile({ label:T("amLinkRequests"), wide:true, warn:true, + stats:[{text:String(LINK_REQUESTS.length), flag:true}], + onClick: ()=>{ navLevel = { type:"flat", kind:"requests" }; render(); } })); + } + CLASSES.forEach(c=>{ + const staff = USERS.filter(u=>u.role==="teacher" && (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=>u.role==="teacher" && (u.classIds||[]).some(id=>Number(id)===Number(c.id))); + panel.appendChild(h("p","am-section-label", T("roleTeacher"))); + panel.appendChild(peopleList(staff)); + if(creatableRoles().includes("teacher")) panel.appendChild(addForm("teacher", c.id)); + + const pupils = USERS.filter(u=>u.role==="pupil" && u.classId===c.id); + panel.appendChild(h("p","am-section-label", T("amPupils"))); + panel.appendChild(peopleList(pupils)); + if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil", c.id)); + } + + /* niveau 2b: één platte groep (Schoolbeheerder/Systeemmanagers/Ouders/ + Zonder school/Niet ingedeeld/Aanmeldingen) - één lijst, evt. met "+". */ + function renderFlatDetail(panel, kind){ + if(kind==="requests"){ + const titleRow = h("div","am-kebab-row"); + titleRow.appendChild(h("h2","am-nav-title", T("amLinkRequests"))); + panel.appendChild(titleRow); + const list = h("div","am-list am-people"); + LINK_REQUESTS.forEach(r=>list.appendChild(requestRow(r))); + panel.appendChild(list); + return; + } + const pupils = USERS.filter(u=>u.role==="pupil"); + let title, people, addRole, presetClassId = null; + if(kind==="admins"){ title = T("roleAdmin"); people = USERS.filter(u=>u.role==="admin"); addRole = creatableRoles().includes("admin") ? "admin" : null; } + else if(kind==="supers"){ title = T("amSupers"); people = USERS.filter(u=>u.role==="super"); addRole = creatableRoles().includes("super") ? "super" : null; } + else if(kind==="parents"){ title = T("amParents"); people = USERS.filter(u=>u.role==="parent"); addRole = null; } + else if(kind==="noschool"){ title = T("amNoSchoolGroup"); people = USERS.filter(u=>["teacher","admin","pupil"].includes(u.role) && u.schoolId==null); addRole = null; } + else{ /* unassigned */ + title = T("amUnassigned"); + people = [ + ...USERS.filter(u=>u.role==="teacher" && !(u.classIds||[]).length), + ...pupils.filter(u=>!u.classId), + ]; + addRole = creatableRoles().includes("pupil") ? "pupil" : null; + } + const titleRow = h("div","am-kebab-row"); + titleRow.appendChild(h("h2","am-nav-title", title)); + panel.appendChild(titleRow); + panel.appendChild(peopleList(people)); + if(addRole) panel.appendChild(addForm(addRole, presetClassId)); + } + + /* zoekresultaten: systeembreed op naam, vervangt de tegels zolang er getypt is */ + function renderSearchResults(panel, q){ + const matches = USERS.filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q)); + if(!matches.length){ panel.appendChild(h("div","guestnote", T("amNoResults"))); return; } + panel.appendChild(peopleList(matches)); + } + + /* de sheet: personen- of aanmeldingsdetail, altijd bovenop het huidige + niveau. validateSheet() controleert eerst of de selectie nog bestaat + (na reload() kan iemand weg zijn, bv. net verwijderd). */ + function validateSheet(){ + if(openSheet && openSheet.type==="person" && !USERS.some(u=>u.id===openSheet.id)) openSheet = null; + if(openSheet && openSheet.type==="request" && !LINK_REQUESTS.some(r=>r.id===openSheet.id)) openSheet = null; + } + function renderSheetInto(panel){ + const backdrop = h("div","am-sheet-backdrop" + (openSheet ? " open" : "")); + backdrop.addEventListener("click", (e)=>{ if(e.target===backdrop){ openSheet = null; render(); } }); + if(openSheet){ + const sheet = h("div","am-sheet"); + const closeBtn = h("button","am-btn am-detail-close", T("close")); + closeBtn.type = "button"; + closeBtn.addEventListener("click", ()=>{ openSheet = null; render(); }); + if(openSheet.type==="person"){ + const u = USERS.find(x=>x.id===openSheet.id); + const head = h("div","am-detail-head"); + head.appendChild(avatar(u.displayName, true)); + const txt = h("div","am-detail-title"); + txt.appendChild(h("h4", null, u.displayName)); + txt.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : ""))); + head.appendChild(txt); + head.appendChild(closeBtn); + sheet.appendChild(head); + const badge = resultBadge(u); + if(badge) sheet.appendChild(badge); + const note = h("div","am-detail-msg formMsg"); + sheet.appendChild(note); + sheet.appendChild(userTools(u, (t)=>{ note.textContent = t || ""; })); + }else if(openSheet.type==="request"){ + const r = LINK_REQUESTS.find(x=>x.id===openSheet.id); + const head = h("div","am-detail-head"); + head.appendChild(avatar(r.pupil.displayName, true)); + const txt = h("div","am-detail-title"); + txt.appendChild(h("h4", null, r.pupil.displayName)); + txt.appendChild(h("span","am-role", `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`)); + head.appendChild(txt); + head.appendChild(closeBtn); + sheet.appendChild(head); + sheet.appendChild(requestTools(r)); + } + backdrop.appendChild(sheet); + } + panel.appendChild(backdrop); + } + + /* het "School"-tabblad zelf: bepaalt o.b.v. selSchool/navLevel welk niveau + te tonen. Een leerkracht (groepsleiding) heeft geen klassenbeheer en + krijgt de eenvoudiger platte leerlingenlijst, ongewijzigd t.o.v. eerder - + server-side toch al beperkt tot de eigen klas(sen), dus geen boom nodig. */ + function renderSchoolNavigator(panel){ + validateSheet(); + panel.classList.add("am-nav-panel"); + + if(currentUser.role==="teacher"){ + const header = h("div","am-nav-header"); + const searchInp = h("input","am-inp am-filter"); + searchInp.type = "search"; + searchInp.placeholder = T("amSearchPh"); + searchInp.value = searchQuery; + searchInp.addEventListener("keydown", ev=>ev.stopPropagation()); + searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); }); + header.appendChild(searchInp); + panel.appendChild(header); + const q = searchQuery.trim().toLowerCase(); + const pupils = USERS.filter(u=>u.role==="pupil" && (!q || `${u.displayName} ${u.username}`.toLowerCase().includes(q))); + panel.appendChild(h("p","am-section-label", T("amPupils"))); + panel.appendChild(peopleList(pupils)); + if(creatableRoles().includes("pupil")) panel.appendChild(addForm("pupil")); + renderSheetInto(panel); + return; + } + + const atRoot = currentUser.role==="super" && !selSchool; + const header = h("div","am-nav-header"); + if(navLevel.type!=="overview"){ + const back = h("button","am-crumb-back", "← " + T("amBack")); + back.type = "button"; + back.addEventListener("click", ()=>{ navLevel = { type:"overview" }; kebabOpen = false; render(); }); + header.appendChild(back); + }else{ + const searchInp = h("input","am-inp am-filter"); + searchInp.type = "search"; + searchInp.placeholder = T("amSearchPh"); + searchInp.value = searchQuery; + searchInp.addEventListener("keydown", ev=>ev.stopPropagation()); + searchInp.addEventListener("input", ()=>{ searchQuery = searchInp.value; render(); }); + header.appendChild(searchInp); + } + panel.appendChild(header); + + if(navLevel.type==="overview" && searchQuery.trim()){ + renderSearchResults(panel, searchQuery.trim().toLowerCase()); + }else if(navLevel.type==="overview"){ + if(atRoot) renderRootOverview(panel); else renderSchoolOverview(panel); + }else if(navLevel.type==="class"){ + renderClassDetail(panel, navLevel.id); + }else if(navLevel.type==="flat"){ + renderFlatDetail(panel, navLevel.kind); + } + + renderSheetInto(panel); } function switchAdminTab(tab){ @@ -1492,8 +1332,8 @@ 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 nu ook echt wat je ziet (Scholen bij "alle - scholen", Klassen bij een gekozen school - zie renderSchoolPanel). */ + 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" : ""; } @@ -1509,7 +1349,10 @@ 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; selectedUserKey = null; reload(); }); + sel.addEventListener("change", ()=>{ + selSchool = sel.value; navLevel = { type:"overview" }; openSheet = null; kebabOpen = false; + reload(); + }); bar.appendChild(sel); return bar; } @@ -1525,7 +1368,7 @@ const tabDefs = adminTabDefs(); if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; - const panels = { school: renderSchoolPanel, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel }; + const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel }; tabDefs.forEach(([t])=>{ const panel = h("div","am-tabpanel"); panel.dataset.panel = t; diff --git a/public/js/core.js b/public/js/core.js index 0a1d2b4..9a02296 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.13-beta"; +const VERSION = "0.4.14-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -304,6 +304,8 @@ const I18N = { amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord", amNoClass:"— geen klas —", amPending:"nog niet geactiveerd", amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Systeem", + amBack:"Terug", amStaff:"Staf", amSupers:"Systeemmanagers", + amUnassigned:"Niet ingedeeld", amNoResults:"Geen resultaten", amCreated:"Aangemaakt! Geef deze koppelcode door:", amPupilMade:"Leerling aangemaakt. Wachtwoord:", amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", @@ -604,6 +606,8 @@ const I18N = { amCode:"link code", amNewCode:"new code", amPw:"password", amNoClass:"— no class —", amPending:"not yet activated", amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"System", + amBack:"Back", amStaff:"Staff", amSupers:"System managers", + amUnassigned:"Unassigned", amNoResults:"No results", amCreated:"Created! Pass on this link code:", amPupilMade:"Pupil created. Password:", amPwHidden:"already used", amGenerate:"generate password",