diff --git a/VERSION b/VERSION index 44b1339..b3b574d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.91-beta +0.3.92-beta diff --git a/public/css/teach.css b/public/css/teach.css index 8c11f9b..67caa8e 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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;} diff --git a/public/js/admin.js b/public/js/admin.js index 09bcba5..06f3722 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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,34 +867,50 @@ 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){ - panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); - return; - } + const looseView = currentUser.role==="super" && !selSchool; const canResetPw = can("schools.resetPasswords"); - /* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken), - met de gevaarlijke acties als aparte, bevestigde knoppen */ + if(looseView){ + if(!CLASSES.length){ + panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); + return; + } + 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"); - 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); } - })); + if(!looseView){ + 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); } })); - 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); diff --git a/public/js/core.js b/public/js/core.js index 5ef8b30..e89f1d8 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.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; }; diff --git a/src/api.js b/src/api.js index 0601a4a..4377b40 100644 --- a/src/api.js +++ b/src/api.js @@ -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) diff --git a/test/schools.test.js b/test/schools.test.js index aedf472..407b50a 100644 --- a/test/schools.test.js +++ b/test/schools.test.js @@ -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,