import { useEffect, useRef, useState } from "react"; import { listResource, createResource, updateResource, deleteResource, getModules, getLokaalinstelling, setLokaalinstelling, } from "./api.js"; const ROLLEN = [ { value: "leerling", label: "Leerling" }, { value: "leerkracht", label: "Leerkracht" }, { value: "praktijkondersteuner", label: "Praktijkondersteuner" }, { value: "klassenondersteuner", label: "Klassenondersteuner" }, ]; // Korte aanduiding van een medewerkersrol (compact in de roostervakjes). const ROL_AFK = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" }; // Automatisch een passend icoon bij een vak/activiteit op basis van de naam. const VAK_ICONEN = [ [/reken|wiskund|getal/i, "🔢"], [/taal|lees|spell|begrijpend|nederlands|woordenschat/i, "📖"], [/engels|frans|duits|spaans/i, "🗣️"], [/gym|beweg|sport|zwem|motoriek/i, "🤸"], [/muziek|zang/i, "🎵"], [/teken|kunst|knutsel|creatief|handvaardig|drama/i, "🎨"], [/pauze|speel|buiten/i, "☕"], [/eten|lunch|fruit/i, "🍎"], [/natuur|biolog|wereld|aardrijk|geschied/i, "🌍"], [/koken|huishoud/i, "🍳"], [/techniek|bouw|hout/i, "🔧"], [/computer|digit|ict|programmeer|media/i, "💻"], [/therap|logop|fysio|sova|sociale|gedrag/i, "💬"], [/stage|werk|prakt/i, "🧑‍🏭"], [/kring|start|dag|inloop/i, "⭕"], ]; function vakIcoon(naam) { if (!naam) return "📚"; for (const [re, icon] of VAK_ICONEN) if (re.test(naam)) return icon; return "📚"; } const BOVENGROEP_LABEL = "Bovenliggende groep"; const BOVENGROEP_LABEL_LC = "bovenliggende groep"; const BOVENGROEP_LABEL_MV = "Bovenliggende groepen"; const WEEKDAGEN = [ { value: 0, label: "Maandag" }, { value: 1, label: "Dinsdag" }, { value: 2, label: "Woensdag" }, { value: 3, label: "Donderdag" }, { value: 4, label: "Vrijdag" }, ]; const DAGTYPES = [ { value: "studiedag", label: "Studiedag" }, { value: "vrije_dag", label: "Vrije dag" }, { value: "vakantie", label: "Vakantie" }, ]; function useList(resource, query = "") { const [items, setItems] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); async function reload() { setLoading(true); try { setItems(await listResource(resource, query)); setError(null); } catch (e) { setError(e.message); } finally { setLoading(false); } } useEffect(() => { reload(); }, [resource, query]); return { items, error, loading, reload, setError }; } function Foutmelding({ msg }) { if (!msg) return null; return
⚠ {msg}
; } /* ----------------------- Generieke eenvoudige catalogus ------------------- */ // Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties). function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false }) { const { items, error, loading, reload, setError } = useList(resource); const leeg = { naam: "", omschrijving: "", ...(kleur ? { kleur: "#2563eb" } : {}), ...(metTheorievak ? { theorievak: false } : {}), ...(metIcoon ? { icoon: "" } : {}), }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0); async function opslaan(e) { e.preventDefault(); try { if (editId) await updateResource(resource, editId, form); else await createResource(resource, form); setForm(leeg); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Verwijderen?")) return; try { await deleteResource(resource, id); await reload(); } catch (err) { setError(err.message); } } function bewerk(it) { setEditId(it.id); setForm({ naam: it.naam, omschrijving: it.omschrijving, ...(kleur ? { kleur: it.kleur } : {}), ...(metTheorievak ? { theorievak: it.theorievak } : {}), ...(metIcoon ? { icoon: it.icoon || "" } : {}), }); } return (

{titel}

{uitleg &&

{uitleg}

}
setForm({ ...form, naam: e.target.value })} /> setForm({ ...form, omschrijving: e.target.value })} /> {kleur && ( setForm({ ...form, kleur: e.target.value })} /> )} {metIcoon && ( setForm({ ...form, icoon: e.target.value })} /> )} {metTheorievak && ( )} {editId && ( )}
{loading ? (

Laden…

) : ( {metTheorievak && } {telKolom && } {items.map((it) => ( {metTheorievak && } {telKolom && } ))} {items.length === 0 && ( )}
Naam OmschrijvingTheorievak{telKolom}
{kleur && } {metIcoon && {it.icoon || vakIcoon(it.naam)} } {it.naam} {it.omschrijving || "—"}{it.theorievak ? "✓" : "—"}{it.aantal_personen ?? "—"}
Nog niets.
)}
); } export function FunctiesPage() { return ( ); } export function ActiviteitenPage() { return ( ); } export function LocatiesPage() { return ( ); } export function LokalenPage() { const { items, error, loading, reload, setError } = useList("lokalen"); const leeg = { naam: "", omschrijving: "", capaciteit: "" }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); const [blokkeren, setBlokkeren] = useState(false); const [instError, setInstError] = useState(null); useEffect(() => { getLokaalinstelling().then((d) => setBlokkeren(!!d.dubbel_blokkeren)).catch(() => {}); }, []); async function toggleBlokkeren(val) { setBlokkeren(val); try { await setLokaalinstelling({ dubbel_blokkeren: val }); setInstError(null); } catch (err) { setInstError(err.message); setBlokkeren(!val); } } async function opslaan(e) { e.preventDefault(); const payload = { naam: form.naam, omschrijving: form.omschrijving, capaciteit: form.capaciteit === "" ? null : Number(form.capaciteit) }; try { if (editId) await updateResource("lokalen", editId, payload); else await createResource("lokalen", payload); setForm(leeg); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Dit lokaal verwijderen?")) return; try { await deleteResource("lokalen", id); await reload(); } catch (err) { setError(err.message); } } return (

Lokalen

Lesruimtes die door lessen én interne stages gedeeld worden. Het systeem bewaakt dubbele boekingen per tijdslot.

setForm({ ...form, naam: e.target.value })} /> setForm({ ...form, omschrijving: e.target.value })} /> setForm({ ...form, capaciteit: e.target.value })} /> {editId && }
{loading ?

Laden…

: ( {items.map((l) => ( ))} {items.length === 0 && }
NaamCapaciteitOmschrijving
{l.naam} {l.capaciteit ?? "—"} {l.omschrijving || "—"}
Nog geen lokalen.
)}
); } /* --------------------------------- Groepen -------------------------------- */ export function GroepenPage() { return ; } /* -------------------------------- Personen -------------------------------- */ const STAFF_ROLLEN = ROLLEN.filter((r) => r.value !== "leerling"); export function PersonenPage() { const { items, error, loading, reload, setError } = useList("personen"); const [groepen, setGroepen] = useState([]); const [functies, setFuncties] = useState([]); const [vakken, setVakken] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]); // Twee losse flows: medewerker en leerling. const [modus, setModus] = useState("leerling"); const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [], begeleide_subgroepen: [], leerpleinen: [] }; const leegLeerling = { voornaam: "", achternaam: "", groep: "", geboortedatum: "" }; const [form, setForm] = useState(leegLeerling); const [editId, setEditId] = useState(null); const [filter, setFilter] = useState(""); const [nieuweFunctie, setNieuweFunctie] = useState(""); async function maakFunctie() { const naam = nieuweFunctie.trim(); if (!naam) return; try { const f = await createResource("functies", { naam }); setFuncties((lijst) => [...lijst, f]); setForm((prev) => ({ ...prev, functies: [...prev.functies, f.id] })); setNieuweFunctie(""); } catch (e) { setError(e.message); } } function laadHulplijsten() { listResource("groepen").then(setGroepen).catch(() => {}); listResource("functies").then(setFuncties).catch(() => {}); listResource("activiteiten").then(setVakken).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([])); } useEffect(() => { laadHulplijsten(); }, []); // De bovenliggende-groep-koppeling loopt via de bestaande API. async function syncLeerpleinen(persoonId, gekozenIds) { const huidige = await listResource("leerpleinen").catch(() => []); for (const lp of huidige) { const inLijst = (lp.medewerkers || []).includes(persoonId); const moet = gekozenIds.includes(lp.id); if (moet && !inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: [...lp.medewerkers, persoonId] }); else if (!moet && inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: lp.medewerkers.filter((x) => x !== persoonId) }); } listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); } function kiesModus(m) { setModus(m); setEditId(null); setForm(m === "medewerker" ? leegMedewerker : leegLeerling); } function toggle(veld, id) { setForm((f) => f[veld].includes(id) ? { ...f, [veld]: f[veld].filter((x) => x !== id) } : { ...f, [veld]: [...f[veld], id] }); } async function opslaan(e) { e.preventDefault(); const payload = modus === "medewerker" ? { voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol, stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken, begeleide_subgroepen: form.begeleide_subgroepen, } : { voornaam: form.voornaam, achternaam: form.achternaam, rol: "leerling", groep: form.groep || null, geboortedatum: form.geboortedatum || null, }; try { const opgeslagen = editId ? await updateResource("personen", editId, payload) : await createResource("personen", payload); if (modus === "medewerker" && opgeslagen && opgeslagen.id) { await syncLeerpleinen(opgeslagen.id, form.leerpleinen || []); } setForm(modus === "medewerker" ? leegMedewerker : leegLeerling); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Deze persoon verwijderen?")) return; try { await deleteResource("personen", id); await reload(); } catch (err) { setError(err.message); } } function bewerk(p) { setEditId(p.id); if (p.rol === "leerling") { setModus("leerling"); setForm({ voornaam: p.voornaam, achternaam: p.achternaam, groep: p.groep || "", geboortedatum: p.geboortedatum || "" }); } else { setModus("medewerker"); setForm({ voornaam: p.voornaam, achternaam: p.achternaam, rol: p.rol, stamgroep: p.stamgroep || "", functies: p.functies_namen.map((x) => x.id), vakken: p.vakken_namen.map((x) => x.id), begeleide_subgroepen: (p.begeleide_subgroepen_namen || []).map((x) => x.id), leerpleinen: leerpleinen.filter((lp) => (lp.medewerkers || []).includes(p.id)).map((lp) => lp.id), }); } } const zichtbaar = filter === "leerling" ? items.filter((p) => p.rol === "leerling") : filter === "medewerker" ? items.filter((p) => p.rol !== "leerling") : items; return (

Personen

Personeel en leerlingen staan los van elkaar. Kies wat je wilt toevoegen.

setForm({ ...form, voornaam: e.target.value })} /> setForm({ ...form, achternaam: e.target.value })} /> {modus === "leerling" ? ( <> ) : ( <> )}
{modus === "medewerker" && ( <>
Functies (klik om te kiezen, of typ een nieuwe):
{functies.map((f) => )} {functies.length === 0 && Nog geen functies.}
setNieuweFunctie(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakFunctie(); } }} />
Vakken die gegeven kunnen worden{form.rol === "leerkracht" ? " (theorievakken worden automatisch gekoppeld)" : ""}:
{vakken.map((a) => )} {vakken.length === 0 && Nog geen vakken/activiteiten.}
Subgroepen (begeleiding):
{subgroepen.map((s) => )} {subgroepen.length === 0 && Nog geen subgroepen.}
{leerpleinen.length > 0 && (
{BOVENGROEP_LABEL_MV}:
{leerpleinen.map((lp) => )}
)} )}
{editId && }
Toon:
{loading ?

Laden…

: ( {zichtbaar.map((p) => ( ))} {zichtbaar.length === 0 && }
NaamRolDetails
{p.volledige_naam} {p.rol_label} {p.rol === "leerling" ? ( <> {p.groep_naam ? 🗂️ {p.groep_naam} : null} {p.geboortedatum ? 🎂 {p.geboortedatum} : null} {!p.groep_naam && !p.geboortedatum ? "—" : null} ) : ( <> {p.stamgroep_naam ?
🗂️ {p.stamgroep_naam}
: null} {p.functies_namen.length ?
🪪 {p.functies_namen.map((x) => x.naam).join(", ")}
: null} {p.vakken_namen.length ?
📚 {p.vakken_namen.map((x) => x.naam).join(", ")}
: null} {!p.stamgroep_naam && !p.functies_namen.length && !p.vakken_namen.length ? "—" : null} )}
Geen personen.
)}
); } /* ------------------------------- Subgroepen ------------------------------- */ export function SubgroepenPage() { return ; } /* ------------------------------- Tijdsloten ------------------------------- */ export function TijdslotenPage() { const { items, error, loading, reload, setError } = useList("tijdsloten"); const [groepen, setGroepen] = useState([]); const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1, groep: "" }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []); async function opslaan(e) { e.preventDefault(); try { const payload = { ...form, groep: form.groep || null }; if (editId) await updateResource("tijdsloten", editId, payload); else await createResource("tijdsloten", payload); setForm(leeg); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Dit tijdslot verwijderen?")) return; try { await deleteResource("tijdsloten", id); await reload(); } catch (err) { setError(err.message); } } return (

Tijdsloten

Vaste tijden per schooldag. Laat de groep leeg voor de algemene tijden, of kies een groep voor afwijkende tijden.

setForm({ ...form, naam: e.target.value })} /> setForm({ ...form, start_tijd: e.target.value })} /> setForm({ ...form, eind_tijd: e.target.value })} /> setForm({ ...form, volgorde: Number(e.target.value) })} /> {editId && }
{loading ?

Laden…

: ( {items.map((t) => ( ))} {items.length === 0 && }
DagTijdNaamGeldt voor
{t.dag_label} {t.start_tijd?.slice(0, 5)}–{t.eind_tijd?.slice(0, 5)} {t.naam} {t.groep_naam || "algemeen"}
Nog geen tijdsloten.
)}
); } /* --------------------------- Schooljaar & kalender ------------------------ */ export function SchooljaarPage() { const { items: jaren, error, loading, reload, setError } = useList("schooljaren"); const leeg = { naam: "", start_datum: "", eind_datum: "" }; const [form, setForm] = useState(leeg); const [gekozen, setGekozen] = useState(null); async function opslaan(e) { e.preventDefault(); try { await createResource("schooljaren", form); setForm(leeg); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Dit schooljaar (incl. kalender) verwijderen?")) return; try { await deleteResource("schooljaren", id); if (gekozen === id) setGekozen(null); await reload(); } catch (err) { setError(err.message); } } return (

Schooljaar & kalender

Schooljaren met studiedagen, vrije dagen en vakanties. Op die dagen vervallen de roosterblokken.

setForm({ ...form, naam: e.target.value })} />
{loading ?

Laden…

: ( {jaren.map((j) => ( ))} {jaren.length === 0 && }
SchooljaarPeriode
{j.naam} {j.start_datum} t/m {j.eind_datum}
Nog geen schooljaren.
)} {gekozen && }
); } function Kalender({ schooljaarId }) { const { items, error, reload, setError } = useList("kalenderdagen", `?schooljaar=${schooljaarId}`); const leeg = { datum: "", type: "vrije_dag", omschrijving: "" }; const [form, setForm] = useState(leeg); async function opslaan(e) { e.preventDefault(); try { await createResource("kalenderdagen", { ...form, schooljaar: schooljaarId }); setForm(leeg); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { try { await deleteResource("kalenderdagen", id); await reload(); } catch (err) { setError(err.message); } } return (

Kalender

setForm({ ...form, datum: e.target.value })} /> setForm({ ...form, omschrijving: e.target.value })} />
{items.map((d) => ( ))} {items.length === 0 && }
DatumTypeOmschrijving
{d.datum} {d.type_label} {d.omschrijving || "—"}
Nog geen bijzondere dagen.
); } /* ------------------------------- Afwezigheid ------------------------------ */ export function AfwezigheidPage() { const { items, error, loading, reload, setError } = useList("afwezigheden"); const [personen, setPersonen] = useState([]); const leeg = { persoon: "", start_datum: "", eind_datum: "", reden: "" }; const [form, setForm] = useState(leeg); useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); async function opslaan(e) { e.preventDefault(); try { await createResource("afwezigheden", form); setForm(leeg); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Deze afwezigheid verwijderen?")) return; try { await deleteResource("afwezigheden", id); await reload(); } catch (err) { setError(err.message); } } return (

Afwezigheid

Leg vast wie wanneer afwezig is. In het rooster zie je dat terug bij de betrokken blokken.

setForm({ ...form, reden: e.target.value })} />
{loading ?

Laden…

: ( {items.map((a) => ( ))} {items.length === 0 && }
PersoonPeriodeReden
{a.persoon_naam} {a.start_datum} t/m {a.eind_datum} {a.reden || "—"}
Geen afwezigheden.
)}
); } /* ----------------------------- Groepsindeling ----------------------------- */ // Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep. const SROL = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" }; function InlineAdd({ placeholder, onAdd, klein }) { const [v, setV] = useState(""); return (
{ e.preventDefault(); if (v.trim()) { onAdd(v.trim()); setV(""); } }} > setV(e.target.value)} />
); } export function GroepsindelingPage() { const [leerpleinen, setLeerpleinen] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [toon, setToon] = useState("allen"); // allen | leerling | personeel const [selectie, setSelectie] = useState(new Set()); // persoon-ids const [plaatsDoel, setPlaatsDoel] = useState(""); // "kind:id" const dragRef = useRef(null); const [ghost, setGhost] = useState(null); const [hoverZone, setHoverZone] = useState(null); async function laadAlles() { setLoading(true); try { const [lp, gr, sg, ll, ps] = await Promise.all([ listResource("leerpleinen"), listResource("groepen"), listResource("subgroepen"), listResource("personen", "?rol=leerling"), listResource("personen"), ]); setLeerpleinen(lp); setGroepen(gr); setSubgroepen(sg); setLeerlingen(ll); setMedewerkers(ps.filter((p) => p.rol !== "leerling")); setError(null); } catch (err) { setError(err.message); } finally { setLoading(false); } } useEffect(() => { laadAlles(); }, []); const leerlingById = (id) => leerlingen.find((l) => l.id === id); const medewerkerById = (id) => medewerkers.find((m) => m.id === id); const isLeerlingId = (id) => leerlingen.some((l) => l.id === id); const toonRol = (rol) => toon === "allen" || (toon === "leerling" ? rol === "leerling" : rol !== "leerling"); const groepLeerlingen = (gid) => leerlingen.filter((l) => l.groep === gid); const groepMedewerkers = (gid) => medewerkers.filter((m) => m.stamgroep === gid); const groepSubs = (gid) => subgroepen.filter((s) => s.groep === gid); const lpGroepen = (lp) => groepen.filter((g) => (lp.groepen || []).includes(g.id)); const groepHeeftLeerplein = (gid) => leerpleinen.some((lp) => (lp.groepen || []).includes(gid)); 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)); const toggleSel = (id) => setSelectie((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const wisSelectie = () => setSelectie(new Set()); const containerOpties = []; leerpleinen.forEach((lp) => { containerOpties.push({ value: `leerplein:${lp.id}`, label: `${lp.naam} (bovenliggende groep)` }); lpGroepen(lp).forEach((g) => { containerOpties.push({ value: `groep:${g.id}`, label: `${lp.naam} › ${g.naam} (groep)` }); groepSubs(g.id).forEach((s) => containerOpties.push({ value: `subgroep:${s.id}`, label: `${lp.naam} › ${g.naam} › ${s.naam} (subgroep)` })); }); }); function startDrag(e, payload) { e.preventDefault(); dragRef.current = payload; const meerdere = selectie.has(payload.id) && selectie.size > 1; setGhost({ x: e.clientX, y: e.clientY, label: meerdere ? `${selectie.size} personen` : payload.naam }); } useEffect(() => { function move(e) { if (!dragRef.current) return; const meerdere = selectie.has(dragRef.current.id) && selectie.size > 1; setGhost({ x: e.clientX, y: e.clientY, label: meerdere ? `${selectie.size} personen` : 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) { const zone = { kind: z.dataset.dropKind, id: Number(z.dataset.dropId) }; if (selectie.has(payload.id) && selectie.size > 1) await plaatsSelectie(zone); else await drop(zone, payload); } } window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [leerpleinen, groepen, subgroepen, leerlingen, medewerkers, selectie]); 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 plaatsSelectie(zone) { const ids = [...selectie]; if (ids.length === 0) return; const { kind, id } = zone; const ll = ids.filter(isLeerlingId); const mw = ids.filter((x) => !isLeerlingId(x)); try { if (kind === "groep") { for (const pid of ids) await updateResource("personen", pid, isLeerlingId(pid) ? { groep: id } : { stamgroep: id }); } else if (kind === "leerplein") { const lp = leerpleinen.find((x) => x.id === id); if (!lp) return; if (ll.length) await updateResource("leerpleinen", id, { leerlingen: [...new Set([...(lp.leerlingen || []), ...ll])] }); if (mw.length) await updateResource("leerpleinen", id, { medewerkers: [...new Set([...(lp.medewerkers || []), ...mw])] }); } else if (kind === "subgroep") { const s = subgroepen.find((x) => x.id === id); if (!s) return; if (ll.length) await updateResource("subgroepen", id, { leden: [...new Set([...s.leden_namen.map((x) => x.id), ...ll])] }); if (mw.length) await updateResource("subgroepen", id, { begeleiders: [...new Set([...s.begeleiders_namen.map((x) => x.id), ...mw])] }); } wisSelectie(); setPlaatsDoel(""); await laadAlles(); } catch (e) { setError(e.message); } } const parseZone = (str) => { const [kind, id] = str.split(":"); return { kind, id: Number(id) }; }; 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 || !toonRol(rol)) return null; const sel = selectie.has(p.id); return ( startDrag(e, { id: p.id, rol, naam: p.volledige_naam })} title={p.volledige_naam} > {p.volledige_naam} {rol !== "leerling" && SROL[p.rol] && {SROL[p.rol]}} {container && ( )} ); }; function subKaart(s) { const leeg = s.begeleiders_namen.length + s.leden_namen.length === 0; return (
{s.naam}
{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 && sleep hier personen heen}
); } function groepKaart(g) { const orphan = !groepHeeftLeerplein(g.id); const mws = groepMedewerkers(g.id); const lls = groepLeerlingen(g.id); return (
{g.naam}
{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 && sleep hier personen heen}
{orphan && (
)}
{groepSubs(g.id).map((s) => subKaart(s))} nieuweSubgroep(g, naam)} />
); } const orphanGroepen = groepen.filter((g) => !groepHeeftLeerplein(g.id)); return (

Groepsindeling

Sleep personen naar een bovenliggende groep, groep of subgroep. leerling en personeel hebben elk een eigen kleur. Vink meerdere personen aan om ze in één keer te plaatsen; × haalt iemand weer weg.

Tonen:
{[["allen", "Allen"], ["leerling", "Leerlingen"], ["personeel", "Personeel"]].map(([v, l]) => ( ))}
{selectie.size > 0 && (
{selectie.size} geselecteerd
)} {loading ?

Laden…

: (
{leerpleinen.map((lp) => { const mwIds = lp.medewerkers || []; const llIds = lp.leerlingen || []; return (
{lp.naam}
Direct in deze bovenliggende groep
{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 && sleep hier personen heen}
{lpGroepen(lp).map((g) => groepKaart(g))} nieuweGroep(lp, naam)} />
); })} {leerpleinen.length === 0 &&
Nog geen bovenliggende groepen. Maak er hierboven één aan.
} {orphanGroepen.length > 0 && (
Groepen zonder bovenliggende groep
{orphanGroepen.map((g) => groepKaart(g))}
)}
)} {ghost &&
{ghost.label}
}
); } /* ---------------------------------- Stage --------------------------------- */ // Module-scherm: interne en externe stages. const OMVANGEN = [ { value: "hele_dag", label: "Hele dag" }, { value: "dagdeel", label: "Dagdeel" }, { value: "blok", label: "Eén blok" }, ]; const DAGDELEN = [ { value: "ochtend", label: "Ochtend" }, { value: "middag", label: "Middag" }, ]; export function StagePage() { const { items, error, loading, reload, setError } = useList("stages"); const [leerlingen, setLeerlingen] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [lokalen, setLokalen] = useState([]); const [tijdsloten, setTijdsloten] = useState([]); const leeg = { leerling: "", type: "intern", begeleider: "", lokaal: "", organisatie: "", omvang: "hele_dag", dag: "", dagdeel: "", tijdslot: "", omschrijving: "" }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); useEffect(() => { listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("lokalen").then(setLokalen).catch(() => setLokalen([])); }, []); const isIntern = form.type === "intern"; const isBlok = form.omvang === "blok"; const isDagdeel = form.omvang === "dagdeel"; async function opslaan(e) { e.preventDefault(); const payload = { leerling: form.leerling, type: form.type, begeleider: form.begeleider, lokaal: isIntern ? (form.lokaal || null) : null, organisatie: isIntern ? "" : form.organisatie, omvang: form.omvang, dag: isBlok ? null : (form.dag === "" ? null : Number(form.dag)), dagdeel: isDagdeel ? form.dagdeel : "", tijdslot: isBlok ? (form.tijdslot || null) : null, omschrijving: form.omschrijving, }; try { if (editId) await updateResource("stages", editId, payload); else await createResource("stages", payload); setForm(leeg); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Deze stage verwijderen?")) return; try { await deleteResource("stages", id); await reload(); } catch (err) { setError(err.message); } } const dagLabel = (v) => (WEEKDAGEN.find((d) => d.value === v) || {}).label || "—"; const tijdslotLabel = (id) => { const t = tijdsloten.find((x) => x.id === id); return t ? `${t.dag_label || ""} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; }; function wanneerTekst(s) { if (s.omvang === "blok") return `Blok: ${s.tijdslot_label || tijdslotLabel(s.tijdslot)}`; if (s.omvang === "dagdeel") return `${dagLabel(s.dag)} — ${s.dagdeel_label || s.dagdeel}`; return `${dagLabel(s.dag)} — hele dag`; } return (

Stage

Interne en externe stages. Beide krijgen een begeleider; een interne stage gebruikt een lokaal (met dubbele-boekingcheck). Stel in per hele dag, dagdeel of los blok.

{isIntern ? ( ) : ( setForm({ ...form, organisatie: e.target.value })} /> )}
{isBlok ? ( ) : ( )} {isDagdeel && ( )}
setForm({ ...form, omschrijving: e.target.value })} />
{editId && }
{loading ?

Laden…

: ( {items.map((s) => ( ))} {items.length === 0 && }
LeerlingTypeBegeleiderWaarWanneer
{s.leerling_naam} {s.type_label} {s.begeleider_naam} {s.type === "intern" ? (s.lokaal_naam || "—") : (s.organisatie || "—")} {wanneerTekst(s)}
Nog geen stages.
)}
); } /* -------------------------------- Gebruikers ------------------------------ */ const GEBRUIKERSROLLEN = [ { value: "beheerder", label: "Beheerder" }, { value: "medewerker", label: "Medewerker" }, { value: "ouder", label: "Ouder" }, { value: "leerling", label: "Leerling" }, ]; export function GebruikersPage() { const { items, error, loading, reload, setError } = useList("gebruikers"); const [personen, setPersonen] = useState([]); const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); async function opslaan(e) { e.preventDefault(); const payload = { username: form.username, first_name: form.first_name, last_name: form.last_name, email: form.email, rol: form.rol, persoon: form.persoon || null, is_staff: form.is_staff, is_active: form.is_active, }; if (form.password) payload.password = form.password; try { if (editId) await updateResource("gebruikers", editId, payload); else await createResource("gebruikers", payload); setForm(leeg); setEditId(null); await reload(); } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Deze gebruiker verwijderen?")) return; try { await deleteResource("gebruikers", id); await reload(); } catch (err) { setError(err.message); } } function bewerk(u) { setEditId(u.id); setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active }); } return (

Gebruikers

Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.

setForm({ ...form, username: e.target.value })} /> setForm({ ...form, password: e.target.value })} />
setForm({ ...form, first_name: e.target.value })} /> setForm({ ...form, last_name: e.target.value })} /> setForm({ ...form, email: e.target.value })} />
{editId && }
{loading ?

Laden…

: ( {items.map((u) => ( ))} {items.length === 0 && }
GebruikerRolPersoonStatus
{u.username}{(u.first_name || u.last_name) &&
{u.first_name} {u.last_name}
}
{u.rol_label || "—"}{u.is_staff && beheer} {u.persoon_naam || "—"} {u.is_active ? "actief" : "inactief"}
Geen gebruikers.
)}
); } /* -------------------------------- Conflicten ------------------------------ */ function Conflicten({ schooljaar }) { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!schooljaar) return; listResource("conflicten", `?schooljaar=${schooljaar}`).then(setData).catch((e) => setError(e.message)); }, [schooljaar]); if (!schooljaar) return

Kies eerst een schooljaar.

; if (error) return ; if (!data) return

Laden…

; return (

{data.aantal === 0 ? "Geen conflicten gevonden 🎉" : `${data.fouten} fout(en) en ${data.waarschuwingen} waarschuwing(en).`}

{data.conflicten.map((c, i) => (
{c.tijdslot} {c.type}
{c.detail}
Blokken: {c.blokken.join(", ")}
))}
); } /* ------------------------------ Datumhulpjes ------------------------------ */ function pad2(n) { return String(n).padStart(2, "0"); } function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function fmtDM(d) { return `${pad2(d.getDate())}-${pad2(d.getMonth() + 1)}`; } function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; } function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; } /* ------------------------- Rooster-scope (filter/print) ------------------- */ // Welke leerlingen raakt een blok (via groep, subgroep of individueel)? export function leerlingenVanBlok(blok, ctx) { if (blok.groep) return ctx.leerlingen.filter((l) => l.groep === blok.groep).map((l) => l.id); if (blok.subgroep) { const s = ctx.subgroepen.find((x) => x.id === blok.subgroep); return s ? s.leden_namen.map((m) => m.id) : []; } if (blok.leerling) return [blok.leerling]; return []; } // 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); if (scope.type === "leerplein") { const p = ctx.leerpleinen.find((x) => x.id === scope.id); if (!p) return []; const ids = new Set(p.leerlingen || []); (p.groepen || []).forEach((g) => ctx.leerlingen.filter((l) => l.groep === g).forEach((l) => ids.add(l.id))); (p.subgroepen || []).forEach((sid) => { const s = ctx.subgroepen.find((x) => x.id === sid); if (s) s.leden_namen.forEach((m) => ids.add(m.id)); }); return [...ids]; } return []; } // Valt een blok binnen de gekozen scope? export function blokInScope(blok, scope, ctx) { if (scope.type === "alles" || !scope.id) return true; if (scope.type === "personeel") return (blok.begeleiders || []).includes(scope.id); const set = new Set(scopeLeerlingen(scope, ctx)); return leerlingenVanBlok(blok, ctx).some((id) => set.has(id)); } function groepVanSubgroep(subgroepen, subgroepId) { const sub = subgroepen.find((s) => s.id === subgroepId); return sub ? sub.groep : null; } function bovenliggendeGroep(blok, subgroepen) { if (blok.groep) return blok.groep; if (blok.subgroep) return groepVanSubgroep(subgroepen, blok.subgroep); return null; } /* ----------------------------- Blok bewerken/kopiëren --------------------- */ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { const { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx; const [f, setF] = useState({ tijdslot: blok.tijdslot, activiteit: blok.activiteit, locatie: blok.locatie || "", lokaal: blok.lokaal || "", doel_type: blok.doel_type, doel: blok.groep || blok.subgroep || blok.leerling || "", begeleiders: [...(blok.begeleiders || [])], opmerkingen: blok.opmerkingen || "", }); const [kopieDoelen, setKopieDoelen] = useState([]); const [error, setError] = useState(null); const [subs, setSubs] = useState(subgroepen); const [nieuweSub, setNieuweSub] = useState(""); const [splitSubs, setSplitSubs] = useState([]); const [splitNieuw, setSplitNieuw] = useState(""); const [verwijderOrig, setVerwijderOrig] = useState(true); const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => ( blok.subgroep ? groepVanSubgroep(subgroepen, blok.subgroep) || "" : "" )); const gekozenGroep = f.doel_type === "groep" ? (f.doel ? Number(f.doel) : null) : f.doel_type === "subgroep" ? (subgroepGroepFilter || groepVanSubgroep(subs, Number(f.doel))) : bovenliggendeGroep(blok, subs); const subgroepOpties = gekozenGroep ? subs.filter((s) => s.groep === gekozenGroep) : subs; const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepOpties : leerlingen; const splitGroep = gekozenGroep || blok.groep || groepVanSubgroep(subs, blok.subgroep) || null; const splitOpties = splitGroep ? subs.filter((s) => s.groep === splitGroep) : subs; const splitGroepNaam = splitGroep ? (groepen.find((g) => g.id === splitGroep)?.naam || "groep") : "groep"; async function maakSubgroep() { if (!nieuweSub.trim()) return; try { const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: gekozenGroep || null }); setSubs([...subs, s]); setSubgroepGroepFilter(s.groep || ""); setF({ ...f, doel_type: "subgroep", doel: s.id }); setNieuweSub(""); } catch (e) { setError(e.message); } } const toggleSplit = (id) => setSplitSubs((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]); async function maakSplitSub() { if (!splitNieuw.trim()) return; try { const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroep }); setSubs([...subs, s]); setSplitSubs([...splitSubs, s.id]); setSplitNieuw(""); } catch (e) { setError(e.message); } } async function opdelen() { if (splitSubs.length === 0) return; try { for (const sid of splitSubs) { await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); } if (verwijderOrig) await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); } } const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] }); const toggleKopie = (id) => setKopieDoelen((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]); const doelPayload = () => ({ groep: f.doel_type === "groep" ? f.doel : null, subgroep: f.doel_type === "subgroep" ? f.doel : null, leerling: f.doel_type === "leerling" ? f.doel : null, }); async function opslaan() { try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } catch (e) { setError(e.message); } } async function kopieer() { try { for (const ts of kopieDoelen) { await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); } await onKlaar(); } catch (e) { setError(e.message); } } async function verwijderen() { if (!confirm("Dit blok (alle weken) verwijderen?")) return; try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); } } return (
e.stopPropagation()}>

{blok.activiteit_naam || "Roosterblok"}

{blok.doel_naam || "Bewerken en splitsen"}
Vak bewerken Wijzig, splits of kopieer dit blok
setF({ ...f, opmerkingen: e.target.value })} />
Doelgroep en begeleiding
{f.doel_type === "subgroep" && ( <> setNieuweSub(e.target.value)} /> )}
{medewerkers.map((m) => )}
Splitsen onder {splitGroepNaam}
{splitOpties.map((s) => )} {splitOpties.length === 0 && Nog geen subgroepen onder deze groep.}
{splitSubs.length > 0 && (
{splitGroepNaam}
{splitSubs.map((sid) => { const sub = splitOpties.find((s) => s.id === sid) || subs.find((s) => s.id === sid); return {sub ? sub.naam : "Subgroep"}; })}
)}
setSplitNieuw(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakSplitSub(); } }} />
Kopiëren
{tijdsloten.map((t) => )}
); } /* ------------------------------- Groepsindeling --------------------------- */ // 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"); const [hoofdId, setHoofdId] = useState(""); const [nieuw, setNieuw] = useState({ naam: "", groep: "", begeleiders: [] }); const [error, setError] = useState(null); const opties = type === "groep" ? groepen : leerpleinen; const hid = hoofdId ? Number(hoofdId) : null; const hoofd = opties.find((o) => o.id === hid); let subs = []; if (hid && type === "groep") subs = subgroepen.filter((s) => s.groep === hid); if (hid && type === "leerplein" && hoofd) subs = subgroepen.filter((s) => (hoofd.subgroepen || []).includes(s.id)); const leerpleinGroepen = type === "leerplein" && hoofd ? groepen.filter((g) => (hoofd.groepen || []).includes(g.id)) : []; const groepenMetSubgroepen = type === "leerplein" ? leerpleinGroepen.map((g) => ({ groep: g, subs: subgroepen.filter((s) => s.groep === g.id) })) : []; const vakkenVan = (subId) => [...new Set(blokken.filter((b) => b.subgroep === subId).map((b) => b.activiteit_naam))]; const toggleBeg = (id) => setNieuw((n) => n.begeleiders.includes(id) ? { ...n, begeleiders: n.begeleiders.filter((x) => x !== id) } : { ...n, begeleiders: [...n.begeleiders, id] }); async function voegToe(e) { e.preventDefault(); try { await createResource("subgroepen", { naam: nieuw.naam, groep: type === "groep" ? hid : nieuw.groep, begeleiders: nieuw.begeleiders }); setNieuw({ naam: "", groep: "", begeleiders: [] }); await onWijzig(); } catch (err) { setError(err.message); } } async function verwijderSub(id) { if (!confirm("Deze subgroep verwijderen?")) return; try { await deleteResource("subgroepen", id); await onWijzig(); } catch (err) { setError(err.message); } } return (

Kies een hoofdgroep of bovenliggende groep en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.

{hid && hoofd && (
{hoofd.naam} — {type}
{type === "leerplein" ? (
{groepenMetSubgroepen.map(({ groep, subs: groepSubs }) => (
{groep.naam}
{groepSubs.map((s) => (
{s.naam}
Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}
Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}
{s.aantal_leden} leerling(en)
))} {groepSubs.length === 0 &&
Nog geen subgroepen onder {groep.naam}.
}
))} {leerpleinGroepen.length === 0 &&
Nog geen groepen onder {hoofd.naam}.
}
) : (
{subs.map((s) => (
{s.naam}
Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}
Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}
{s.aantal_leden} leerling(en)
))} {subs.length === 0 &&
Nog geen subgroepen onder {hoofd.naam}.
}
)}
Subgroep toevoegen onder {type === "groep" ? hoofd.naam : `een groep binnen deze ${BOVENGROEP_LABEL_LC}`}:
{type === "leerplein" && ( )} setNieuw({ ...nieuw, naam: e.target.value })} />
Verantwoordelijke(n):
{medewerkers.map((m) => )}
)}
); } /* ---------------------------------- Rooster ------------------------------- */ // Eén component met twee modi: // - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen) // - mode="edit" -> Roostermaker (volledige bewerking + drag-and-drop) export function RoosterScherm({ mode }) { const edit = mode === "edit"; const [printAan, setPrintAan] = useState(false); const [schooljaren, setSchooljaren] = useState([]); const [schooljaar, setSchooljaar] = useState(""); const [tijdsloten, setTijdsloten] = useState([]); const [activiteiten, setActiviteiten] = useState([]); const [locaties, setLocaties] = useState([]); const [lokalen, setLokalen] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [blokken, setBlokken] = useState([]); const [kalender, setKalender] = useState([]); const [afwezig, setAfwezig] = useState([]); const [uitzonderingen, setUitzonderingen] = useState([]); const [stages, setStages] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]); const [maandag, setMaandag] = useState(() => maandagVan(new Date())); const [toonForm, setToonForm] = useState(false); const [tab, setTab] = useState("week"); const [error, setError] = useState(null); // 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). const [bewerkBlok, setBewerkBlok] = useState(null); const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", 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); const laadRef = useRef(() => {}); const [ghost, setGhost] = useState(null); const [hoverSlot, setHoverSlot] = useState(null); useEffect(() => { listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message)); listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("activiteiten").then(setActiviteiten).catch(() => {}); listResource("locaties").then(setLocaties).catch(() => {}); listResource("lokalen").then(setLokalen).catch(() => {}); listResource("groepen").then(setGroepen).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); listResource("stages", "?actief=1").then(setStages).catch(() => setStages([])); // Afdrukken alleen tonen als de print-module aanstaat. getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {}); }, []); // Scope-context + gefilterde blokken (voor weergave/afdrukken). const scopeCtx = { leerlingen, subgroepen, leerpleinen }; const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" }; const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx)); const scopeOpties = scopeType === "klas" ? groepen : scopeType === "leerplein" ? leerpleinen : scopeType === "leerling" ? leerlingen : scopeType === "personeel" ? medewerkers : []; const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i)); const vanISO = fmtISO(maandag); const totISO = fmtISO(plusDagen(maandag, 6)); async function laadBlokken() { if (!schooljaar) { setBlokken([]); return; } try { setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`)); setKalender(await listResource("kalenderdagen", `?schooljaar=${schooljaar}`)); setError(null); } catch (e) { setError(e.message); } } async function laadWeek() { if (!schooljaar) return; try { setAfwezig(await listResource("afwezigheden", `?van=${vanISO}&tot=${totISO}`)); setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`)); } catch (e) { setError(e.message); } } async function herlaadNaBlokWijziging() { try { setSubgroepen(await listResource("subgroepen")); } catch { /* blokken verversen blijft leidend */ } await laadBlokken(); } laadRef.current = laadBlokken; useEffect(() => { laadBlokken(); }, [schooljaar]); useEffect(() => { laadWeek(); }, [schooljaar, vanISO]); // Listeners voor drag-and-drop, één keer opgezet. useEffect(() => { function move(e) { if (!dragRef.current) return; setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.activiteit_naam }); const el = document.elementFromPoint(e.clientX, e.clientY); const cell = el && el.closest("[data-tijdslot]"); setHoverSlot(cell ? Number(cell.dataset.tijdslot) : null); } async function up(e) { if (!dragRef.current) return; const el = document.elementFromPoint(e.clientX, e.clientY); const cell = el && el.closest("[data-tijdslot]"); const doel = cell ? Number(cell.dataset.tijdslot) : null; const blok = dragRef.current; dragRef.current = null; setGhost(null); setHoverSlot(null); if (doel && doel !== blok.tijdslot) { try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); } catch (err) { setError(err.message); } } } window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, []); // Sneltoetsen (alleen in de Roostermaker). useEffect(() => { if (!edit) return; 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); sluitNieuwBlok(); return; } if (typing) return; 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())); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [edit]); function startDrag(e, blok) { e.preventDefault(); dragRef.current = blok; setGhost({ x: e.clientX, y: e.clientY, label: blok.activiteit_naam }); 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 basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || 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; try { await deleteResource("roosterblokken", id); await laadBlokken(); } catch (err) { setError(err.message); } } async function laatVervallen(blok, iso) { try { await createResource("blok-uitzonderingen", { roosterblok: blok.id, datum: iso, vervalt: true }); await laadWeek(); } catch (err) { setError(err.message); } } async function herstel(uitId) { try { await deleteResource("blok-uitzonderingen", uitId); await laadWeek(); } catch (err) { setError(err.message); } } async function dagVervallen(iso) { try { await createResource("kalenderdagen", { schooljaar, datum: iso, type: "vrije_dag", omschrijving: "vervallen" }); await laadBlokken(); } catch (err) { setError(err.message); } } async function dagHerstel(kalId) { try { await deleteResource("kalenderdagen", kalId); await laadBlokken(); } catch (err) { setError(err.message); } } // Hulpfuncties voor het effectieve rooster. const kalenderOp = (iso) => kalender.find((k) => k.datum === iso); const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso); // Op id vergelijken, niet op naam: twee personen kunnen dezelfde naam hebben. const persoonAfwezig = (id, iso) => afwezig.some((a) => a.persoon === id && a.start_datum <= iso && a.eind_datum >= iso); const rolAfk = (id) => { const m = medewerkers.find((x) => x.id === id); return m ? (ROL_AFK[m.rol] || "") : ""; }; // Stage: welke leerlingen zijn op een gegeven tijdslot op stage (en dus niet in de klas)? const tijdslotById = (id) => tijdsloten.find((t) => t.id === id); const leerlingNaam = (id) => { const l = leerlingen.find((x) => x.id === id); return l ? l.volledige_naam : "?"; }; function stageDektTijdslot(stage, t) { if (!t) return false; if (stage.omvang === "blok") return stage.tijdslot === t.id; if (stage.dag === null || stage.dag === undefined || stage.dag !== t.dag) return false; if (stage.omvang === "hele_dag") return true; const isOchtend = (t.start_tijd || "") < "12:00"; return (stage.dagdeel === "ochtend" && isOchtend) || (stage.dagdeel === "middag" && !isOchtend); } function leerlingenOpStageBij(t) { const set = new Set(); for (const st of stages) { if (stageDektTijdslot(st, t)) set.add(st.leerling); } return set; } function stageNoot(b) { const t = tijdslotById(b.tijdslot); const opStage = leerlingenOpStageBij(t); if (opStage.size === 0) return null; if (b.doel_type === "leerling") { return opStage.has(b.leerling) ?
📍 Op stage — niet in de klas
: null; } const namen = leerlingenVanBlok(b, scopeCtx).filter((id) => opStage.has(id)).map(leerlingNaam); if (namen.length === 0) return null; return
📍 Op stage: {namen.join(", ")}
; } const editorCtx = { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }; const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id; const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b)); function renderRoosterBlok(b, iso, kal) { const uit = uitzOp(b.id, iso); const vervallen = !!kal || (uit && uit.vervalt); return (
toggleOpenBlok(b) : undefined} title={edit ? "Klik om te bewerken" : undefined} >
{edit && startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿} {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} {edit && } {edit && }
Doel: {b.doel_naam} ({b.doel_type})
{b.locatie_naam &&
Locatie: {b.locatie_naam}
} {b.lokaal_naam &&
Lokaal: {b.lokaal_naam}
} {b.begeleiders_namen.length > 0 && (
Begeleiding: {b.begeleiders_namen.map((x, idx) => ( {idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""} ))}
)} {stageNoot(b)} {kal &&
Vervalt - {kal.type_label}
} {!kal && uit && uit.vervalt &&
Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && }
} {!kal && uit && !uit.vervalt && uit.vervanger_naam &&
Vervanger: {uit.vervanger_naam}
} {edit && !kal && !uit && ( )}
); } function renderCompactBlok(b, iso, kal) { const uit = uitzOp(b.id, iso); const vervallen = !!kal || (uit && uit.vervalt); return (
toggleOpenBlok(b) : undefined} title={edit ? "Klik om te bewerken" : undefined} >
{edit && startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿} {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} {edit && } {edit && }
{b.locatie_naam && {b.locatie_naam}} {b.lokaal_naam && {b.lokaal_naam}} {b.begeleiders_namen.length > 0 && {b.begeleiders_namen.map((x) => x.naam).join(", ")}} {vervallen && Vervalt}
{stageNoot(b)}
); } function renderSlotBlokken(slotBlokken, iso, kal) { const losse = []; const perGroep = new Map(); slotBlokken.forEach((b) => { const gid = bovenliggendeGroep(b, subgroepen); if (!gid) { losse.push(b); return; } if (!perGroep.has(gid)) perGroep.set(gid, []); perGroep.get(gid).push(b); }); const out = losse.map((b) => renderRoosterBlok(b, iso, kal)); if (scopeType === "leerplein" && scopeId && perGroep.size > 0) { const leerplein = leerpleinen.find((l) => l.id === Number(scopeId)); const kolommen = [...perGroep.entries()]; out.push(
{leerplein ? leerplein.naam : BOVENGROEP_LABEL} {BOVENGROEP_LABEL} → groepen
{kolommen.length}
{kolommen.map(([gid, groepBlokken]) => { const groep = groepen.find((g) => g.id === gid); return (
{groep ? groep.naam : "Groep"}
{groepBlokken.map((b) => renderCompactBlok(b, iso, kal))}
); })}
); return out; } perGroep.forEach((groepBlokken, gid) => { const heeftSplitsing = groepBlokken.some((b) => b.subgroep); if (!heeftSplitsing) { groepBlokken.forEach((b) => out.push(renderRoosterBlok(b, iso, kal))); return; } const groep = groepen.find((g) => g.id === gid); const heleGroep = groepBlokken.filter((b) => b.groep === gid); const subIds = [...new Set(groepBlokken.filter((b) => b.subgroep).map((b) => b.subgroep))]; const kolomAantal = subIds.length + (heleGroep.length > 0 ? 1 : 0); out.push(
{groep ? groep.naam : "Groep"} Groep → subgroepen
{kolomAantal}
{heleGroep.length > 0 && (
Hele groep
{heleGroep.map((b) => renderCompactBlok(b, iso, kal))}
)} {subIds.map((sid) => { const sub = subgroepen.find((s) => s.id === sid); const blocks = groepBlokken.filter((b) => b.subgroep === sid); return (
{sub ? sub.naam : "Subgroep"}
{blocks.map((b) => renderCompactBlok(b, iso, kal))}
); })}
); }); return out; } return (

{edit ? "Roostermaker" : "Rooster"}

{edit ? "Sleep een blok naar een ander tijdslot om het te verplaatsen. Vrije dagen, afwezigheid en uitzonderingen worden meegenomen." : "Bekijk het weekrooster. Vrije dagen, afwezigheid en uitzonderingen zijn verwerkt."}

{schooljaar && (
week van {fmtDM(maandag)}
)} {edit && schooljaar && tab === "week" && } {printAan && schooljaar && (edit ? tab === "week" : true) && ( )}
{schooljaar && (edit ? tab === "week" : true) && (
{scopeType !== "alles" && ( )} {scopeType !== "alles" && scopeId && {zichtbareBlokken.length} blok(ken)} {edit && groepen.length > 0 && (
{groepen.map((g) => ( ))}
)}
)} {edit && (
)} {edit && tab === "week" && (

Sneltoetsen: N nieuw blok · / week · T deze week · Esc sluiten

)} {edit && schooljaar && tab === "week" && toonForm && (
e.stopPropagation()}>

Vak toevoegen

Maak één blok of splits direct over subgroepen.
Basis Tijd, vak en doel
setForm({ ...form, opmerkingen: e.target.value })} />
Doelgroep
{form.doel_type === "groep" && form.doel && (
Groep direct splitsen {splitNieuwBlok && ( <>
{splitNieuweOpties.map((s) => )} {splitNieuweOpties.length === 0 && Nog geen subgroepen onder deze groep.}
{splitNieuwDoelen.length > 0 && (
{splitNieuweGroep?.naam || "Groep"}
{splitNieuwDoelen.map((sid) => {splitNieuweOpties.find((s) => s.id === sid)?.naam || "Subgroep"})}
)} )}
)}
Begeleiders
{medewerkers.map((m) => )} {medewerkers.length === 0 && Nog geen medewerkers.}
)} {schooljaar && tab === "week" && (
{WEEKDAGEN.map((d, i) => { const datum = weekDatums[i]; const iso = fmtISO(datum); const kal = kalenderOp(iso); const slots = tijdsloten.filter((t) => t.dag === d.value).sort((a, b) => a.volgorde - b.volgorde); return (
{d.label} {fmtDM(datum)} {kal &&
{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}
} {edit && (kal ? : )}
{slots.length === 0 &&
} {slots.map((t) => { const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id); return (
{t.start_tijd?.slice(0, 5)} {t.naam}
{renderSlotBlokken(slotBlokken, iso, kal)}
); })}
); })}
)} {schooljaar && tab === "conflicten" && } {tab === "schooljaar" && (

Maak hier snel een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).

)} {tab === "instellingen" && (

Bouwstenen van het rooster — beheer ze hier zonder het Rooster te verlaten.

)} {edit && bewerkBlok && ( setBewerkBlok(null)} onKlaar={async () => { setBewerkBlok(null); await herlaadNaBlokWijziging(); }} /> )} {edit && ghost &&
{ghost.label}
}
); } // Twee schermen op basis van dezelfde component. export function RoosterWeergavePage() { return ; } export function RoostermakerPage() { return ; } /* -------------------------------- Urenoverzicht --------------------------- */ export function UrenPage() { const [schooljaren, setSchooljaren] = useState([]); const [schooljaar, setSchooljaar] = useState(""); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { listResource("schooljaren") .then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }) .catch((e) => setError(e.message)); }, []); useEffect(() => { if (!schooljaar) { setData(null); return; } setLoading(true); listResource("urenoverzicht", `?schooljaar=${schooljaar}`) .then((d) => { setData(d); setError(null); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, [schooljaar]); return (

Urenoverzicht

Lesuren per groep en per leerling over het hele schooljaar. Vrije dagen, vakanties en studiedagen tellen niet mee; vervallen blokken zijn afgetrokken. Stage-uren tellen mee bij de leerling.

{loading ?

Laden…

: data && ( <>

Per groep

{data.per_groep.length === 0 && } {data.per_groep.flatMap((g) => [ ...g.vakken.map((v, i) => ( )), , ])}
GroepVakUren (schooljaar)
Geen lesblokken gevonden.
{i === 0 ? {g.groep} : ""} {vakIcoon(v.vak)} {v.vak} {v.uren} u
totaal{g.totaal} u

Per leerling

{data.per_leerling.length === 0 && } {data.per_leerling.map((l) => ( ))}
LeerlingGroepVakkenStageTotaal
Geen leerlingen met uren.
{l.leerling} {l.groep || "—"} {l.vakken.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"} {l.stage_uren ? `${l.stage_uren} u` : "—"} {l.totaal} u
)}
); }