From 5f9ffbfdde177a9add7ba4e172e05d60e06710f8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 00:14:05 +0200 Subject: [PATCH] feat: client-kant ouderportaal (v0.3.58-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derde tab "Ouders" in het inlogscherm: ouderaccount registreren (naam + 2× wachtwoord) met directe login - Nieuw ouderportaal (parent.js): kinderenlijst met school/klas of "thuis", kind toevoegen (wachtwoord eenmalig als badge bij de rij), koppelen via ouder-code, aanmelden bij een school via doorzoekbare lijst met aanmeldstatus op de rij, voortgang per kind uitklapbaar, ontkoppelen met bevestiging - Beheer: blok "Aanmeldingen (n)" bovenaan de Gebruikers-tab met per verzoek adopteren (klas + optionele andere gebruikersnaam bij naamconflict), koppelen aan een bestaande leerling, of afwijzen - Beheer: actie "👪 Ouder-code" op elke leerlingrij (eenmalige code als badge); groep "Ouders" in de gebruikerslijst - Foutmeldingen in het inlogscherm nu zichtbaar op alle drie de tabs - i18n nl/en; volledige ouder- en schoolflow geverifieerd in headless Chromium (21 checks) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/css/teach.css | 17 ++++ public/index.html | 17 +++- public/js/admin.js | 93 ++++++++++++++++- public/js/core.js | 88 +++++++++++++++- public/js/parent.js | 233 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+), 11 deletions(-) create mode 100644 public/js/parent.js diff --git a/VERSION b/VERSION index da7d5af..ee0c844 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.57-beta +0.3.58-beta diff --git a/public/css/teach.css b/public/css/teach.css index 129cd7c..ee0616d 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -152,6 +152,23 @@ box-shadow:0 2px 8px rgba(30,50,90,.15); } .pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} + /* ---------- Ouderportaal ---------- */ + /* ouders zien geen whiteboard: alleen het portaal met hun kinderen */ + #parentView{display:none;} + body.parent-mode #board,body.parent-mode #fab,body.parent-mode #fabBoardsBtn, + body.parent-mode #boardStrip,body.parent-mode #fabmenu, + body.parent-mode #hud,body.parent-mode #btnFolders,body.parent-mode #btnSave{display:none !important;} + body.parent-mode #parentView{ + display:block; position:absolute; inset:var(--tbar) 0 0 0; + overflow:auto; padding:24px 18px; box-sizing:border-box; + } + .parent-inner{ + max-width:720px; margin:0 auto; display:flex; flex-direction:column; gap:10px; + } + .parent-title{margin:0 0 4px; font-size:22px;} + .parent-inner .am-row{flex-wrap:wrap;} + .parent-inner .am-row-tools{flex-basis:100%; display:flex; flex-direction:column; gap:8px;} + .parent-inner .am-row-tools:empty{display:none;} /* floating add-button (FAB) */ #fab{ position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px; diff --git a/public/index.html b/public/index.html index 099f97e..d68996d 100644 --- a/public/index.html +++ b/public/index.html @@ -30,6 +30,7 @@
+
@@ -108,12 +109,12 @@
+
-
@@ -130,6 +131,19 @@
+ +
@@ -268,6 +282,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index b46427b..bd99575 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -11,6 +11,8 @@ /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ let ASSIGNMENTS = {}; + /* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */ + let LINK_REQUESTS = []; /* resultaat van een laatste multireset (klas/school): blijft zichtbaar totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet meteen weer verdwijnen na een reload() */ @@ -175,7 +177,12 @@ const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); } - }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; } + LINK_REQUESTS = []; + if(currentUser.role!=="super" || selSchool){ + const lq = currentUser.role==="super" ? "?school="+selSchool : ""; + LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || []; + } + }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; } render(); } @@ -290,6 +297,16 @@ }catch(e){ msg(e.message); } })); } + /* eenmalige ouder-code waarmee een ouder dit kind aan het eigen + ouderaccount koppelt (ouderportaal); een eerdere code vervalt */ + actions.appendChild(dangerBtn("👪 " + T("amParentCode"), "amParentCodeWarn", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); + rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); + openRows.add(u.id); + reload(); + }catch(e){ msg(e.message); } + })); }else{ if(u.pending && u.linkCode){ tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); @@ -811,7 +828,9 @@ wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */ function applyUserFilter(panel){ const q = userFilter.trim().toLowerCase(); - const list = panel.querySelector(".am-list"); + /* expliciet de gebruikerslijst: het Aanmeldingen-blok erboven is ook een + .am-list maar valt buiten het zoekfilter en de groepslogica */ + const list = panel.querySelector(".am-userlist") || panel.querySelector(".am-list"); if(!list) return; let curGroup = null, groupVisible = false; const closeGroup = ()=>{ @@ -842,7 +861,73 @@ Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst (die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */ + /* aanmeldverzoek van een ouder (thuiskind -> deze school): de school kiest + adopteren (thuisaccount wordt de schoolleerling, evt. met klas en - bij + naamconflict - een andere gebruikersnaam), koppelen aan een bestaande + leerling, of afwijzen */ + function linkRequestRow(r){ + const row = h("div","am-row"); + row.appendChild(h("span","am-name","🧒 " + r.pupil.displayName + + (r.pupil.displayName!==r.pupil.username ? ` (${r.pupil.username})` : ""))); + row.appendChild(h("span","am-role", `${T("amRequestVia")} 👪 ${r.parent.displayName}`)); + const tools = h("div","am-row-tools"); + + const adoptWrap = h("div","am-inline"); + const cs = classSel(null); + const un = h("input","am-inp"); + un.placeholder = T("amAdoptUserPh"); + un.style.display = "none"; + un.addEventListener("keydown", ev=>ev.stopPropagation()); + const adopt = h("button","am-btn", T("amAdopt")); + adopt.type = "button"; + adopt.addEventListener("click", async ()=>{ + const body = { action:"adopt", classId: cs.value ? +cs.value : null }; + if(un.style.display!=="none" && un.value.trim()) body.username = un.value.trim(); + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body }); reload(); } + catch(e){ + /* naamconflict binnen de school: veld tonen om direct een andere + gebruikersnaam mee te geven */ + if(e.status===409){ un.style.display = ""; un.focus(); } + msg(e.message); + } + }); + adoptWrap.appendChild(cs); + adoptWrap.appendChild(un); + adoptWrap.appendChild(adopt); + tools.appendChild(field("amAdopt", adoptWrap)); + + const matchWrap = h("div","am-inline"); + const ms = h("select","am-sel"); + fillPupilSelect(ms, USERS.filter(u=>u.role==="pupil")); + const mb = h("button","am-btn", T("amMatchBtn")); + mb.type = "button"; + mb.addEventListener("click", async ()=>{ + if(!ms.value) return; + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"match", pupilId: +ms.value } }); reload(); } + catch(e){ msg(e.message); } + }); + matchWrap.appendChild(ms); + matchWrap.appendChild(mb); + tools.appendChild(field("amMatchBtn", matchWrap)); + + const actions = h("div","am-actions"); + actions.appendChild(dangerBtn("🗑 " + T("amReject"), "amRejectWarn", async ()=>{ + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"reject" } }); reload(); } + catch(e){ msg(e.message); } + })); + tools.appendChild(field("amFieldActions", actions)); + row.appendChild(tools); + return row; + } + function renderGebruikersPanel(panel){ + /* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */ + if(LINK_REQUESTS.length){ + panel.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`)); + const rl = h("div","am-reqlist am-list"); + LINK_REQUESTS.forEach(r=>rl.appendChild(linkRequestRow(r))); + panel.appendChild(rl); + } const filterBar = h("div","am-add"); const filterInp = h("input","am-inp am-filter"); filterInp.type = "search"; @@ -853,8 +938,8 @@ filterBar.appendChild(filterInp); panel.appendChild(filterBar); - const list = h("div","am-list"); - const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]]; + const list = h("div","am-list am-userlist"); + const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]]; staffGroups.forEach(([role, label])=>{ const us = USERS.filter(u=>u.role===role); if(!us.length) return; diff --git a/public/js/core.js b/public/js/core.js index 21babf9..1b7cb61 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.57-beta"; +const VERSION = "0.3.58-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -204,7 +204,22 @@ const I18N = { errPwMatch:"Wachtwoorden komen niet overeen.", codePh:"koppelcode", roleSuper:"Systeemmanager", roleAdmin:"Schoolbeheerder", - roleTeacher:"Groepsleiding", rolePupil:"Leerling", + roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleParent:"Ouder", + tabParentReg:"Ouders", + parentRegIntro:"Maak een ouderaccount en volg je kind - ook als de school (nog) niet meedoet:", + parentRegBtn:"Account maken", + parentTitle:"Mijn kinderen", + parentNoChildren:"Nog geen kinderen gekoppeld.", + parentAddChild:"+ Kind", parentChildNamePh:"naam van je kind…", parentAddBtn:"Toevoegen", + parentPwNote:"Bewaar dit wachtwoord: je kind logt ermee in (schoolveld leeg laten).", + parentLinkTitle:"+ Ouder-code", parentLinkPh:"ouder-code van school…", parentLinkBtn:"Koppel", + parentLinkNote:"Vraag de school om een ouder-code voor je kind.", + parentHome:"thuis", + parentApply:"🏫 Aanmelden", parentApplied:"aangemeld bij", parentApplyBtn:"Meld aan", + parentSchoolPh:"zoek je school…", + parentUnlink:"Ontkoppel", + parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.", + parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.", pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilWatching:"meekijken", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", @@ -219,6 +234,13 @@ const I18N = { amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", + amParents:"Ouders", + amParentCode:"Ouder-code", amResultParentCode:"Ouder-code", + amParentCodeWarn:"Maakt een eenmalige ouder-code aan; een eerdere code vervalt.", + amLinkRequests:"Aanmeldingen", amRequestVia:"via", + amAdopt:"Adopteer", amAdoptUserPh:"andere gebruikersnaam (bij conflict)…", + amMatchPick:"— koppel aan bestaande leerling… —", amMatchBtn:"Koppel", + amReject:"Afwijzen", amRejectWarn:"Wijst de aanmelding af; de ouder ziet het verzoek verdwijnen.", amSchoolNamePh:"naam van de school…", amNewSchoolConfirm:"Aanmaken:", amDeleteSchoolWarn:"Verwijdert deze school met alle klassen en gebruikers. Dit kan niet ongedaan worden gemaakt.", amClassNamePh:"naam van de klas…", @@ -429,7 +451,22 @@ const I18N = { errPwMatch:"Passwords don't match.", codePh:"link code", roleSuper:"System manager", roleAdmin:"School administrator", - roleTeacher:"Group leader", rolePupil:"Pupil", + roleTeacher:"Group leader", rolePupil:"Pupil", roleParent:"Parent", + tabParentReg:"Parents", + parentRegIntro:"Create a parent account and follow your child - even if the school doesn't take part (yet):", + parentRegBtn:"Create account", + parentTitle:"My children", + parentNoChildren:"No children linked yet.", + parentAddChild:"+ Child", parentChildNamePh:"your child's name…", parentAddBtn:"Add", + parentPwNote:"Keep this password: your child logs in with it (leave the school field empty).", + parentLinkTitle:"+ Parent code", parentLinkPh:"parent code from school…", parentLinkBtn:"Link", + parentLinkNote:"Ask the school for a parent code for your child.", + parentHome:"home", + parentApply:"🏫 Apply", parentApplied:"applied to", parentApplyBtn:"Apply", + parentSchoolPh:"search your school…", + parentUnlink:"Unlink", + parentUnlinkWarn:"Removes only the link; your child's account remains.", + parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.", pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilWatching:"watching", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", @@ -444,6 +481,13 @@ const I18N = { amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", amDeleteWarn:"Deletes this account permanently, including boards and words.", amResultPw:"New password", amResultCode:"New link code", + amParents:"Parents", + amParentCode:"Parent code", amResultParentCode:"Parent code", + amParentCodeWarn:"Generates a one-time parent code; a previous code stops working.", + amLinkRequests:"Enrolments", amRequestVia:"via", + amAdopt:"Adopt", amAdoptUserPh:"different username (on conflict)…", + amMatchPick:"— link to existing pupil… —", amMatchBtn:"Link", + amReject:"Reject", amRejectWarn:"Rejects the enrolment; the request disappears for the parent.", amSchoolNamePh:"name of the school…", amNewSchoolConfirm:"Create:", amDeleteSchoolWarn:"Deletes this school with all its classes and users. This cannot be undone.", amClassNamePh:"name of the class…", @@ -494,6 +538,9 @@ function applyI18n(){ document.getElementById("liCode").placeholder = T("codePh"); document.getElementById("liPassNew").placeholder = T("pwNewPh"); document.getElementById("liPassNew2").placeholder = T("pwConfirmPh"); + document.getElementById("prName").placeholder = T("namePh"); + document.getElementById("prPass").placeholder = T("pwNewPh"); + document.getElementById("prPass2").placeholder = T("pwConfirmPh"); document.getElementById("ownPwCurrent").placeholder = T("pwCurrentPh"); document.getElementById("ownPw").placeholder = T("pwNewPh"); document.getElementById("ownPw2").placeholder = T("pwConfirmPh"); @@ -652,11 +699,22 @@ async function hydrateFromServer(){ if(currentUser && currentUser.role === "pupil"){ THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; + stopParentView(); startPupilView(); document.dispatchEvent(new CustomEvent("userchange")); return; } + /* ouders krijgen het portaal (parent.js) i.p.v. het whiteboard */ + if(currentUser && currentUser.role === "parent"){ + THEMES = {nl:[],en:[]}; + GENERAL_EXTRA = {nl:[],en:[]}; + stopPupilView(); + startParentView(); + document.dispatchEvent(new CustomEvent("userchange")); + return; + } stopPupilView(); + stopParentView(); const res = await api("/me/data"); const d = res.data || {}; DATA_REV = typeof res.rev === "number" ? res.rev : 0; @@ -671,6 +729,7 @@ async function hydrateFromServer(){ function resetToGuest(){ currentUser = null; stopPupilView(); + stopParentView(); THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; ANCHOR_LIB = []; @@ -686,7 +745,7 @@ const loginWrap = document.getElementById("loginWrap"); const loginMsg = document.getElementById("loginMsg"); const settingsWrap = document.getElementById("settingsWrap"); const settingsMsg = document.getElementById("settingsMsg"); -const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil"; +const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : r==="parent" ? "roleParent" : "rolePupil"; function updateUserUI(){ document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("btnUser").classList.toggle("on", !!currentUser); @@ -703,11 +762,12 @@ function updateUserUI(){ document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none"; } } -/* login-modal tabs: "inloggen" vs "koppelcode activeren" */ +/* login-modal tabs: "inloggen" / "koppelcode activeren" / "ouderaccount maken" */ function switchLoginTab(tab){ document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab)); document.getElementById("loginBox").style.display = tab==="login" ? "block" : "none"; document.getElementById("linkBox").style.display = tab==="link" ? "block" : "none"; + document.getElementById("parentRegBox").style.display = tab==="parentreg" ? "block" : "none"; } document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{ b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab)); @@ -851,6 +911,24 @@ function pwToggle(btnId, ...fieldIds){ } pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2"); pwToggle("btnOwnPwToggle", "ownPwCurrent", "ownPw", "ownPw2"); +pwToggle("btnPrPassToggle", "prPass", "prPass2"); + +/* ouderaccount registreren: direct ingelogd, parent.js toont daarna het portaal */ +document.getElementById("btnParentReg").addEventListener("click", async ()=>{ + const btn = document.getElementById("btnParentReg"); + const username = document.getElementById("prName").value.trim(); + const password = document.getElementById("prPass").value; + const password2 = document.getElementById("prPass2").value; + loginMsg.classList.remove("ok"); + if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; } + await withBusy(btn, async ()=>{ + try{ + await afterLogin(await api("/auth/register-parent", { body:{ username, password } })); + document.getElementById("prPass").value = ""; + document.getElementById("prPass2").value = ""; + }catch(e){ loginMsg.textContent = e.message; } + }); +}); /* account activeren met een koppelcode */ document.getElementById("btnLink").addEventListener("click", async ()=>{ diff --git a/public/js/parent.js b/public/js/parent.js new file mode 100644 index 0000000..04ff852 --- /dev/null +++ b/public/js/parent.js @@ -0,0 +1,233 @@ +/* teach - ouderportaal: ouders (role parent) zien geen whiteboard maar een + portaal met hun gekoppelde kinderen. Per kind: school/klas (of "thuis"), + voortgangstijdlijn, en voor thuiskinderen een aanmelding bij een school + (doorzoekbare lijst; de school handelt het verzoek af in het beheer). + Kinderen komen erbij door ze zelf aan te maken (thuiskind, wachtwoord + eenmalig zichtbaar) of via een eenmalige ouder-code van de school. */ +"use strict"; +const parentView = document.getElementById("parentView"); +/* opengeklapte voortgang/aanmeldformulieren en eenmalige resultaten + (kind-wachtwoord) blijven behouden over her-renders van het portaal */ +const parentOpen = new Set(); +const parentResults = new Map(); +let PARENT_SCHOOLS = []; + +function prH(tag, cls, text){ + const el = document.createElement(tag); + if(cls) el.className = cls; + if(text != null) el.textContent = text; + return el; +} +/* arm-then-confirm zoals dangerBtn in het beheer: eerste klik wapent ("zeker?"), + tweede klik voert uit, na 4s ontwapent hij vanzelf */ +function prDangerBtn(label, warnKey, fn){ + const b = prH("button","am-btn am-danger", label); + b.type = "button"; + if(warnKey) b.title = T(warnKey); + let timer = null; + b.addEventListener("click", async ()=>{ + if(!timer){ + b.classList.add("armed"); + b.textContent = "⚠ " + T("amSure"); + timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); + return; + } + clearTimeout(timer); timer = null; + b.classList.remove("armed"); b.textContent = label; + await fn(b); + }); + return b; +} +function prMsg(t){ + const m = parentView.querySelector(".am-msg"); + if(m) m.textContent = t || ""; +} + +async function parentReload(){ + let children = []; + try{ children = (await api("/parent/children")).children || []; } + catch(e){ /* render toont dan een lege lijst; melding via prMsg hieronder */ } + renderParentView(children); +} + +function parentChildRow(c){ + const row = prH("div","am-row"); + row.appendChild(prH("span","am-name","🧒 " + c.displayName + (c.displayName!==c.username ? ` (${c.username})` : ""))); + const where = c.schoolName + ? c.schoolName + (c.className ? ` · ${c.className}` : "") + : T("parentHome") + (c.appliedTo ? ` · ${T("parentApplied")} ${c.appliedTo}` : ""); + row.appendChild(prH("span","am-role", where)); + + const tools = prH("div","am-row-tools"); + const r = parentResults.get(c.id); + if(r){ + const res = prH("div","am-result"); + res.appendChild(prH("span","am-result-lbl", r.label + ": ")); + const code = prH("code", null, r.value); + res.appendChild(code); + res.appendChild(prH("div","guestnote", T("parentPwNote"))); + tools.appendChild(res); + } + + /* voortgangstijdlijn (zelfde regelopbouw als het beheer: widget, score, tijd) */ + const progHolder = prH("div","am-list"); + async function loadProgress(){ + progHolder.innerHTML = ""; + let events = []; + try{ events = (await api("/progress/pupil/"+c.id)).events || []; } + catch(e){ progHolder.appendChild(prH("div","guestnote", e.message)); return; } + if(!events.length){ progHolder.appendChild(prH("div","guestnote", T("parentProgressEmpty"))); return; } + events.forEach(ev=>{ + const line = prH("div","am-row"); + line.appendChild(prH("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType)); + const scoreParts = []; + if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`); + if(ev.stars) scoreParts.push("★".repeat(ev.stars)); + line.appendChild(prH("span","am-code", scoreParts.join(" · "))); + line.appendChild(prH("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); + progHolder.appendChild(line); + }); + } + const progBtn = prH("button","am-btn", T("parentProgress")); + progBtn.type = "button"; + progBtn.addEventListener("click", ()=>{ + const key = "prog:"+c.id; + if(parentOpen.has(key)){ parentOpen.delete(key); progHolder.innerHTML = ""; } + else{ parentOpen.add(key); loadProgress(); } + }); + const actions = prH("div","am-actions"); + actions.appendChild(progBtn); + + /* thuiskind zonder lopende aanmelding: aanmelden bij een school via een + doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */ + const applyHolder = prH("div","am-add"); + applyHolder.style.display = "none"; + if(!c.schoolId && !c.appliedTo){ + const applyBtn = prH("button","am-btn", T("parentApply")); + applyBtn.type = "button"; + const inp = prH("input","am-inp"); + inp.placeholder = T("parentSchoolPh"); + inp.setAttribute("list","parentSchoolList"); + const go = prH("button","am-btn", T("parentApplyBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + const s = PARENT_SCHOOLS.find(x=>x.name.toLowerCase()===inp.value.trim().toLowerCase()); + if(!s){ prMsg(T("parentSchoolPh")); return; } + try{ await api(`/parent/children/${c.id}/apply`, { body:{ schoolId: s.id } }); parentOpen.delete("apply:"+c.id); parentReload(); } + catch(e){ prMsg(e.message); } + }); + applyHolder.appendChild(inp); + applyHolder.appendChild(go); + applyBtn.addEventListener("click", ()=>{ + const key = "apply:"+c.id; + if(parentOpen.has(key)) parentOpen.delete(key); else parentOpen.add(key); + applyHolder.style.display = parentOpen.has(key) ? "" : "none"; + if(parentOpen.has(key)) inp.focus(); + }); + applyHolder.style.display = parentOpen.has("apply:"+c.id) ? "" : "none"; + actions.appendChild(applyBtn); + } + + actions.appendChild(prDangerBtn(T("parentUnlink"), "parentUnlinkWarn", async ()=>{ + try{ await api("/parent/children/"+c.id, { method:"DELETE" }); parentResults.delete(c.id); parentReload(); } + catch(e){ prMsg(e.message); } + })); + tools.appendChild(actions); + tools.appendChild(applyHolder); + tools.appendChild(progHolder); + row.appendChild(tools); + if(parentOpen.has("prog:"+c.id)) loadProgress(); + return row; +} + +/* ingeklapt formulier ("+ …"-knop) zoals addForm in het beheer */ +function parentCollapsedForm(label, build){ + const wrap = prH("div","am-add-wrap"); + const toggle = prH("button","tbtn ghost", label); + toggle.type = "button"; + const f = prH("div","am-add"); + f.style.display = "none"; + toggle.addEventListener("click", ()=>{ + f.style.display = f.style.display==="none" ? "" : "none"; + const first = f.querySelector("input"); + if(f.style.display!=="none" && first) first.focus(); + }); + build(f); + wrap.appendChild(toggle); + wrap.appendChild(f); + return wrap; +} + +function renderParentView(children){ + parentView.innerHTML = ""; + const inner = prH("div","parent-inner"); + inner.appendChild(prH("h2","parent-title", T("parentTitle"))); + inner.appendChild(prH("div","am-msg")); + + const list = prH("div","am-list"); + if(!children.length) list.appendChild(prH("div","guestnote", T("parentNoChildren"))); + children.forEach(c=>list.appendChild(parentChildRow(c))); + inner.appendChild(list); + + /* datalist voor de doorzoekbare schoolkeuze (gedeeld door alle rijen) */ + const dl = document.createElement("datalist"); + dl.id = "parentSchoolList"; + PARENT_SCHOOLS.forEach(s=>dl.appendChild(new Option(s.name))); + inner.appendChild(dl); + + /* kind aanmaken (thuiskind): naam -> account zonder school + wachtwoord */ + inner.appendChild(parentCollapsedForm(T("parentAddChild"), f=>{ + const inp = prH("input","am-inp"); + inp.placeholder = T("parentChildNamePh"); + inp.maxLength = 30; + const go = prH("button","am-btn", T("parentAddBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + try{ + const r = await api("/parent/children", { body:{ name: inp.value.trim() } }); + parentResults.set(r.child.id, { label: T("amFieldPw"), value: r.child.password }); + parentReload(); + }catch(e){ prMsg(e.message); } + }); + inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); }); + f.appendChild(inp); + f.appendChild(go); + })); + + /* schoolkind koppelen met de eenmalige ouder-code van de school */ + inner.appendChild(parentCollapsedForm(T("parentLinkTitle"), f=>{ + f.appendChild(prH("div","guestnote", T("parentLinkNote"))); + const inp = prH("input","am-inp"); + inp.placeholder = T("parentLinkPh"); + inp.maxLength = 9; + inp.style.textTransform = "uppercase"; + const go = prH("button","am-btn", T("parentLinkBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + try{ + await api("/parent/children/link", { body:{ code: inp.value.trim().toUpperCase() } }); + inp.value = ""; + parentReload(); + }catch(e){ prMsg(e.message); } + }); + inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); }); + f.appendChild(inp); + f.appendChild(go); + })); + + parentView.appendChild(inner); +} + +function startParentView(){ + document.body.classList.add("parent-mode"); + parentOpen.clear(); + parentResults.clear(); + (async ()=>{ + try{ PARENT_SCHOOLS = await api("/schools"); }catch(e){ PARENT_SCHOOLS = []; } + parentReload(); + })(); +} +function stopParentView(){ + document.body.classList.remove("parent-mode"); + parentView.innerHTML = ""; +}