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 @@
+
+
+
@@ -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 = "";
+}