Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
6 changed files with 439 additions and 11 deletions
Showing only changes of commit 5f9ffbfdde - Show all commits

View file

@ -1 +1 @@
0.3.57-beta
0.3.58-beta

View file

@ -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;

View file

@ -30,6 +30,7 @@
<div id="board"></div>
<div id="pupilView"></div>
<div id="parentView"></div>
<button id="fab"></button>
<button id="fabBoardsBtn">🗂</button>
<div id="boardStrip"></div>
@ -108,12 +109,12 @@
<div class="ed-tabs">
<button type="button" class="active" data-tab="login" data-i18n="tabLogin"></button>
<button type="button" data-tab="link" data-i18n="tabLinkCode"></button>
<button type="button" data-tab="parentreg" data-i18n="tabParentReg"></button>
</div>
<div id="loginBox">
<select id="liSchool"></select>
<input type="text" id="liName" maxlength="30" autocomplete="username">
<input type="password" id="liPass" maxlength="64" autocomplete="current-password">
<div id="loginMsg" class="formMsg"></div>
<div class="lrow">
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
</div>
@ -130,6 +131,19 @@
<button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button>
</div>
</div>
<div id="parentRegBox" style="display:none;">
<div class="lisub" data-i18n="parentRegIntro"></div>
<input type="text" id="prName" maxlength="30" autocomplete="username">
<div class="lrow">
<input type="password" id="prPass" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost pwtoggle" type="button" id="btnPrPassToggle" data-i18n="pwShow"></button>
</div>
<input type="password" id="prPass2" maxlength="64" autocomplete="new-password">
<div class="lrow">
<button class="tbtn ghost" id="btnParentReg" data-i18n="parentRegBtn"></button>
</div>
</div>
<div id="loginMsg" class="formMsg"></div>
<div class="guestnote" data-i18n="guestNote"></div>
<button class="tbtn ghost" type="button" id="btnGuestView" style="margin-top:10px;">🎨 <span data-i18n="stView"></span></button>
</div>
@ -268,6 +282,7 @@
<script src="js/widgets/learning.js"></script>
<script src="js/board.js"></script>
<script src="js/pupil.js"></script>
<script src="js/parent.js"></script>
<script src="js/admin.js"></script>
<script src="js/image-catalog.js"></script>
<script src="js/app.js"></script>

View file

@ -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;

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

233
public/js/parent.js Normal file
View file

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