Verbeter vak toevoegen en groepsindeling UI

This commit is contained in:
bes-r 2026-06-15 22:33:40 +02:00
parent 18043bc147
commit fa50438d35
5 changed files with 219 additions and 93 deletions

View file

@ -32,7 +32,7 @@ class CoreConfig(AppConfig):
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12),
# Submenu Organisatie: wie hoort waar.
MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"),
MenuItem("Structuur", "/structuur", icon="layers", order=24, group="Organisatie"),
MenuItem("Groepsindeling", "/structuur", icon="layers", order=24, group="Organisatie"),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Organisatie"),
),
)

View file

@ -5,7 +5,7 @@ class LeerpleinConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.leerplein"
label = "module_leerplein"
verbose_name = "Module: Leerplein (groepsstructuur)"
verbose_name = "Module: Bovenliggende groepen"
def ready(self):
from plugins.registry import ModuleSpec, registry
@ -13,13 +13,13 @@ class LeerpleinConfig(AppConfig):
registry.register(
ModuleSpec(
key="leerplein",
name="Leerplein (groepsstructuur)",
name="Bovenliggende groepen",
description=(
"Koppel subgroepen aan groepen en bundel groepen, subgroepen "
"en losse leerlingen onder een overkoepelend leerplein."
"en losse leerlingen onder een bovenliggende groep."
),
version="0.1.0",
category="Structuur",
category="Groepsindeling",
core=False,
default_enabled=False,
menu_items=(),

View file

@ -15,7 +15,7 @@ const ICONS = {
};
const STRUCTUUR_PADEN = new Set(["/structuur", "/leerplein", "/groepen", "/subgroepen"]);
const STRUCTUUR_ITEM = { label: "Structuur", path: "/structuur", icon: "layers", order: 24, group: "Organisatie" };
const STRUCTUUR_ITEM = { label: "Groepsindeling", path: "/structuur", icon: "layers", order: 24, group: "Organisatie" };
const normaliseerPad = (path) => (STRUCTUUR_PADEN.has(path) ? "/structuur" : path);
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een

View file

@ -16,6 +16,9 @@ const ROLLEN = [
// Korte aanduiding van een medewerkersrol (compact in de roostervakjes).
const ROL_AFK = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" };
const BOVENGROEP_LABEL = "Bovenliggende groep";
const BOVENGROEP_LABEL_LC = "bovenliggende groep";
const BOVENGROEP_LABEL_MV = "Bovenliggende groepen";
const WEEKDAGEN = [
{ value: 0, label: "Maandag" },
@ -381,7 +384,7 @@ export function PersonenPage() {
</div>
{leerpleinen.length > 0 && (
<div className="leden-keuze">
<div className="muted small">Leerpleinen:</div>
<div className="muted small">{BOVENGROEP_LABEL_MV}:</div>
<div className="chips">
{leerpleinen.map((lp) => <button type="button" key={lp.id} className={form.leerpleinen.includes(lp.id) ? "chip on" : "chip"} onClick={() => toggle("leerpleinen", lp.id)}>{lp.naam}</button>)}
</div>
@ -698,8 +701,8 @@ export function AfwezigheidPage() {
);
}
/* -------------------------------- Leerplein ------------------------------- */
// Gezamenlijk structuurbeheer: leerplein -> groep -> subgroep.
/* ----------------------------- Groepsindeling ----------------------------- */
// Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep.
export function LeerpleinPage() {
const [leerpleinen, setLeerpleinen] = useState([]);
const [groepen, setGroepen] = useState([]);
@ -745,7 +748,6 @@ export function LeerpleinPage() {
const toggleArray = (lijst, id) => lijst.includes(id) ? lijst.filter((x) => x !== id) : [...lijst, id];
const groepNaam = (id) => groepen.find((g) => g.id === id)?.naam || "—";
const leerpleinNaam = (id) => leerpleinen.find((lp) => lp.id === id)?.naam || "—";
const leerpleinVanGroep = (groepId) => leerpleinen.find((lp) => (lp.groepen || []).includes(Number(groepId)));
const leerpleinVanSubgroep = (subgroepId) => leerpleinen.find((lp) => (lp.subgroepen || []).includes(Number(subgroepId)));
const leerlingenVanGroep = (groepId) => leerlingen.filter((l) => l.groep === Number(groepId));
@ -820,7 +822,7 @@ export function LeerpleinPage() {
}
async function opslaanGroep(e) {
e.preventDefault();
if (!groepForm.leerplein) { setError("Kies een bovenliggend leerplein voor deze groep."); return; }
if (!groepForm.leerplein) { setError(`Kies een ${BOVENGROEP_LABEL_LC} voor deze groep.`); return; }
try {
const payload = { naam: groepForm.naam, omschrijving: groepForm.omschrijving };
const opgeslagen = editGroepId ? await updateResource("groepen", editGroepId, payload) : await createResource("groepen", payload);
@ -834,7 +836,7 @@ export function LeerpleinPage() {
async function opslaanSubgroep(e) {
e.preventDefault();
if (!subgroepForm.groep) { setError("Kies een groep waar deze subgroep onder valt."); return; }
if (subgroepForm.ledenBron === "leerplein" && !subgroepForm.leerplein) { setError("Kies een leerplein voor leden uit het hele leerplein."); return; }
if (subgroepForm.ledenBron === "leerplein" && !subgroepForm.leerplein) { setError(`Kies een ${BOVENGROEP_LABEL_LC} voor leden uit de volledige ${BOVENGROEP_LABEL_LC}.`); return; }
try {
const payload = {
naam: subgroepForm.naam,
@ -852,8 +854,8 @@ export function LeerpleinPage() {
}
async function verwijderLeerplein(id) {
const lp = leerpleinen.find((x) => x.id === id);
if ((lp?.groepen || []).length > 0) { setError("Verplaats of verwijder eerst de groepen onder dit leerplein."); return; }
if (!confirm("Dit leerplein verwijderen?")) return;
if ((lp?.groepen || []).length > 0) { setError(`Verplaats of verwijder eerst de groepen onder deze ${BOVENGROEP_LABEL_LC}.`); return; }
if (!confirm(`Deze ${BOVENGROEP_LABEL_LC} verwijderen?`)) return;
try { await deleteResource("leerpleinen", id); await laadAlles(); } catch (err) { setError(err.message); }
}
async function verwijderGroep(id) {
@ -885,18 +887,18 @@ export function LeerpleinPage() {
return (
<section>
<h1>Groepenstructuur</h1>
<p className="muted">Beheer leerpleinen, groepen en subgroepen op één plek. Een groep hoort altijd onder een leerplein; een subgroep hoort altijd onder een groep.</p>
<h1>Groepsindeling</h1>
<p className="muted">Beheer bovenliggende groepen, groepen en subgroepen op één plek. Een groep hoort altijd onder een bovenliggende groep; een subgroep hoort altijd onder een groep.</p>
<Foutmelding msg={error} />
<form className="form-card structuur-form" onSubmit={opslaanLeerplein}>
<h2>Leerplein</h2>
<h2>{BOVENGROEP_LABEL}</h2>
<div className="form-row">
<input placeholder="Naam leerplein" value={leerpleinForm.naam} required onChange={(e) => setLeerpleinForm({ ...leerpleinForm, naam: e.target.value })} />
<input placeholder={`Naam ${BOVENGROEP_LABEL_LC}`} value={leerpleinForm.naam} required onChange={(e) => setLeerpleinForm({ ...leerpleinForm, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={leerpleinForm.omschrijving} onChange={(e) => setLeerpleinForm({ ...leerpleinForm, omschrijving: e.target.value })} />
</div>
<div className="leden-keuze">
<div className="muted small">Losse leerlingen op leerpleinniveau:</div>
<div className="muted small">Losse leerlingen op niveau van de {BOVENGROEP_LABEL_LC}:</div>
<Vinkjes items={leerlingen} geselecteerd={leerpleinForm.leerlingen} toggle={(id) => setLeerpleinForm((f) => ({ ...f, leerlingen: toggleArray(f.leerlingen, id) }))} />
</div>
<div className="leden-keuze">
@ -904,7 +906,7 @@ export function LeerpleinPage() {
<Vinkjes items={medewerkers} geselecteerd={leerpleinForm.medewerkers} toggle={(id) => setLeerpleinForm((f) => ({ ...f, medewerkers: toggleArray(f.medewerkers, id) }))} leeg="Geen medewerkers gevonden." />
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editLeerpleinId ? "Leerplein opslaan" : "Leerplein toevoegen"}</button>
<button className="btn primary" type="submit">{editLeerpleinId ? `${BOVENGROEP_LABEL} opslaan` : `${BOVENGROEP_LABEL} toevoegen`}</button>
{editLeerpleinId && <button type="button" className="btn" onClick={() => { setEditLeerpleinId(null); setLeerpleinForm(leegLeerplein); }}>Annuleren</button>}
</div>
</form>
@ -913,7 +915,7 @@ export function LeerpleinPage() {
<h2>Groep</h2>
<div className="form-row">
<select value={groepForm.leerplein} required onChange={(e) => setGroepForm({ ...groepForm, leerplein: e.target.value })}>
<option value=""> bovenliggend leerplein </option>
<option value=""> {BOVENGROEP_LABEL_LC} </option>
{leerpleinen.map((lp) => <option key={lp.id} value={lp.id}>{lp.naam}</option>)}
</select>
<input placeholder="Naam groep" value={groepForm.naam} required onChange={(e) => setGroepForm({ ...groepForm, naam: e.target.value })} />
@ -944,10 +946,10 @@ export function LeerpleinPage() {
setSubgroepForm({ ...subgroepForm, groep: groepId, leerplein: subgroepForm.leerplein || lp?.id || "" });
}}>
<option value=""> groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam} ({leerpleinVanGroep(g.id)?.naam || "geen leerplein"})</option>)}
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam} ({leerpleinVanGroep(g.id)?.naam || `geen ${BOVENGROEP_LABEL_LC}`})</option>)}
</select>
<select value={subgroepForm.leerplein} onChange={(e) => setSubgroepForm({ ...subgroepForm, leerplein: e.target.value })}>
<option value=""> koppel ook aan leerplein </option>
<option value=""> koppel ook aan {BOVENGROEP_LABEL_LC} </option>
{leerpleinen.map((lp) => <option key={lp.id} value={lp.id}>{lp.naam}</option>)}
</select>
<input placeholder="Naam subgroep" value={subgroepForm.naam} required onChange={(e) => setSubgroepForm({ ...subgroepForm, naam: e.target.value })} />
@ -955,7 +957,7 @@ export function LeerpleinPage() {
</div>
<div className="form-row">
<label className="veld"><input type="radio" checked={subgroepForm.ledenBron === "groep"} onChange={() => setSubgroepForm({ ...subgroepForm, ledenBron: "groep" })} /> leerlingen uit groep</label>
<label className="veld"><input type="radio" checked={subgroepForm.ledenBron === "leerplein"} onChange={() => setSubgroepForm({ ...subgroepForm, ledenBron: "leerplein" })} /> leerlingen uit heel leerplein</label>
<label className="veld"><input type="radio" checked={subgroepForm.ledenBron === "leerplein"} onChange={() => setSubgroepForm({ ...subgroepForm, ledenBron: "leerplein" })} /> leerlingen uit volledige {BOVENGROEP_LABEL_LC}</label>
</div>
<div className="leden-keuze">
<div className="lijst-kop">
@ -978,50 +980,67 @@ export function LeerpleinPage() {
</form>
{loading ? <p>Laden</p> : (
<div className="structuur-overzicht">
<div className="structuur-tree">
{leerpleinen.map((lp) => {
const lpGroepen = groepen.filter((g) => (lp.groepen || []).includes(g.id));
const lpSubgroepen = subgroepen.filter((s) => (lp.subgroepen || []).includes(s.id) || lpGroepen.some((g) => g.id === s.groep));
return (
<div className="structuur-card" key={lp.id}>
<div className="structuur-head">
<div><strong>{lp.naam}</strong>{lp.omschrijving && <div className="muted small">{lp.omschrijving}</div>}</div>
<div className="tree-root" key={lp.id}>
<div className="tree-node tree-parent">
<div className="tree-node-main">
<span className="tree-dot parent" />
<div>
<strong>{lp.naam}</strong>
<div className="muted small">{lpGroepen.length} groep(en){lp.omschrijving ? ` · ${lp.omschrijving}` : ""}</div>
</div>
</div>
<div className="row-actions"><button className="link" onClick={() => bewerkLeerplein(lp)}>bewerken</button><button className="link danger" onClick={() => verwijderLeerplein(lp.id)}>verwijderen</button></div>
</div>
<div className="structuur-grid">
<div className="tree-children">
{lpGroepen.map((g) => {
const groepLeerlingen = leerlingenVanGroep(g.id);
const groepSubs = lpSubgroepen.filter((s) => s.groep === g.id);
return (
<div className="structuur-groep" key={g.id}>
<div className="structuur-head compact">
<div><strong>{g.naam}</strong><div className="muted small">{groepLeerlingen.length} leerling(en)</div></div>
<div className="tree-branch" key={g.id}>
<div className="tree-node tree-group">
<div className="tree-node-main">
<span className="tree-dot group" />
<div>
<strong>{g.naam}</strong>
<div className="muted small">{groepLeerlingen.length} leerling(en)</div>
</div>
</div>
<div className="row-actions"><button className="link" onClick={() => bewerkGroep(g)}>bewerken</button><button className="link danger" onClick={() => verwijderGroep(g.id)}>verwijderen</button></div>
</div>
<div className="muted small">{groepLeerlingen.map((l) => l.volledige_naam).join(", ") || "Geen leerlingen"}</div>
<div className="structuur-subgroepen">
<div className="tree-meta muted small">{groepLeerlingen.map((l) => l.volledige_naam).join(", ") || "Geen leerlingen"}</div>
<div className="tree-children tree-subchildren">
{groepSubs.map((s) => (
<div className="structuur-subgroep" key={s.id}>
<div className="structuur-head compact">
<div className="tree-node tree-subgroup" key={s.id}>
<div className="tree-node-main">
<span className="tree-dot sub" />
<div>
<strong>{s.naam}</strong>
<div className="row-actions"><button className="link" onClick={() => bewerkSubgroep(s)}>bewerken</button><button className="link danger" onClick={() => verwijderSubgroep(s.id)}>verwijderen</button></div>
</div>
<div className="muted small">{s.aantal_leden} leerling(en): {s.leden_namen.map((x) => x.naam).join(", ") || "geen"}</div>
</div>
</div>
<div>
<div className="row-actions"><button className="link" onClick={() => bewerkSubgroep(s)}>bewerken</button><button className="link danger" onClick={() => verwijderSubgroep(s.id)}>verwijderen</button></div>
</div>
</div>
))}
{groepSubs.length === 0 && <div className="muted small">Geen subgroepen.</div>}
{groepSubs.length === 0 && <div className="tree-empty muted small">Geen subgroepen.</div>}
</div>
</div>
);
})}
{lpGroepen.length === 0 && <div className="muted">Nog geen groepen onder dit leerplein.</div>}
{lpGroepen.length === 0 && <div className="tree-empty muted">Nog geen groepen onder deze {BOVENGROEP_LABEL_LC}.</div>}
</div>
</div>
);
})}
{leerpleinen.length === 0 && <div className="muted">Nog geen leerpleinen.</div>}
{leerpleinen.length === 0 && <div className="muted">Nog geen bovenliggende groepen.</div>}
{groepen.filter((g) => !leerpleinVanGroep(g.id)).length > 0 && (
<div className="banner error">Er zijn groepen zonder leerplein. Bewerk ze hieronder en kies een bovenliggend leerplein: {groepen.filter((g) => !leerpleinVanGroep(g.id)).map((g) => g.naam).join(", ")}</div>
<div className="banner error">Er zijn groepen zonder {BOVENGROEP_LABEL_LC}. Bewerk ze hieronder en kies een {BOVENGROEP_LABEL_LC}: {groepen.filter((g) => !leerpleinVanGroep(g.id)).map((g) => g.naam).join(", ")}</div>
)}
</div>
)}
@ -1277,7 +1296,7 @@ export function leerlingenVanBlok(blok, ctx) {
if (blok.leerling) return [blok.leerling];
return [];
}
// Welke leerlingen vallen binnen een gekozen scope (klas/leerplein/leerling)?
// Welke leerlingen vallen binnen een gekozen scope (klas/bovenliggende groep/leerling)?
export function scopeLeerlingen(scope, ctx) {
if (scope.type === "leerling") return [scope.id];
if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id);
@ -1504,7 +1523,7 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
}
/* ------------------------------- Groepsindeling --------------------------- */
// Verdeel een hoofdgroep (of leerplein) kolomsgewijs in subgroepen: per kolom
// Verdeel een hoofdgroep (of bovenliggende groep) kolomsgewijs in subgroepen: per kolom
// de subgroep met haar verantwoordelijke(n) en de vakken die er gegeven worden.
function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken, onWijzig }) {
const [type, setType] = useState("groep");
@ -1544,13 +1563,13 @@ function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken
return (
<div>
<p className="muted">Kies een hoofdgroep of leerplein en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.</p>
<p className="muted">Kies een hoofdgroep of bovenliggende groep en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.</p>
<Foutmelding msg={error} />
<div className="rooster-balk">
<label className="veld">Type
<select value={type} onChange={(e) => { setType(e.target.value); setHoofdId(""); setNieuw({ naam: "", groep: "", begeleiders: [] }); }}>
<option value="groep">Groep</option>
<option value="leerplein">Leerplein</option>
<option value="leerplein">{BOVENGROEP_LABEL}</option>
</select>
</label>
<select value={hoofdId} onChange={(e) => setHoofdId(e.target.value)}>
@ -1599,7 +1618,7 @@ function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken
)}
<form className="form-card" onSubmit={voegToe}>
<div className="muted small">Subgroep toevoegen onder {type === "groep" ? hoofd.naam : "een groep binnen dit leerplein"}:</div>
<div className="muted small">Subgroep toevoegen onder {type === "groep" ? hoofd.naam : `een groep binnen deze ${BOVENGROEP_LABEL_LC}`}:</div>
<div className="form-row">
{type === "leerplein" && (
<select value={nieuw.groep || ""} required onChange={(e) => setNieuw({ ...nieuw, groep: e.target.value })}>
@ -1648,7 +1667,7 @@ export function RoosterScherm({ mode }) {
const [toonForm, setToonForm] = useState(false);
const [tab, setTab] = useState("week");
const [error, setError] = useState(null);
// Scope-filter (ook voor gericht afdrukken): per klas/leerplein/leerling/personeel.
// Scope-filter (ook voor gericht afdrukken): per klas/bovenliggende groep/leerling/personeel.
const [scopeType, setScopeType] = useState("alles");
const [scopeId, setScopeId] = useState("");
// Blok bewerken/kopiëren (alleen in de Roostermaker).
@ -1656,6 +1675,8 @@ export function RoosterScherm({ mode }) {
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
const [form, setForm] = useState(leeg);
const [splitNieuwBlok, setSplitNieuwBlok] = useState(false);
const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]);
// Drag-and-drop via pointer events (werkt op muis én touch).
const dragRef = useRef(null);
@ -1748,9 +1769,9 @@ export function RoosterScherm({ mode }) {
function onKey(e) {
const t = e.target;
const typing = t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable);
if (e.key === "Escape") { setBewerkBlok(null); setToonForm(false); return; }
if (e.key === "Escape") { setBewerkBlok(null); sluitNieuwBlok(); return; }
if (typing) return;
if (e.key === "n" || e.key === "N") { setTab("week"); setToonForm((v) => !v); }
if (e.key === "n" || e.key === "N") { setTab("week"); setBewerkBlok(null); setToonForm((v) => { if (v) resetNieuweSplit(); return !v; }); }
else if (e.key === "ArrowLeft") { setMaandag((m) => plusDagen(m, -7)); }
else if (e.key === "ArrowRight") { setMaandag((m) => plusDagen(m, 7)); }
else if (e.key === "t" || e.key === "T") { setMaandag(maandagVan(new Date())); }
@ -1766,14 +1787,50 @@ export function RoosterScherm({ mode }) {
setHoverSlot(blok.tijdslot);
}
function resetNieuweSplit() {
setSplitNieuwBlok(false);
setSplitNieuwDoelen([]);
}
function openNieuwBlok() {
setBewerkBlok(null);
setToonForm(true);
}
function sluitNieuwBlok() {
setToonForm(false);
resetNieuweSplit();
}
function toggleNieuwSplitDoel(id) {
setSplitNieuwDoelen((doelen) => doelen.includes(id) ? doelen.filter((x) => x !== id) : [...doelen, id]);
}
const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen;
const splitNieuweOpties = form.doel_type === "groep" && form.doel
? subgroepen.filter((s) => s.groep === Number(form.doel))
: [];
const splitNieuweGroep = groepen.find((g) => g.id === Number(form.doel));
function toggleBegeleider(id) {
setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] });
}
async function opslaan(e) {
e.preventDefault();
const payload = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, groep: form.doel_type === "groep" ? form.doel : null, subgroep: form.doel_type === "subgroep" ? form.doel : null, leerling: form.doel_type === "leerling" ? form.doel : null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen };
try { await createResource("roosterblokken", payload); setForm({ ...leeg, doel_type: form.doel_type }); setToonForm(false); await laadBlokken(); } catch (err) { setError(err.message); }
const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen };
try {
if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) {
for (const subgroep of splitNieuwDoelen) {
await createResource("roosterblokken", { ...basis, groep: null, subgroep, leerling: null });
}
} else {
await createResource("roosterblokken", {
...basis,
groep: form.doel_type === "groep" ? form.doel : null,
subgroep: form.doel_type === "subgroep" ? form.doel : null,
leerling: form.doel_type === "leerling" ? form.doel : null,
});
}
setForm({ ...leeg, doel_type: form.doel_type });
sluitNieuwBlok();
await herlaadNaBlokWijziging();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit roosterblok (alle weken) verwijderen?")) return;
@ -1880,8 +1937,8 @@ export function RoosterScherm({ mode }) {
<div className="split-header">
<span className="split-icon"></span>
<div className="split-title">
<strong>{leerplein ? leerplein.naam : "Leerplein"}</strong>
<span>Leerplein groepen</span>
<strong>{leerplein ? leerplein.naam : BOVENGROEP_LABEL}</strong>
<span>{BOVENGROEP_LABEL} groepen</span>
</div>
<span className="split-count">{kolommen.length}</span>
</div>
@ -1969,7 +2026,7 @@ export function RoosterScherm({ mode }) {
<span className="week-label">week van {fmtDM(maandag)}</span>
</div>
)}
{edit && schooljaar && tab === "week" && <button className="btn primary icon-label" title="Blok toevoegen" onClick={() => setToonForm((v) => !v)}>{toonForm ? "×" : ""} <span>{toonForm ? "Sluiten" : "Blok"}</span></button>}
{edit && schooljaar && tab === "week" && <button className="btn primary icon-label" title="Vak toevoegen" onClick={toonForm ? sluitNieuwBlok : openNieuwBlok}>{toonForm ? "×" : ""} <span>{toonForm ? "Sluiten" : "Vak"}</span></button>}
{printAan && schooljaar && (edit ? tab === "week" : true) && (
<button className="btn print-knop icon-label" title="Rooster afdrukken" onClick={() => window.print()}>🖨 <span>Print</span></button>
)}
@ -1981,7 +2038,7 @@ export function RoosterScherm({ mode }) {
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
<option value="alles">Alles</option>
<option value="klas">Per klas</option>
<option value="leerplein">Per leerplein</option>
<option value="leerplein">Per {BOVENGROEP_LABEL_LC}</option>
<option value="leerling">Per leerling</option>
<option value="personeel">Per personeel</option>
</select>
@ -2024,8 +2081,21 @@ export function RoosterScherm({ mode }) {
)}
{edit && schooljaar && tab === "week" && toonForm && (
<form className="form-card no-print" onSubmit={opslaan}>
<div className="form-row">
<div className="modal-overlay no-print" onClick={sluitNieuwBlok}>
<form className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Vak toevoegen" onSubmit={opslaan} onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>Vak toevoegen</h2>
<div className="muted small">Maak één blok of splits direct over subgroepen.</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={sluitNieuwBlok}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Basis</strong>
<span className="muted small">Tijd, vak en doel</span>
</div>
<div className="editor-grid">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
@ -2038,30 +2108,65 @@ export function RoosterScherm({ mode }) {
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
</div>
<div className="form-row">
<select value={form.doel_type} onChange={(e) => setForm({ ...form, doel_type: e.target.value, doel: "" })}>
<details className="editor-detail" open>
<summary>Doelgroep</summary>
<div className="editor-grid">
<select value={form.doel_type} onChange={(e) => { setForm({ ...form, doel_type: e.target.value, doel: "" }); resetNieuweSplit(); }}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={form.doel} required onChange={(e) => setForm({ ...form, doel: e.target.value })}>
<select value={form.doel} required onChange={(e) => { setForm({ ...form, doel: e.target.value }); resetNieuweSplit(); }}>
<option value=""> kies {form.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<div className="chips">
</details>
{form.doel_type === "groep" && form.doel && (
<details className="editor-detail split-editor" open>
<summary>Groep direct splitsen</summary>
<label className="veld editor-check">
<input type="checkbox" checked={splitNieuwBlok} onChange={(e) => { setSplitNieuwBlok(e.target.checked); if (!e.target.checked) setSplitNieuwDoelen([]); }} />
blok onder subgroepen plaatsen
</label>
{splitNieuwBlok && (
<>
<div className="chips compact">
{splitNieuweOpties.map((s) => <button type="button" key={s.id} className={splitNieuwDoelen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggleNieuwSplitDoel(s.id)}>{s.naam}</button>)}
{splitNieuweOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
</div>
{splitNieuwDoelen.length > 0 && (
<div className="split-preview">
<div className="split-preview-root">{splitNieuweGroep?.naam || "Groep"}</div>
<div className="split-preview-children">
{splitNieuwDoelen.map((sid) => <span key={sid}>{splitNieuweOpties.find((s) => s.id === sid)?.naam || "Subgroep"}</span>)}
</div>
</div>
)}
</>
)}
</details>
)}
<details className="editor-detail" open>
<summary>Begeleiders</summary>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div>
</details>
<div className="editor-actions">
<button className="btn primary icon-label" type="submit" disabled={splitNieuwBlok && splitNieuwDoelen.length === 0}> <span>{splitNieuwBlok ? `${splitNieuwDoelen.length} blok(ken)` : "Vak toevoegen"}</span></button>
<button type="button" className="btn" onClick={sluitNieuwBlok}>Annuleren</button>
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
<button className="btn primary" type="submit">Blok toevoegen</button>
</div>
</form>
</div>
)}
{schooljaar && tab === "week" && (

View file

@ -229,6 +229,24 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.check-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lijst-kop { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.structuur-overzicht { display: grid; gap: 14px; margin-top: 18px; }
.structuur-tree { display: grid; gap: 14px; margin-top: 18px; }
.tree-root { border: 1px solid #bfdbfe; border-radius: 8px; background: #f8fbff; padding: 12px; }
.tree-node { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 10px 12px; }
.tree-node-main { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
.tree-node-main > div { min-width: 0; }
.tree-node-main strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-parent { border-color: #bfdbfe; background: #fff; }
.tree-group { border-left: 4px solid var(--brand); }
.tree-subgroup { border-left: 4px solid #93c5fd; background: #f9fafb; }
.tree-dot { width: 12px; height: 12px; border-radius: 999px; margin-top: 4px; flex: none; background: var(--line); }
.tree-dot.parent { background: var(--brand); }
.tree-dot.group { background: #38bdf8; }
.tree-dot.sub { background: #93c5fd; }
.tree-children { position: relative; display: grid; gap: 10px; margin: 10px 0 0 22px; padding-left: 16px; border-left: 2px solid #bfdbfe; }
.tree-branch { display: grid; gap: 7px; }
.tree-subchildren { margin-top: 0; border-left-color: #dbeafe; }
.tree-meta { padding-left: 16px; }
.tree-empty { padding: 8px 0 8px 12px; }
.structuur-card { border: 1px solid #bfdbfe; border-radius: 8px; background: #f8fbff; padding: 12px; }
.structuur-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.structuur-head.compact { align-items: center; margin-bottom: 6px; }
@ -305,6 +323,9 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.top-tabs-settings { margin-left: 0; border-left: 0; padding-left: 0; }
.split-kolommen { grid-template-columns: 1fr; }
.split-kolom { border-left: 0; padding-left: 0; }
.tree-node { flex-direction: column; align-items: stretch; }
.tree-children { margin-left: 10px; padding-left: 12px; }
.tree-meta { padding-left: 0; }
}
/* --- Login --- */