teach/public/js/parent.js
Ramon 5f9ffbfdde
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 37s
feat: client-kant ouderportaal (v0.3.58-beta)
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 00:14:05 +02:00

233 lines
9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = "";
}