fix: leerlingen zichtbaar bij gekozen school + uniforme controls in het beheer (v0.3.92-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s

- Leerlingen stonden onder standaard-ingeklapte klasgroepen, dus met een gekozen school leek de lijst leeg terwijl de detailweergave de juiste school toonde. Klasgroepen staan nu standaard open zodra een school is gekozen (de schoolkeuze wás al het filter); bewust dichtklappen wordt onthouden
- Losse klassen (school verwijderd) waren nergens zichtbaar: de systeemmanager ziet ze nu zonder gekozen school met een uitleg, en kan ze via een School-veld terugkoppelen; nieuwe PATCH /admin/classes/:id + GET ?school=none (super) met servertests
- Bij het aan een school koppelen van een leerling blijft de klasindeling behouden als die klas al bij de doelschool hoort (eerst klas terugkoppelen, dan de leerlingen)
- UI-consistentie: één uniforme control-hoogte (32px) voor álle knoppen, selects en invoervelden in Instellingen - einde aan de grote maatverschillen; T() valt terug op NL en dan de sleutelnaam zodat een knop nooit zonder tekst kan staan
- Status in de gebruikerstabel als compacte glyph (● in gebruik / ○ wacht) met uitleg bij aanwijzen i.p.v. een afgekapte "nog niet ing…"-chip
- Geverifieerd in headless Chromium: nieuwe suite (21 checks: zichtbaarheid met schoolfilter, losse klassen, geen lege knoppen, uniforme hoogte over 8 secties) + volledige regressie van 5 bestaande suites

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
This commit is contained in:
Ramon 2026-07-17 19:46:09 +02:00
parent 15618348dc
commit aee1c59fbd
6 changed files with 143 additions and 33 deletions

View file

@ -1 +1 @@
0.3.91-beta
0.3.92-beta

View file

@ -2054,3 +2054,25 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
#settingsModal .am-card-head.am-card-toggle{cursor:pointer;}
#settingsModal .cms-card-body{padding:10px 12px;}
#settingsModal .cms-card-body .am-reset{border:none; padding:0; background:transparent;}
/* ---- uniforme control-maat: elk veld/knop in het beheer even hoog ---- */
#settingsModal .am-btn,
#settingsModal .am-copy,
#settingsModal .tbtn,
#settingsModal .am-edit,
#settingsModal .am-inp,
#settingsModal .am-sel,
#settingsModal .am-pw,
#settingsModal input[type="search"],
#settingsModal input[type="number"],
#settingsModal input[type="password"],
#settingsModal input[type="text"]{
height:var(--control-h); min-height:var(--control-h); box-sizing:border-box;
font-size:13px; line-height:1;
}
#settingsModal .am-edit{padding:0 12px; opacity:.75;}
#settingsModal .am-edit:hover{opacity:1;}
/* textarea's (CMS) mogen hoger; niet vastpinnen */
#settingsModal textarea.am-inp, #settingsModal .cms-ta{height:auto; min-height:64px;}
/* labels boven de velden even breed als hun veld zodat rijen uitlijnen */
#settingsModal .am-field{gap:4px;}

View file

@ -24,6 +24,7 @@
/* 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();
/* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */
@ -265,7 +266,7 @@
SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin")
: USERS.filter(u=>u.role==="admin");
}
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=none") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
ASSIGNMENTS = {};
if(CLASSES.length){
@ -312,10 +313,17 @@
.map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name)
.filter(Boolean).join(", ");
}
function userStatus(u){
if(u.pending) return T("amPending");
if(u.role==="pupil" && !u.everLoggedIn) return T("amNeverLoggedIn");
return "";
/* status compact als glyph met uitleg-title: in gebruik, wacht nog op
eerste login/activatie. Tekst clipte in de smalle statuskolom. */
function userStatusGlyph(u){
const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn);
const label = u.pending ? T("amPending")
: (u.role==="pupil" && !u.everLoggedIn) ? T("amNeverLoggedIn")
: T("amStatusActive");
const g = h("span","am-glyph" + (waiting ? "" : " ok"), waiting ? "○" : "●");
g.title = label;
g.setAttribute("aria-label", label);
return g;
}
function nameCellFor(display, username){
const cell = h("span","am-name");
@ -334,9 +342,7 @@
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");
const st = userStatus(u);
if(st) stCell.appendChild(h("span","am-chip", st));
else stCell.textContent = "—";
stCell.appendChild(userStatusGlyph(u));
row.appendChild(stCell);
row.addEventListener("click", ()=>{ selectedUserKey = "u:"+u.id; syncSelection(); });
return row;
@ -861,21 +867,37 @@
if(creatableRoles().includes("super")) panel.appendChild(addForm("super"));
}
/* klassen (admin/super binnen een school) */
/* klassen (admin/super binnen een school); de systeemmanager ziet zonder
gekozen school de losse klassen (school verwijderd) om ze terug te koppelen */
function renderKlassenPanel(panel){
const schoolChosen = currentUser.role!=="super" || !!selSchool;
if(!schoolChosen){
const looseView = currentUser.role==="super" && !selSchool;
const canResetPw = can("schools.resetPasswords");
if(looseView){
if(!CLASSES.length){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return;
}
const canResetPw = can("schools.resetPasswords");
/* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken),
met de gevaarlijke acties als aparte, bevestigde knoppen */
panel.appendChild(h("div","guestnote", T("amLooseClassesHint")));
}
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 aan een school (terug)koppelen */
if(currentUser.role==="super"){
const ss = h("select","am-sel");
ss.appendChild(new Option(T("amNoSchool"), ""));
SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.value = looseView ? "" : (selSchool || "");
ss.addEventListener("change", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldSchool", ss));
}
const acts = h("div","am-actions");
if(!looseView){
acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{
try{
const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"});
@ -884,11 +906,11 @@
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); }
}));
const tools = h("div","am-tools");
tools.appendChild(field("amFieldActions", acts));
edit = { key:"klas:"+c.id, tools };
}
@ -898,6 +920,7 @@
edit,
}));
});
if(looseView) return;
if(canResetPw){
const f = h("div","am-add");
const inp = h("input","am-inp");
@ -1289,7 +1312,8 @@
const sync = ()=>{ g.textContent = `${openGroups.has(key) ? "▾" : "▸"} ${label} (${us.length})`; };
sync();
g.addEventListener("click", ()=>{
if(openGroups.has(key)) openGroups.delete(key); else openGroups.add(key);
if(openGroups.has(key)){ openGroups.delete(key); closedGroups.add(key); }
else{ openGroups.add(key); closedGroups.delete(key); }
sync();
applyUserFilter(panel);
});
@ -1306,6 +1330,9 @@
}else{
CLASSES.forEach(c=>{
const us = pupils.filter(u=>u.classId===c.id);
/* met een gekozen school staan de klasgroepen standaard open - de
schoolkeuze wás al het filter; dichtklappen wordt onthouden */
if(us.length && !closedGroups.has("class:"+c.id)) openGroups.add("class:"+c.id);
if(us.length) pupilGroup("class:"+c.id, c.name, us);
});
const noClass = pupils.filter(u=>!u.classId);

View file

@ -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.3.91-beta";
const VERSION = "0.3.92-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -280,6 +280,7 @@ const I18N = {
amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode",
amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amStatusActive:"actief",
amNoSchool:"— geen school —", amNoSchoolGroup:"Zonder school", amShowMore:"meer tonen",
amLooseClassesHint:"Deze klassen horen bij geen enkele school. Koppel ze via Bewerken.",
amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.",
amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw",
amParents:"Ouders",
@ -575,6 +576,7 @@ const I18N = {
amResultPw:"New password", amResultCode:"New link code",
amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amStatusActive:"active",
amNoSchool:"— no school —", amNoSchoolGroup:"No school", amShowMore:"show more",
amLooseClassesHint:"These classes belong to no school. Attach them via Edit.",
amDetailEmpty:"Select a user on the left to edit, or create a new one.",
amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new",
amParents:"Parents",
@ -621,7 +623,9 @@ const I18N = {
}
};
let LANG = "nl";
const T = k => I18N[LANG][k];
/* vangnet: ontbreekt een sleutel in de actieve taal, val terug op NL en
daarna op de sleutelnaam zelf - een knop mag nooit zonder tekst staan */
const T = k => I18N[LANG][k] ?? I18N.nl[k] ?? k;
/* effective CSS zoom of the app (display-size setting); pointer coordinates
are visual pixels, absolute positions are layout pixels divide by VZ() */
const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; };

View file

@ -338,13 +338,33 @@ export default async function api(app) {
need(req, reply, PERMISSIONS['classes.view']);
const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id;
if (!schoolId) return { classes: [] };
// 'none' (alleen super): losse klassen (school ooit verwijderd) - anders
// zijn die nergens zichtbaar en dus ook niet terug te koppelen
const loose = schoolId === 'none' && req.user.allRoles.includes('super');
const r = await pool.query(
`SELECT c.id, c.name,
COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids
FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]);
WHERE ${loose ? 'c.school_id IS NULL' : 'c.school_id = $1'} GROUP BY c.id ORDER BY c.name`,
loose ? [] : [schoolId]);
return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) };
});
// klas aan een (andere) school koppelen of losmaken - alleen systeemmanager;
// herstelinstrument voor klassen die hun school kwijt zijn
app.patch('/admin/classes/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
if (!c) return fail(reply, 404, 'klas onbekend');
const b = req.body ?? {};
if (b.schoolId === undefined) return fail(reply, 400, 'school verplicht');
const target = b.schoolId === null ? null : Number(b.schoolId);
if (target !== null) {
const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0];
if (!school) return fail(reply, 404, 'school onbekend');
}
await pool.query('UPDATE classes SET school_id = $1 WHERE id = $2', [target, c.id]);
return { ok: true };
});
app.post('/admin/classes', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']);
const { name, school } = req.body ?? {};
@ -594,8 +614,15 @@ export default async function api(app) {
if (!school) return fail(reply, 404, 'school onbekend');
}
if (target !== (u.school_id == null ? null : Number(u.school_id))) {
// klasindeling behouden als de huidige klas al bij de doelschool hoort
// (herstelvolgorde: eerst de klas terugkoppelen, dan de leerlingen)
let keepClass = null;
if (u.class_id != null && target !== null) {
const cls = (await pool.query('SELECT school_id FROM classes WHERE id = $1', [u.class_id])).rows[0];
if (cls && Number(cls.school_id) === target) keepClass = u.class_id;
}
try {
await pool.query('UPDATE users SET school_id = $1, class_id = NULL WHERE id = $2', [target, u.id]);
await pool.query('UPDATE users SET school_id = $1, class_id = $2 WHERE id = $3', [target, keepClass, u.id]);
} catch (e) {
// naam kan botsen in de doelschool óf (bij losmaken) in de
// school-loze naamruimte (systeemmanagers/ouders/thuiskinderen)

View file

@ -122,7 +122,7 @@ test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los'
payload: { schoolId: 2 } });
assert.equal(res2.statusCode, 200, res2.body);
const upd = calls2.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
assert.deepEqual(upd.params, [2, 7]);
assert.deepEqual(upd.params, [2, null, 7]);
assert.ok(calls2.some((c) => c.sql.includes('DELETE FROM class_teachers')));
await app.close(); await app2.close();
// losmaken: schoolId null, geen schools-check nodig; leerling verliest toewijzing
@ -131,7 +131,7 @@ test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los'
payload: { schoolId: null } });
assert.equal(res3.statusCode, 200, res3.body);
const upd3 = calls3.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
assert.deepEqual(upd3.params, [null, 9]);
assert.deepEqual(upd3.params, [null, null, 9]);
assert.ok(calls3.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id')));
await app3.close();
});
@ -172,6 +172,36 @@ test('school koppelen meldt een naamconflict netjes als 409', async () => {
await app.close();
});
test('systeemmanager koppelt een losse klas terug aan een school', async () => {
const { app, calls } = await makeAppWithSchool(superUser, null);
const origQuery = app.pg.query.bind(app.pg);
app.pg.query = async (sql, params = []) => {
if (sql.startsWith('SELECT * FROM classes WHERE id')) { calls.push({ sql, params }); return { rows: [{ id: 9, name: 'Groep 4', school_id: null }] }; }
return origQuery(sql, params);
};
const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies,
payload: { schoolId: 2 } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id'));
assert.deepEqual(upd.params, [2, 9]);
// schoolbeheerder mag dit niet (schools.manage = super)
const { app: app2, calls: calls2 } = await makeAppWithSchool(admin, null);
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies,
payload: { schoolId: 2 } });
assert.equal(res2.statusCode, 403);
assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE classes SET school_id')));
await app.close(); await app2.close();
});
test('losse klassen zijn opvraagbaar met school=none (alleen super)', async () => {
const { app, calls } = await makeApp(superUser);
const res = await app.inject({ url: '/api/admin/classes?school=none', cookies });
assert.equal(res.statusCode, 200, res.body);
const q = calls.find((c) => c.sql.includes('FROM classes c'));
assert.ok(q.sql.includes('school_id IS NULL'), 'filtert op klassen zonder school');
await app.close();
});
test('hernoemen weigert een lege of te lange naam', async () => {
const { app } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,