fix: onzichtbare knopteksten, lege klassenlijst en correcte klas-schoolregels (v0.3.93-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s

- Onzichtbare knoppen opgelost: .tbtn houdt standaard witte tekst (wit-op-blauw), maar in het beheer staat hij op een lichte achtergrond -> tekst was wit-op-wit. Nu expliciet donkere tekst; de primaire accentknop houdt bewust witte tekst
- Systeemmanager kreeg standaard "alle scholen" waardoor klassen leeg leken en niet toe te voegen waren; er wordt nu meteen een concrete school gekozen (klassen/gebruikers direct gevuld). Contextbalk laat wisselen; "alle scholen" blijft een bewuste keuze
- Lege school toont nu een duidelijke uitleg dat bestaande klassen bij een andere school kunnen horen, met verwijzing naar de schoolkeuze
- Correcte gegevensregels: classes.school_id is NOT NULL met ON DELETE CASCADE, dus een klas kan niet "los" bestaan (die eerdere aanname is teruggedraaid). Een klas is nu te VERPLAATSEN naar een andere school (PATCH weigert null/ongeldig); een verwijderde school neemt zijn klassen mee (cascade) - dat verklaart verdwenen klassen
- Bij een leerling naar een andere school koppelen blijft de klas behouden als die al bij de doelschool hoort
- Verificatie uitgebreid met een echte kleurcontrast-scan (vangt wit-op-wit) over alle secties + de default-school-flow; alle 94 servertests en 8 Playwright-suites groen

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 20:22:36 +02:00
parent aee1c59fbd
commit c86abcf794
6 changed files with 76 additions and 73 deletions

View file

@ -1 +1 @@
0.3.92-beta
0.3.93-beta

View file

@ -1914,9 +1914,13 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo
}
#settingsModal .am-btn, #settingsModal .am-copy, #settingsModal .tbtn{
border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface);
color:var(--ink); /* .tbtn is standaard wit-op-blauw; hier wit-op-wit dus overschrijven */
font-size:12.5px; font-weight:550; box-shadow:none; padding:0 12px;
}
#settingsModal .am-btn:hover, #settingsModal .tbtn:hover{background:var(--surface-2);}
/* de primaire knop (accent) houdt bewust witte tekst */
#settingsModal .am-btn.am-primary, #settingsModal .tbtn.am-primary{color:#fff;}
#settingsModal .tbtn:disabled{opacity:.5;}
#settingsModal .am-btn.am-danger{background:transparent; border:1px solid var(--line); color:var(--red); font-weight:600;}
#settingsModal .am-btn.am-danger.armed{background:var(--red); border-color:var(--red); color:#fff;}
#settingsModal .am-edit{border:1px solid transparent; border-radius:var(--radius-s); font-size:12.5px; opacity:.75;}

View file

@ -220,7 +220,10 @@
if(force) loadPromise = null;
if(!loadPromise){
loadPromise = (async ()=>{
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
/* 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. */
if(currentUser.role!=="super") selSchool = currentUser.schoolId;
await reload();
})();
}
@ -252,11 +255,17 @@
if(!currentUser) resetResults = null;
loadPromise = null;
selectedUserKey = null;
selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */
});
async function reload(){
try{
if(currentUser.role==="super") SCHOOLS = await api("/schools");
if(currentUser.role==="super"){
SCHOOLS = await api("/schools");
/* eerste bezoek: kies meteen een concrete school i.p.v. het verwarrende
"alle scholen" (klassen en per-school-lijsten zijn dan direct gevuld) */
if(selSchool == null) selSchool = SCHOOLS.length ? String(SCHOOLS[0].id) : "";
}
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
@ -266,7 +275,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=none") : "";
const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
ASSIGNMENTS = {};
if(CLASSES.length){
@ -867,37 +876,35 @@
if(creatableRoles().includes("super")) panel.appendChild(addForm("super"));
}
/* klassen (admin/super binnen een school); de systeemmanager ziet zonder
gekozen school de losse klassen (school verwijderd) om ze terug te koppelen */
/* klassen (admin/super binnen de gekozen school). Een klas hoort altijd bij
een school; de systeemmanager kan een klas naar een andere school
verplaatsen. Zonder gekozen school ("alle scholen") is er geen concrete
school om klassen bij te tonen of aan te maken. */
function renderKlassenPanel(panel){
const looseView = currentUser.role==="super" && !selSchool;
const canResetPw = can("schools.resetPasswords");
if(looseView){
if(!CLASSES.length){
panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
if(currentUser.role==="super" && !selSchool){
panel.appendChild(h("div","guestnote", T("amPickSchoolFirst")));
return;
}
panel.appendChild(h("div","guestnote", T("amLooseClassesHint")));
}
const canResetPw = can("schools.resetPasswords");
const otherSchools = SCHOOLS.filter(sc=>String(sc.id)!==String(selSchool));
CLASSES.forEach(c=>{
const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length;
let edit = null;
if(canResetPw){
const tools = h("div","am-tools");
/* systeemmanager: klas aan een school (terug)koppelen */
if(currentUser.role==="super"){
/* systeemmanager: klas naar een andere school verplaatsen */
if(currentUser.role==="super" && otherSchools.length){
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.appendChild(new Option(T("amMoveToSchool"), ""));
otherSchools.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id)));
ss.addEventListener("change", async ()=>{
try{ await api("/admin/classes/"+c.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); }
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("amFieldSchool", ss));
tools.appendChild(field("amFieldMoveSchool", 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"});
@ -906,7 +913,6 @@
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); }
@ -920,7 +926,11 @@
edit,
}));
});
if(looseView) return;
/* lege school: maak duidelijk dat er (nog) geen klassen zijn en dat
bestaande klassen bij een andere school kunnen horen */
if(!CLASSES.length){
panel.appendChild(h("div","guestnote", T("amNoClassesHere")));
}
if(canResetPw){
const f = h("div","am-add");
const inp = h("input","am-inp");

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.92-beta";
const VERSION = "0.3.93-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -280,7 +280,8 @@ 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.",
amMoveToSchool:"— verplaats naar school… —", amFieldMoveSchool:"Verplaats naar", amPickSchoolFirst:"Kies hierboven eerst een school om de klassen te zien of aan te maken.",
amNoClassesHere:"Deze school heeft nog geen klassen. Bestaande klassen kunnen bij een andere school horen — kies die school hierboven, of maak hieronder een nieuwe klas.",
amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.",
amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw",
amParents:"Ouders",
@ -576,7 +577,8 @@ 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.",
amMoveToSchool:"— move to school… —", amFieldMoveSchool:"Move to", amPickSchoolFirst:"First pick a school above to see or create its classes.",
amNoClassesHere:"This school has no classes yet. Existing classes may belong to another school — pick that school above, or create a new class below.",
amDetailEmpty:"Select a user on the left to edit, or create a new one.",
amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new",
amParents:"Parents",

View file

@ -338,30 +338,26 @@ 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 ${loose ? 'c.school_id IS NULL' : 'c.school_id = $1'} GROUP BY c.id ORDER BY c.name`,
loose ? [] : [schoolId]);
WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [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
// een klas naar een ANDERE school verplaatsen - alleen systeemmanager.
// Een klas hoort altijd bij een school (classes.school_id is NOT NULL);
// losmaken kan dus niet - er moet een geldige doelschool 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 raw = req.body?.schoolId;
if (raw == null) return fail(reply, 400, 'school verplicht'); // een klas hoort altijd bij een school
const target = Number(raw);
if (!Number.isInteger(target) || target <= 0) return fail(reply, 400, 'ongeldige school');
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 };
});

View file

@ -172,34 +172,25 @@ test('school koppelen meldt een naamconflict netjes als 409', async () => {
await app.close();
});
test('systeemmanager koppelt een losse klas terug aan een school', async () => {
test('systeemmanager verplaatst een klas naar een andere school; losmaken kan niet', 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 } });
const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
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]);
assert.deepEqual(upd.params, [3, 100]);
// een klas hoort altijd bij een school: geen/null wordt geweigerd
const { app: app2 } = await makeAppWithSchool(superUser, null);
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: null } });
assert.equal(res2.statusCode, 400);
// 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();
const { app: app3, calls: calls3 } = await makeAppWithSchool(admin, null);
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
assert.equal(res3.statusCode, 403);
assert.ok(!calls3.some((c) => c.sql.startsWith('UPDATE classes SET school_id')));
await app.close(); await app2.close(); await app3.close();
});
test('hernoemen weigert een lege of te lange naam', async () => {