Groepsindeling als één sleep-overzicht met kleurstructuur

This commit is contained in:
bes-r 2026-06-16 22:17:47 +02:00
parent 3d29359d61
commit 77a5eacfed
3 changed files with 316 additions and 294 deletions

View file

@ -62,6 +62,13 @@ volgt.
- [x] Interne stages gebruiken nu een **lokaal** (de aparte stagelocatie-lijst is - [x] Interne stages gebruiken nu een **lokaal** (de aparte stagelocatie-lijst is
vervallen) en tellen mee in de dubbele-boekingcheck. vervallen) en tellen mee in de dubbele-boekingcheck.
## Groepsindeling
- [x] **Eén sleep-overzicht**: bovenliggende groep → groep → subgroep genest, met
een zijbalk "nog niet ingedeeld". Personen naar elk niveau slepen; × haalt
ze weer weg. Leerlingen licht gekleurd, personeel donker; duidelijke
gekleurde structuur per niveau. Inline aanmaken/hernoemen.
## Koppelingen & inzetregels ## Koppelingen & inzetregels
- [x] **Personeel koppelen aan leerplein en subgroep** (personeelsformulier: - [x] **Personeel koppelen aan leerplein en subgroep** (personeelsformulier:

View file

@ -804,6 +804,21 @@ export function AfwezigheidPage() {
/* ----------------------------- Groepsindeling ----------------------------- */ /* ----------------------------- Groepsindeling ----------------------------- */
// Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep. // Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep.
const SROL = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" };
function InlineAdd({ placeholder, onAdd, klein }) {
const [v, setV] = useState("");
return (
<form
className={klein ? "inline-add klein" : "inline-add"}
onSubmit={(e) => { e.preventDefault(); if (v.trim()) { onAdd(v.trim()); setV(""); } }}
>
<input placeholder={placeholder} value={v} onChange={(e) => setV(e.target.value)} />
<button className="btn klein" type="submit">+</button>
</form>
);
}
export function GroepsindelingPage() { export function GroepsindelingPage() {
const [leerpleinen, setLeerpleinen] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]);
const [groepen, setGroepen] = useState([]); const [groepen, setGroepen] = useState([]);
@ -813,15 +828,9 @@ export function GroepsindelingPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const leegLeerplein = { naam: "", omschrijving: "", leerlingen: [], medewerkers: [] }; const dragRef = useRef(null);
const leegGroep = { naam: "", omschrijving: "", leerplein: "", leerlingen: [] }; const [ghost, setGhost] = useState(null);
const leegSubgroep = { naam: "", omschrijving: "", groep: "", leerplein: "", ledenBron: "groep", leden: [], begeleiders: [] }; const [hoverZone, setHoverZone] = useState(null);
const [leerpleinForm, setLeerpleinForm] = useState(leegLeerplein);
const [groepForm, setGroepForm] = useState(leegGroep);
const [subgroepForm, setSubgroepForm] = useState(leegSubgroep);
const [editLeerpleinId, setEditLeerpleinId] = useState(null);
const [editGroepId, setEditGroepId] = useState(null);
const [editSubgroepId, setEditSubgroepId] = useState(null);
async function laadAlles() { async function laadAlles() {
setLoading(true); setLoading(true);
@ -839,316 +848,271 @@ export function GroepsindelingPage() {
setLeerlingen(ll); setLeerlingen(ll);
setMedewerkers(ps.filter((p) => p.rol !== "leerling")); setMedewerkers(ps.filter((p) => p.rol !== "leerling"));
setError(null); setError(null);
} catch (err) { } catch (err) { setError(err.message); } finally { setLoading(false); }
setError(err.message);
} finally {
setLoading(false);
}
} }
useEffect(() => { laadAlles(); }, []); useEffect(() => { laadAlles(); }, []);
const toggleArray = (lijst, id) => lijst.includes(id) ? lijst.filter((x) => x !== id) : [...lijst, id]; const leerlingById = (id) => leerlingen.find((l) => l.id === id);
const groepNaam = (id) => groepen.find((g) => g.id === id)?.naam || "—"; const medewerkerById = (id) => medewerkers.find((m) => m.id === id);
const leerpleinVanGroep = (groepId) => leerpleinen.find((lp) => (lp.groepen || []).includes(Number(groepId))); const groepLeerlingen = (gid) => leerlingen.filter((l) => l.groep === gid);
const leerpleinVanSubgroep = (subgroepId) => leerpleinen.find((lp) => (lp.subgroepen || []).includes(Number(subgroepId))); const groepMedewerkers = (gid) => medewerkers.filter((m) => m.stamgroep === gid);
const leerlingenVanGroep = (groepId) => leerlingen.filter((l) => l.groep === Number(groepId)); const groepSubs = (gid) => subgroepen.filter((s) => s.groep === gid);
function leerlingenVanLeerplein(leerpleinId) { const lpGroepen = (lp) => groepen.filter((g) => (lp.groepen || []).includes(g.id));
const lp = leerpleinen.find((x) => x.id === Number(leerpleinId)); const groepHeeftLeerplein = (gid) => leerpleinen.some((lp) => (lp.groepen || []).includes(gid));
if (!lp) return [];
const ids = new Set(lp.leerlingen || []);
leerlingen.forEach((l) => { if ((lp.groepen || []).includes(l.groep)) ids.add(l.id); });
(lp.subgroepen || []).forEach((sid) => {
const s = subgroepen.find((x) => x.id === sid);
(s?.leden_namen || []).forEach((lid) => ids.add(lid.id));
});
return leerlingen.filter((l) => ids.has(l.id));
}
const subgroepLeerlingen = subgroepForm.ledenBron === "leerplein"
? leerlingenVanLeerplein(subgroepForm.leerplein)
: leerlingenVanGroep(subgroepForm.groep);
function Vinkjes({ items, geselecteerd, toggle, leeg = "Geen leerlingen gevonden." }) { // "Nog niet ingedeeld": personen die nergens voorkomen.
const ingedeeldeLeerlingen = (() => {
const s = new Set();
leerlingen.forEach((l) => { if (l.groep) s.add(l.id); });
leerpleinen.forEach((lp) => (lp.leerlingen || []).forEach((id) => s.add(id)));
subgroepen.forEach((sg) => (sg.leden_namen || []).forEach((x) => s.add(x.id)));
return s;
})();
const ingedeeldeMedewerkers = (() => {
const s = new Set();
medewerkers.forEach((m) => { if (m.stamgroep) s.add(m.id); });
leerpleinen.forEach((lp) => (lp.medewerkers || []).forEach((id) => s.add(id)));
subgroepen.forEach((sg) => (sg.begeleiders_namen || []).forEach((x) => s.add(x.id)));
return s;
})();
const losseLeerlingen = leerlingen.filter((l) => !ingedeeldeLeerlingen.has(l.id));
const losseMedewerkers = medewerkers.filter((m) => !ingedeeldeMedewerkers.has(m.id));
function startDrag(e, payload) {
e.preventDefault();
dragRef.current = payload;
setGhost({ x: e.clientX, y: e.clientY, label: payload.naam });
}
useEffect(() => {
function move(e) {
if (!dragRef.current) return;
setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const z = el && el.closest("[data-drop-kind]");
setHoverZone(z ? `${z.dataset.dropKind}:${z.dataset.dropId}` : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const z = el && el.closest("[data-drop-kind]");
const payload = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverZone(null);
if (z) await drop({ kind: z.dataset.dropKind, id: Number(z.dataset.dropId) }, payload);
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
}, [leerpleinen, groepen, subgroepen, leerlingen, medewerkers]);
async function drop(zone, p) {
const { kind, id } = zone;
try {
if (kind === "groep") {
await updateResource("personen", p.id, p.rol === "leerling" ? { groep: id } : { stamgroep: id });
} else if (kind === "leerplein") {
const lp = leerpleinen.find((x) => x.id === id); if (!lp) return;
if (p.rol === "leerling") {
if (!(lp.leerlingen || []).includes(p.id)) await updateResource("leerpleinen", id, { leerlingen: [...(lp.leerlingen || []), p.id] });
} else if (!(lp.medewerkers || []).includes(p.id)) {
await updateResource("leerpleinen", id, { medewerkers: [...(lp.medewerkers || []), p.id] });
}
} else if (kind === "subgroep") {
const s = subgroepen.find((x) => x.id === id); if (!s) return;
if (p.rol === "leerling") {
const ids = s.leden_namen.map((x) => x.id);
if (!ids.includes(p.id)) await updateResource("subgroepen", id, { leden: [...ids, p.id] });
} else {
const ids = s.begeleiders_namen.map((x) => x.id);
if (!ids.includes(p.id)) await updateResource("subgroepen", id, { begeleiders: [...ids, p.id] });
}
}
await laadAlles();
} catch (e) { setError(e.message); }
}
async function haalWeg(container, p) {
const { kind, id } = container;
try {
if (kind === "groep") {
await updateResource("personen", p.id, p.rol === "leerling" ? { groep: null } : { stamgroep: null });
} else if (kind === "leerplein") {
const lp = leerpleinen.find((x) => x.id === id); if (!lp) return;
await updateResource("leerpleinen", id, p.rol === "leerling"
? { leerlingen: (lp.leerlingen || []).filter((x) => x !== p.id) }
: { medewerkers: (lp.medewerkers || []).filter((x) => x !== p.id) });
} else if (kind === "subgroep") {
const s = subgroepen.find((x) => x.id === id); if (!s) return;
await updateResource("subgroepen", id, p.rol === "leerling"
? { leden: s.leden_namen.map((x) => x.id).filter((x) => x !== p.id) }
: { begeleiders: s.begeleiders_namen.map((x) => x.id).filter((x) => x !== p.id) });
}
await laadAlles();
} catch (e) { setError(e.message); }
}
async function nieuweBovengroep(naam) { try { await createResource("leerpleinen", { naam }); await laadAlles(); } catch (e) { setError(e.message); } }
async function nieuweGroep(lp, naam) { try { const g = await createResource("groepen", { naam }); await updateResource("leerpleinen", lp.id, { groepen: [...(lp.groepen || []), g.id] }); await laadAlles(); } catch (e) { setError(e.message); } }
async function nieuweSubgroep(g, naam) { try { await createResource("subgroepen", { naam, groep: g.id }); await laadAlles(); } catch (e) { setError(e.message); } }
async function koppelGroep(g, lpId) { const lp = leerpleinen.find((x) => x.id === lpId); if (!lp) return; try { await updateResource("leerpleinen", lpId, { groepen: [...(lp.groepen || []), g.id] }); await laadAlles(); } catch (e) { setError(e.message); } }
async function hernoem(resource, id, huidig) { const naam = prompt("Nieuwe naam:", huidig); if (naam && naam.trim()) { try { await updateResource(resource, id, { naam: naam.trim() }); await laadAlles(); } catch (e) { setError(e.message); } } }
async function verwijder(resource, id, vraag) { if (!confirm(vraag)) return; try { await deleteResource(resource, id); await laadAlles(); } catch (e) { setError(e.message); } }
const zoneProps = (kind, id) => ({
"data-drop-kind": kind, "data-drop-id": id,
className: "dropzone" + (hoverZone === `${kind}:${id}` ? " over" : ""),
});
const chip = (p, rol, container) => {
if (!p) return null;
return ( return (
<div className="check-grid"> <span
{items.map((item) => ( key={(container ? container.kind + container.id : "pool") + "-" + p.id}
<label className="check-item" key={item.id}> className={`chip-persoon ${rol === "leerling" ? "ll" : "mw"}`}
<input type="checkbox" checked={geselecteerd.includes(item.id)} onChange={() => toggle(item.id)} /> onPointerDown={(e) => startDrag(e, { id: p.id, rol, naam: p.volledige_naam })}
<span>{item.volledige_naam || item.naam}</span> title={p.volledige_naam}
</label> >
))} <span className="chip-naam">{p.volledige_naam}</span>
{items.length === 0 && <span className="muted small">{leeg}</span>} {rol !== "leerling" && SROL[p.rol] && <span className="chip-rol">{SROL[p.rol]}</span>}
{container && (
<button type="button" className="chip-x" title="Uit deze groep halen"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); haalWeg(container, { id: p.id, rol }); }}
>×</button>
)}
</span>
);
};
function subKaart(s) {
const leeg = s.begeleiders_namen.length + s.leden_namen.length === 0;
return (
<div className="subgroep-kaart" key={s.id}>
<div className="kaart-kop sub-kop">
<strong>{s.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("subgroepen", s.id, s.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("subgroepen", s.id, `Subgroep '${s.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("subgroep", s.id)}>
<div className="chip-rij">
{s.begeleiders_namen.map((b) => chip(medewerkerById(b.id) || { id: b.id, volledige_naam: b.naam, rol: "leerkracht" }, "medewerker", { kind: "subgroep", id: s.id }))}
{s.leden_namen.map((l) => chip(leerlingById(l.id) || { id: l.id, volledige_naam: l.naam }, "leerling", { kind: "subgroep", id: s.id }))}
{leeg && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
</div>
);
}
function groepKaart(g) {
const orphan = !groepHeeftLeerplein(g.id);
const mws = groepMedewerkers(g.id);
const lls = groepLeerlingen(g.id);
return (
<div className="groep-kaart" key={g.id}>
<div className="kaart-kop groep-kop">
<strong>{g.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("groepen", g.id, g.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("groepen", g.id, `Groep '${g.naam}' verwijderen? (roosterblokken van deze groep vervallen)`)}>×</button>
</span>
</div>
<div {...zoneProps("groep", g.id)}>
<div className="chip-rij">
{mws.map((m) => chip(m, "medewerker", { kind: "groep", id: g.id }))}
{lls.map((l) => chip(l, "leerling", { kind: "groep", id: g.id }))}
{mws.length + lls.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
{orphan && (
<div className="koppel-rij">
<select value="" onChange={(e) => e.target.value && koppelGroep(g, Number(e.target.value))}>
<option value="">koppel aan bovenliggende groep</option>
{leerpleinen.map((lp) => <option key={lp.id} value={lp.id}>{lp.naam}</option>)}
</select>
</div>
)}
<div className="sub-lijst">
{groepSubs(g.id).map((s) => subKaart(s))}
<InlineAdd klein placeholder="+ subgroep" onAdd={(naam) => nieuweSubgroep(g, naam)} />
</div>
</div> </div>
); );
} }
async function syncGroepLeerplein(groepId, leerpleinId) { const orphanGroepen = groepen.filter((g) => !groepHeeftLeerplein(g.id));
const gid = Number(groepId);
const lpid = Number(leerpleinId);
for (const lp of leerpleinen) {
const heeft = (lp.groepen || []).includes(gid);
const moet = lp.id === lpid;
if (moet && !heeft) await updateResource("leerpleinen", lp.id, { groepen: [...(lp.groepen || []), gid] });
if (!moet && heeft) await updateResource("leerpleinen", lp.id, { groepen: (lp.groepen || []).filter((id) => id !== gid) });
}
}
async function syncSubgroepLeerplein(subgroepId, leerpleinId) {
const sid = Number(subgroepId);
const lpid = leerpleinId ? Number(leerpleinId) : null;
for (const lp of leerpleinen) {
const heeft = (lp.subgroepen || []).includes(sid);
const moet = lp.id === lpid;
if (moet && !heeft) await updateResource("leerpleinen", lp.id, { subgroepen: [...(lp.subgroepen || []), sid] });
if (!moet && heeft) await updateResource("leerpleinen", lp.id, { subgroepen: (lp.subgroepen || []).filter((id) => id !== sid) });
}
}
async function syncGroepLeerlingen(groepId, gekozenIds) {
const gid = Number(groepId);
for (const l of leerlingen) {
const gekozen = gekozenIds.includes(l.id);
if (gekozen && l.groep !== gid) await updateResource("personen", l.id, { groep: gid });
if (!gekozen && l.groep === gid) await updateResource("personen", l.id, { groep: null });
}
}
async function opslaanLeerplein(e) {
e.preventDefault();
try {
const payload = { ...leerpleinForm };
if (editLeerpleinId) await updateResource("leerpleinen", editLeerpleinId, payload);
else await createResource("leerpleinen", payload);
setLeerpleinForm(leegLeerplein);
setEditLeerpleinId(null);
await laadAlles();
} catch (err) { setError(err.message); }
}
async function opslaanGroep(e) {
e.preventDefault();
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);
await syncGroepLeerplein(opgeslagen.id, groepForm.leerplein);
await syncGroepLeerlingen(opgeslagen.id, groepForm.leerlingen);
setGroepForm(leegGroep);
setEditGroepId(null);
await laadAlles();
} catch (err) { setError(err.message); }
}
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 ${BOVENGROEP_LABEL_LC} voor leden uit de volledige ${BOVENGROEP_LABEL_LC}.`); return; }
try {
const payload = {
naam: subgroepForm.naam,
omschrijving: subgroepForm.omschrijving,
groep: subgroepForm.groep,
leden: subgroepForm.leden,
begeleiders: subgroepForm.begeleiders,
};
const opgeslagen = editSubgroepId ? await updateResource("subgroepen", editSubgroepId, payload) : await createResource("subgroepen", payload);
await syncSubgroepLeerplein(opgeslagen.id, subgroepForm.leerplein);
setSubgroepForm(leegSubgroep);
setEditSubgroepId(null);
await laadAlles();
} catch (err) { setError(err.message); }
}
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 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) {
if (!confirm("Deze groep verwijderen?")) return;
try { await deleteResource("groepen", id); await laadAlles(); } catch (err) { setError(err.message); }
}
async function verwijderSubgroep(id) {
if (!confirm("Deze subgroep verwijderen?")) return;
try { await syncSubgroepLeerplein(id, null); await deleteResource("subgroepen", id); await laadAlles(); } catch (err) { setError(err.message); }
}
function bewerkLeerplein(lp) {
setEditLeerpleinId(lp.id);
setLeerpleinForm({ naam: lp.naam, omschrijving: lp.omschrijving || "", leerlingen: lp.leerlingen || [], medewerkers: lp.medewerkers || [] });
}
function bewerkGroep(g) {
const lp = leerpleinVanGroep(g.id);
setEditGroepId(g.id);
setGroepForm({ naam: g.naam, omschrijving: g.omschrijving || "", leerplein: lp?.id || "", leerlingen: leerlingen.filter((l) => l.groep === g.id).map((l) => l.id) });
}
function bewerkSubgroep(s) {
const lp = leerpleinVanSubgroep(s.id) || leerpleinVanGroep(s.groep);
setEditSubgroepId(s.id);
setSubgroepForm({
naam: s.naam, omschrijving: s.omschrijving || "", groep: s.groep || "",
leerplein: lp?.id || "", ledenBron: lp ? "leerplein" : "groep",
leden: s.leden_namen.map((x) => x.id), begeleiders: s.begeleiders_namen.map((x) => x.id),
});
}
return ( return (
<section> <section className="indeling">
<h1>Groepsindeling</h1> <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> <p className="muted">Sleep personen naar een bovenliggende groep, groep of subgroep. <span className="legenda ll">leerling</span> en <span className="legenda mw">personeel</span> hebben elk een eigen kleur. Klik × om iemand uit een groep te halen.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<form className="form-card indeling-form" onSubmit={opslaanLeerplein}>
<h2>{BOVENGROEP_LABEL}</h2>
<div className="form-row">
<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 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">
<div className="muted small">Medewerkers:</div>
<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 ? `${BOVENGROEP_LABEL} opslaan` : `${BOVENGROEP_LABEL} toevoegen`}</button>
{editLeerpleinId && <button type="button" className="btn" onClick={() => { setEditLeerpleinId(null); setLeerpleinForm(leegLeerplein); }}>Annuleren</button>}
</div>
</form>
<form className="form-card indeling-form" onSubmit={opslaanGroep}>
<h2>Groep</h2>
<div className="form-row">
<select value={groepForm.leerplein} required onChange={(e) => setGroepForm({ ...groepForm, leerplein: e.target.value })}>
<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 })} />
<input placeholder="Omschrijving (optioneel)" value={groepForm.omschrijving} onChange={(e) => setGroepForm({ ...groepForm, omschrijving: e.target.value })} />
</div>
<div className="leden-keuze">
<div className="lijst-kop">
<span className="muted small">Leerlingen op schoolniveau in deze groep:</span>
<span>
<button type="button" className="link small" onClick={() => setGroepForm((f) => ({ ...f, leerlingen: leerlingen.map((l) => l.id) }))}>alles</button>
<button type="button" className="link small" onClick={() => setGroepForm((f) => ({ ...f, leerlingen: [] }))}>geen</button>
</span>
</div>
<Vinkjes items={leerlingen} geselecteerd={groepForm.leerlingen} toggle={(id) => setGroepForm((f) => ({ ...f, leerlingen: toggleArray(f.leerlingen, id) }))} />
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editGroepId ? "Groep opslaan" : "Groep toevoegen"}</button>
{editGroepId && <button type="button" className="btn" onClick={() => { setEditGroepId(null); setGroepForm(leegGroep); }}>Annuleren</button>}
</div>
</form>
<form className="form-card indeling-form" onSubmit={opslaanSubgroep}>
<h2>Subgroep</h2>
<div className="form-row">
<select value={subgroepForm.groep} required onChange={(e) => {
const groepId = e.target.value;
const lp = leerpleinVanGroep(groepId);
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 ${BOVENGROEP_LABEL_LC}`})</option>)}
</select>
<select value={subgroepForm.leerplein} onChange={(e) => setSubgroepForm({ ...subgroepForm, leerplein: e.target.value })}>
<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 })} />
<input placeholder="Omschrijving (optioneel)" value={subgroepForm.omschrijving} onChange={(e) => setSubgroepForm({ ...subgroepForm, omschrijving: e.target.value })} />
</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 volledige {BOVENGROEP_LABEL_LC}</label>
</div>
<div className="leden-keuze">
<div className="lijst-kop">
<span className="muted small">Leerlingen in subgroep:</span>
<span>
<button type="button" className="link small" onClick={() => setSubgroepForm((f) => ({ ...f, leden: subgroepLeerlingen.map((l) => l.id) }))}>alles</button>
<button type="button" className="link small" onClick={() => setSubgroepForm((f) => ({ ...f, leden: [] }))}>geen</button>
</span>
</div>
<Vinkjes items={subgroepLeerlingen} geselecteerd={subgroepForm.leden} toggle={(id) => setSubgroepForm((f) => ({ ...f, leden: toggleArray(f.leden, id) }))} />
</div>
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<Vinkjes items={medewerkers} geselecteerd={subgroepForm.begeleiders} toggle={(id) => setSubgroepForm((f) => ({ ...f, begeleiders: toggleArray(f.begeleiders, id) }))} leeg="Geen medewerkers gevonden." />
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editSubgroepId ? "Subgroep opslaan" : "Subgroep toevoegen"}</button>
{editSubgroepId && <button type="button" className="btn" onClick={() => { setEditSubgroepId(null); setSubgroepForm(leegSubgroep); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : ( {loading ? <p>Laden</p> : (
<div className="indeling-tree"> <div className="indeling-layout">
{leerpleinen.map((lp) => { <aside className="indeling-pool">
const lpGroepen = groepen.filter((g) => (lp.groepen || []).includes(g.id)); <h3>Nog niet ingedeeld</h3>
const lpSubgroepen = subgroepen.filter((s) => (lp.subgroepen || []).includes(s.id) || lpGroepen.some((g) => g.id === s.groep)); <div className="pool-sectie">
return ( <div className="pool-kop">Leerlingen</div>
<div className="tree-root" key={lp.id}> <div className="chip-rij">
<div className="tree-node tree-parent"> {losseLeerlingen.map((l) => chip(l, "leerling", null))}
<div className="tree-node-main"> {losseLeerlingen.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
<span className="tree-dot parent" /> </div>
<div> </div>
<strong>{lp.naam}</strong> <div className="pool-sectie">
<div className="muted small">{lpGroepen.length} groep(en){lp.omschrijving ? ` · ${lp.omschrijving}` : ""}</div> <div className="pool-kop">Personeel</div>
<div className="chip-rij">
{losseMedewerkers.map((m) => chip(m, "medewerker", null))}
{losseMedewerkers.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
</div>
</div>
</aside>
<div className="indeling-board">
<InlineAdd placeholder="Nieuwe bovenliggende groep…" onAdd={nieuweBovengroep} />
{leerpleinen.map((lp) => {
const mwIds = lp.medewerkers || [];
const llIds = lp.leerlingen || [];
return (
<div className="bovengroep-kaart" key={lp.id}>
<div className="kaart-kop bovengroep-kop">
<strong>{lp.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("leerpleinen", lp.id, lp.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("leerpleinen", lp.id, `Bovenliggende groep '${lp.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("leerplein", lp.id)}>
<div className="zone-kop">Direct in deze bovenliggende groep</div>
<div className="chip-rij">
{mwIds.map((id) => chip(medewerkerById(id), "medewerker", { kind: "leerplein", id: lp.id }))}
{llIds.map((id) => chip(leerlingById(id), "leerling", { kind: "leerplein", id: lp.id }))}
{mwIds.length + llIds.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div> </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 className="groep-lijst">
{lpGroepen(lp).map((g) => groepKaart(g))}
<InlineAdd klein placeholder="+ groep" onAdd={(naam) => nieuweGroep(lp, naam)} />
</div>
</div> </div>
<div className="tree-children"> );
{lpGroepen.map((g) => { })}
const groepLeerlingen = leerlingenVanGroep(g.id); {leerpleinen.length === 0 && <div className="muted">Nog geen bovenliggende groepen. Maak er hierboven één aan.</div>}
const groepSubs = lpSubgroepen.filter((s) => s.groep === g.id); {orphanGroepen.length > 0 && (
return ( <div className="bovengroep-kaart los">
<div className="tree-branch" key={g.id}> <div className="kaart-kop"><strong>Groepen zonder bovenliggende groep</strong></div>
<div className="tree-node tree-group"> <div className="groep-lijst">
<div className="tree-node-main"> {orphanGroepen.map((g) => groepKaart(g))}
<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="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="tree-node tree-subgroup" key={s.id}>
<div className="tree-node-main">
<span className="tree-dot sub" />
<div>
<strong>{s.naam}</strong>
<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="tree-empty muted small">Geen subgroepen.</div>}
</div>
</div>
);
})}
{lpGroepen.length === 0 && <div className="tree-empty muted">Nog geen groepen onder deze {BOVENGROEP_LABEL_LC}.</div>}
</div> </div>
</div> </div>
); )}
})} </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 {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> </div>
)} )}
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section> </section>
); );
} }
/* ---------------------------------- Stage --------------------------------- */ /* ---------------------------------- Stage --------------------------------- */
// Module-scherm: interne en externe stages. // Module-scherm: interne en externe stages.
const OMVANGEN = [ const OMVANGEN = [

View file

@ -357,3 +357,54 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.subsectie h2 { margin: 0 0 4px; font-size: 18px; } .subsectie h2 { margin: 0 0 4px; font-size: 18px; }
.veld-kol { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; } .veld-kol { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; }
.veld-kop { font-size: 11px; font-weight: 700; color: var(--muted); } .veld-kop { font-size: 11px; font-weight: 700; color: var(--muted); }
/* ---- Groepsindeling: sleep-overzicht met kleurstructuur ---- */
.indeling-layout { display: grid; grid-template-columns: 240px 1fr; gap: 16px; align-items: start; }
.indeling-pool { position: sticky; top: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; }
.indeling-pool h3 { margin: 0 0 8px; font-size: 14px; }
.pool-sectie { margin-bottom: 12px; }
.pool-kop { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); margin-bottom: 6px; }
.indeling-board { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.chip-rij { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.chip-persoon { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-radius: 999px; font-size: 12px; line-height: 1.6; cursor: grab; touch-action: none; user-select: none; border: 1px solid transparent; }
.chip-persoon:active { cursor: grabbing; }
.chip-persoon.mw { background: #3730a3; color: #fff; } /* personeel: donker */
.chip-persoon.ll { background: #dbeafe; color: #1e3a8a; border-color: #bfdbfe; } /* leerling: licht */
.chip-rol { font-size: 10px; font-weight: 700; opacity: .8; }
.chip-x { border: 0; background: rgba(255,255,255,.25); color: inherit; border-radius: 999px; width: 16px; height: 16px; line-height: 14px; font-size: 13px; cursor: pointer; padding: 0; }
.chip-persoon.ll .chip-x { background: rgba(30,58,138,.15); }
.chip-x:hover { background: rgba(0,0,0,.25); }
.bovengroep-kaart { border: 1px solid var(--line); border-left: 5px solid var(--brand); border-radius: 12px; background: var(--panel); padding: 10px 12px 12px; }
.bovengroep-kaart.los { border-left-color: #f59e0b; }
.groep-lijst { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; padding-left: 12px; border-left: 2px dashed #c7d2fe; }
.groep-kaart { border: 1px solid var(--line); border-left: 4px solid #0ea5e9; border-radius: 10px; background: #f8fbff; padding: 8px 10px 10px; }
.sub-lijst { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; padding-left: 12px; border-left: 2px dashed #bae6fd; }
.subgroep-kaart { border: 1px solid var(--line); border-left: 3px solid #14b8a6; border-radius: 8px; background: #f0fdfa; padding: 6px 9px 8px; }
.kaart-kop { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.bovengroep-kop strong { font-size: 16px; color: var(--brand); }
.groep-kop strong { color: #0369a1; }
.sub-kop strong { color: #0f766e; }
.kaart-acties { display: inline-flex; gap: 6px; white-space: nowrap; }
.dropzone { margin-top: 6px; border: 1px dashed transparent; border-radius: 8px; padding: 6px; transition: background .1s, border-color .1s; }
.dropzone.over { border-color: var(--brand); background: var(--brand-soft); }
.zone-kop { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.koppel-rij { margin-top: 6px; }
.inline-add { display: flex; gap: 6px; margin-top: 4px; }
.inline-add input { flex: 1; min-width: 0; }
.inline-add.klein input { font-size: 13px; padding: 6px 8px; }
.btn.klein { padding: 4px 12px; font-size: 14px; }
.link.small { font-size: 12px; }
.legenda { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.legenda.ll { background: #dbeafe; color: #1e3a8a; }
.legenda.mw { background: #3730a3; color: #fff; }
@media (max-width: 760px) {
.indeling-layout { grid-template-columns: 1fr; }
.indeling-pool { position: static; }
}