v0.3.37-beta: één functie per gebruiker + klas-koppeling voor groepsleiding
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s

- Het extra-functies-systeem (chips + select door elkaar) is uit de UI:
  een gebruiker heeft één functie, alleen te wijzigen via de Functie-select
- Groepsleiding is nu eenvoudig aan klassen te koppelen: huidige klassen
  als chips met ✕, een select "voeg toe aan klas…" voor nieuwe koppelingen;
  de klassen staan ook in de rijkop ("Groepsleiding · Groep 4")
- /admin/users geeft de klas-koppelingen (class_teachers) per gebruiker mee
- Formulier-fouten (bv. ongeldige gebruikersnaam bij een nieuwe
  systeemmanager) verschijnen nu direct bij het formulier in plaats van
  onzichtbaar bovenaan - dit maakte aanmaken schijnbaar onmogelijk
- Wachtwoord/koppelcode van een nieuwe gebruiker verschijnt direct bij die
  gebruiker in de lijst (rij en groep klappen vanzelf open)
This commit is contained in:
Ramon 2026-07-15 16:20:21 +02:00
parent df921d5968
commit 804ffb98f6
5 changed files with 73 additions and 27 deletions

View file

@ -1 +1 @@
0.3.36-beta 0.3.37-beta

View file

@ -1153,6 +1153,8 @@
/* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */ /* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */
.am-group-toggle{cursor:pointer; user-select:none;} .am-group-toggle{cursor:pointer; user-select:none;}
.am-group-toggle:hover{color:var(--ink);} .am-group-toggle:hover{color:var(--ink);}
/* formulier-fout direct bij het formulier zelf */
.am-form-err{width:100%; min-height:0; color:var(--red); font-size:13px; font-weight:700;}
/* 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;}

View file

@ -198,11 +198,19 @@
if(u.role==="pupil"){ if(u.role==="pupil"){
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : ""))); row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
}else if(u.role==="teacher"){
const names = (u.classIds||[])
.map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name)
.filter(Boolean);
row.appendChild(h("span","am-role", T(roleKey(u.role))
+ (names.length ? ` · ${names.join(", ")}` : "")
+ (u.pending ? ` · ${T("amPending")}` : "")));
}else{ }else{
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
} }
if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){ if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){
/* super mag de rol van andere stafaccounts wijzigen - leerlingen blijven altijd leerling */ /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super);
het aparte extra-functies-systeem is uit de UI gehaald */
const rs = h("select","am-sel"); const rs = h("select","am-sel");
["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r))); ["teacher","admin","super"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r)));
rs.value = u.role; rs.value = u.role;
@ -211,23 +219,38 @@
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
tools.appendChild(field("amFieldRole", rs)); tools.appendChild(field("amFieldRole", rs));
}
/* extra functies naast de hoofdrol (bv. een teacher die ook beheerdersrechten /* groepsleiding eenvoudig aan klassen koppelen: huidige klassen als
krijgt) - los van de hoofdrol hierboven, direct toggelbaar per rol */ chips met , en een select om een klas toe te voegen */
const extraWrap = h("div","am-classes"); if(u.role==="teacher" && can("classes.manage") && CLASSES.length){
["teacher","admin","super"].filter(r=>r!==u.role).forEach(r=>{ const wrap = h("div","am-classes");
const has = (u.extraRoles||[]).includes(r); (u.classIds||[]).forEach(cid=>{
const chip = h("button","am-chip", (has?"✓ ":"+ ")+T(roleKey(r))); const c = CLASSES.find(x=>Number(x.id)===Number(cid));
chip.type = "button"; if(!c) return;
chip.classList.toggle("on", has); const chip = h("span","am-chip on", c.name);
chip.addEventListener("click", async ()=>{ const x = h("button","am-chipx","✕");
const next = has ? (u.extraRoles||[]).filter(x=>x!==r) : [...(u.extraRoles||[]), r]; x.type = "button";
try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{extraRoles: next}}); reload(); } x.title = T("amClassRemove");
x.addEventListener("click", async ()=>{
try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); }
catch(e){ msg(e.message); } catch(e){ msg(e.message); }
}); });
extraWrap.appendChild(chip); chip.appendChild(x);
wrap.appendChild(chip);
}); });
tools.appendChild(field("amFieldExtraRoles", extraWrap)); const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id)));
if(rest.length){
const addSel = h("select","am-sel");
addSel.appendChild(new Option(T("amClassAdd"), ""));
rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id)));
addSel.addEventListener("change", async ()=>{
if(!addSel.value) return;
try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); }
catch(e){ msg(e.message); }
});
wrap.appendChild(addSel);
}
tools.appendChild(field("amFieldClasses", wrap));
} }
if(u.role==="pupil"){ if(u.role==="pupil"){
/* klas (uitwisselen met andere klassen) */ /* klas (uitwisselen met andere klassen) */
@ -367,7 +390,10 @@
go.type = "button"; go.type = "button";
go.disabled = true; go.disabled = true;
name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; });
/* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */
const errEl = h("div","am-form-err");
go.addEventListener("click", async ()=>{ go.addEventListener("click", async ()=>{
errEl.textContent = "";
const body = { role, username: name.value.trim(), displayName: name.value.trim() }; const body = { role, username: name.value.trim(), displayName: name.value.trim() };
if(role==="pupil" && cs && cs.value) body.classId = +cs.value; if(role==="pupil" && cs && cs.value) body.classId = +cs.value;
if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim(); if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim();
@ -376,16 +402,22 @@
const r = await api("/admin/users", { body }); const r = await api("/admin/users", { body });
name.value = ""; name.value = "";
if(pwInp) pwInp.value = ""; if(pwInp) pwInp.value = "";
await reload(); /* het wachtwoord of de koppelcode verschijnt direct bij de nieuwe
msg(role==="pupil" gebruiker in de lijst (rij en groep klappen vanzelf open) */
? `${T("amPupilMade")} ${r.user.password}` rowResults.set(r.user.id, {
: r.user.password label: r.user.password ? T("amResultPw") : T("amResultCode"),
? `${T("amPwSet")} ${r.user.password}` value: r.user.password || r.user.linkCode,
: `${T("amCreated")} ${r.user.linkCode}`, });
r.user.password || r.user.linkCode); openRows.add(r.user.id);
}catch(e){ msg(e.message); } if(role==="pupil"){
openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none");
openGroups.add("pupils");
}
reload();
}catch(e){ errEl.textContent = e.message; }
}); });
f.appendChild(go); f.appendChild(go);
f.appendChild(errEl);
wrap.appendChild(f); wrap.appendChild(f);
toggle.addEventListener("click", ()=>{ toggle.addEventListener("click", ()=>{

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.3.36-beta"; const VERSION = "0.3.37-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -184,8 +184,9 @@ const I18N = {
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school", amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder", amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder",
amClassAdd:"— voeg toe aan klas… —", amClassRemove:"Uit deze klas halen",
amSure:"weet je het zeker?", amDelete:"verwijder", amSure:"weet je het zeker?", amDelete:"verwijder",
amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas", amFieldName:"Naam", amFieldRole:"Functie", amFieldClass:"Klas", amFieldClasses:"Klassen",
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…", amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
amNewPwWarn:"Maakt een nieuw wachtwoord aan. Het oude wachtwoord werkt daarna niet meer en de gebruiker wordt overal uitgelogd.", amNewPwWarn:"Maakt een nieuw wachtwoord aan. Het oude wachtwoord werkt daarna niet meer en de gebruiker wordt overal uitgelogd.",
amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.",
@ -381,8 +382,9 @@ const I18N = {
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school", amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
amAdmins:"School admins", amAdminOne:"School admin", amAdmins:"School admins", amAdminOne:"School admin",
amClassAdd:"— add to class… —", amClassRemove:"Remove from this class",
amSure:"are you sure?", amDelete:"delete", amSure:"are you sure?", amDelete:"delete",
amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class", amFieldName:"Name", amFieldRole:"Role", amFieldClass:"Class", amFieldClasses:"Classes",
amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…", amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…",
amNewPwWarn:"Generates a new password. The old one stops working and the user is signed out everywhere.", amNewPwWarn:"Generates a new password. The old one stops working and the user is signed out everywhere.",
amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.",

View file

@ -408,10 +408,20 @@ export default async function api(app) {
const key = String(r.user_id); const key = String(r.user_id);
extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]); extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]);
}); });
// klas-koppelingen van groepsleiding (class_teachers), ook in één keer
const ctRows = rows.length
? (await pool.query('SELECT user_id, class_id FROM class_teachers WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
: [];
const classesByUser = new Map();
ctRows.forEach((r) => {
const key = String(r.user_id);
classesByUser.set(key, [...(classesByUser.get(key) || []), Number(r.class_id)]);
});
return { users: rows.map((u) => ({ return { users: rows.map((u) => ({
...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }), ...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }),
linkCode: u.link_code || null, linkCode: u.link_code || null,
everLoggedIn: !!u.last_login_at, everLoggedIn: !!u.last_login_at,
classIds: classesByUser.get(String(u.id)) || [],
})) }; })) };
}); });