Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
6 changed files with 76 additions and 73 deletions
Showing only changes of commit c86abcf794 - Show all commits

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 () => {