import { Fragment, useEffect, useRef, useState } from "react"; import { listResource, createResource, updateResource, deleteResource, getModules, getAuditLog, getLokaalinstelling, setLokaalinstelling, exportUrl, importPersonenCsv, getPortal, getVervangSuggesties, pasVervangingToe, uploadBestand, wachtwoordReset, adminWachtwoordReset, gebruikersImport, getHuisstijl, uploadLogo, verwijderLogo, opslaanVormgeving, } 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, metPictogram = 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) + (metPictogram ? 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); } } async function uploadPicto(id, file) { try { await uploadBestand(resource, id, "pictogram", file); await reload(); } catch (err) { setError(err.message); } } async function verwijderPicto(id) { try { await updateResource(resource, id, { pictogram: null }); 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 && } {metPictogram && } {telKolom && } {items.map((it) => ( {metTheorievak && } {metPictogram && ( )} {telKolom && } ))} {items.length === 0 && ( )}
Naam OmschrijvingTheorievakPictogram{telKolom}
{kleur && } {metIcoon && {it.icoon || vakIcoon(it.naam)} } {it.naam} {it.omschrijving || "—"}{it.theorievak ? "✓" : "—"} {it.pictogram && } {it.pictogram && } {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(""); const [wizardAan, setWizardAan] = useState(false); const [importView, setImportView] = useState(false); 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([])); getModules().then((ms) => setWizardAan(ms.some((m) => m.key === "personenwizard" && m.enabled))).catch(() => {}); } 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.

{wizardAan && }
{importView ? : (<>
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: "roostermaker", label: "Roostermaker" }, { value: "directie", label: "Directie" }, { value: "zorgcoordinator", label: "Zorgcoordinator" }, { value: "stagebegeleider", label: "Stagebegeleider" }, { value: "kijker", label: "Alleen lezen" }, { 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); const [gegenereerd, setGegenereerd] = useState(null); const [view, setView] = useState("beheer"); useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); function kiesPersoon(id) { const pp = personen.find((x) => x.id === Number(id)); setForm((f) => { const nf = { ...f, persoon: id }; if (pp) { if (!f.first_name) nf.first_name = pp.voornaam || ""; if (!f.last_name) nf.last_name = pp.achternaam || ""; if (!f.username) nf.username = `${pp.voornaam || ""}.${pp.achternaam || ""}`.toLowerCase().replace(/\s+/g, ""); } return nf; }); } async function genereerWachtwoord() { setError(null); setGegenereerd(null); if (editId) { try { const r = await adminWachtwoordReset(editId); setGegenereerd(r.wachtwoord); } catch (e) { setError(e.message); } } else { const tekens = "abcdefghjkmnpqrstuvwxyz23456789"; const bytes = (window.crypto || {}).getRandomValues ? window.crypto.getRandomValues(new Uint8Array(10)) : Array.from({ length: 10 }, () => Math.floor(Math.random() * 256)); const w = Array.from(bytes).map((b) => tekens[b % tekens.length]).join(""); setForm((f) => ({ ...f, password: w })); setGegenereerd(w); } } 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); setGegenereerd(null); 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; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.

{gegenereerd &&
Tijdelijk wachtwoord: {gegenereerd} — geef dit door aan de gebruiker; laat het daarna wijzigen.
}
{view === "import" ? : (<>
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)}`; } const MAANDEN = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"]; function isoWeeknr(d) { const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dag = (t.getUTCDay() + 6) % 7; t.setUTCDate(t.getUTCDate() - dag + 3); const eersteDo = new Date(Date.UTC(t.getUTCFullYear(), 0, 4)); return 1 + Math.round(((t - eersteDo) / 86400000 - 3 + ((eersteDo.getUTCDay() + 6) % 7)) / 7); } function weekTitel(maandag) { const vrij = plusDagen(maandag, 4); const reeks = maandag.getMonth() === vrij.getMonth() ? `${maandag.getDate()}\u2013${vrij.getDate()} ${MAANDEN[vrij.getMonth()]}` : `${maandag.getDate()} ${MAANDEN[maandag.getMonth()]} \u2013 ${vrij.getDate()} ${MAANDEN[vrij.getMonth()]}`; return `Week ${isoWeeknr(maandag)} \u00b7 ${reeks}`; } 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)); } // Bereken conflicten direct uit de geladen blokken (zelfde tijdslot): begeleider // dubbel, lokaal dubbel of een leerling dubbel ingeroosterd. Houdt rekening met // geldigheidsperiodes (blokken die elkaar niet in de tijd overlappen botsen niet). // Leesbare tekstkleur (zwart of wit) op een gegeven achtergrondkleur, op basis // van de waargenomen helderheid — zo blijft de tekst altijd genoeg contrast houden. export function tekstOpKleur(hex) { if (!hex || typeof hex !== "string") return ""; const m = hex.replace("#", "").trim(); const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m; if (v.length < 6) return ""; const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16); if ([r, g, b].some(Number.isNaN)) return ""; const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum > 0.6 ? "#16181d" : "#ffffff"; } export function berekenRoosterConflicten(blokken, ctx, tijdsloten) { const blokIds = new Set(); const teksten = new Map(); // tekst -> ernst (dedupe) const slotLabel = (id) => { const t = (tijdsloten || []).find((x) => x.id === id); if (!t) return ""; const dag = (WEEKDAGEN.find((d) => d.value === t.dag) || {}).label || ""; return `${dag} ${(t.start_tijd || "").slice(0, 5)}`.trim(); }; const leerlingNaam = (id) => { const l = (ctx.leerlingen || []).find((x) => x.id === id); return l ? l.volledige_naam : "Leerling"; }; const overlapt = (a, b) => { const at = a.geldig_tot || null, av = a.geldig_van || null; const bt = b.geldig_tot || null, bv = b.geldig_van || null; if (at && bv && at < bv) return false; if (bt && av && bt < av) return false; return true; }; const perSlot = new Map(); blokken.forEach((b) => { if (!perSlot.has(b.tijdslot)) perSlot.set(b.tijdslot, []); perSlot.get(b.tijdslot).push(b); }); perSlot.forEach((lijst, slotId) => { if (lijst.length < 2) return; const lab = slotLabel(slotId); const flag = (a, b, tekst, ernst) => { if (!overlapt(a, b)) return; blokIds.add(a.id); blokIds.add(b.id); if (!teksten.has(tekst)) teksten.set(tekst, ernst); }; for (let i = 0; i < lijst.length; i++) { for (let j = i + 1; j < lijst.length; j++) { const a = lijst[i], b = lijst[j]; (a.begeleiders_namen || []).forEach((p) => { if ((b.begeleiders_namen || []).some((q) => q.id === p.id)) { flag(a, b, `${p.naam} staat dubbel ingepland — ${lab}`, "fout"); } }); if (a.lokaal && b.lokaal && a.lokaal === b.lokaal) { flag(a, b, `Lokaal ${a.lokaal_naam} dubbel geboekt — ${lab}`, "waarschuwing"); } const lb = new Set(leerlingenVanBlok(b, ctx)); leerlingenVanBlok(a, ctx) .filter((id) => lb.has(id)) .forEach((id) => flag(a, b, `${leerlingNaam(id)} is dubbel ingeroosterd — ${lab}`, "fout")); } } }); const meldingen = [...teksten.entries()] .map(([tekst, ernst]) => ({ tekst, ernst })) .sort((x, y) => (x.ernst === y.ernst ? 0 : x.ernst === "fout" ? -1 : 1)); return { blokIds, meldingen }; } 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 || "", geldig_van: blok.geldig_van || "", geldig_tot: blok.geldig_tot || "", }); 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 [splitGroepId, setSplitGroepId] = useState(() => String( blok.groep || groepVanSubgroep(subgroepen, blok.subgroep) || (blok.leerling ? ((leerlingen.find((l) => l.id === blok.leerling) || {}).groep || "") : "") || "" )); 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 splitGroepNum = splitGroepId ? Number(splitGroepId) : null; const splitOpties = splitGroepNum ? subs.filter((s) => s.groep === splitGroepNum) : []; const splitGroepNaam = splitGroepNum ? (groepen.find((g) => g.id === splitGroepNum)?.naam || "groep") : "(kies groep)"; async function maakSubgroep() { if (!nieuweSub.trim()) return; const g = subgroepGroepFilter || gekozenGroep; if (!g) { setError("Kies eerst een groep voor de nieuwe subgroep."); return; } try { const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: Number(g) }); 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; if (!splitGroepNum) { setError("Kies eerst een groep om onder te splitsen."); return; } try { const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroepNum }); 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 , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); } 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 , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); 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 , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); } 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 })} /> setF({ ...f, geldig_van: e.target.value })} /> setF({ ...f, geldig_tot: 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 [blokwizardAan, setBlokwizardAan] = useState(false); const [autoroosterAan, setAutoroosterAan] = 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: "", geldig_van: "", geldig_tot: "" }; 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); const [conflictOpen, setConflictOpen] = useState(false); const [genOpen, setGenOpen] = useState(false); const [weergave, setWeergaveState] = useState(() => { try { return localStorage.getItem("rw_weergave") || "kolom"; } catch { return "kolom"; } }); const setWeergave = (v) => { setWeergaveState(v); try { localStorage.setItem("rw_weergave", v); } catch { /* niet beschikbaar */ } }; const [vakkleurVlak, setVakkleurState] = useState(() => { try { return localStorage.getItem("rw_vakkleur") === "vlak"; } catch { return false; } }); const setVakkleur = (v) => { setVakkleurState(v); try { localStorage.setItem("rw_vakkleur", v ? "vlak" : "streep"); } catch { /* niet beschikbaar */ } }; const [detailsOpen, setDetailsOpenState] = useState(() => { try { return localStorage.getItem("rw_rooster_details") !== "0"; } catch { return true; } }); const setDetailsOpen = (v) => { setDetailsOpenState(v); try { localStorage.setItem("rw_rooster_details", v ? "1" : "0"); } catch { /* niet beschikbaar */ } }; // Printopties const [printOpen, setPrintOpen] = useState(false); const [printPending, setPrintPending] = useState(false); const [printAllesActief, setPrintAllesActief] = useState(false); const [printKop, setPrintKop] = useState(""); const [printLegenda, setPrintLegenda] = useState(false); const [printOpts, setPrintOpts] = useState({ bereik: "huidig", indeling: "kolom", kleur: false, begeleiding: true, lokaal: true, legenda: false, paginaPerDag: false, papier: "A4", orientatie: "landscape" }); 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)); setBlokwizardAan(ms.some((m) => m.key === "blokwizard" && m.enabled)); setAutoroosterAan(ms.some((m) => m.key === "autorooster" && m.enabled)); }).catch(() => {}); }, []); // Scope-context + gefilterde blokken (voor weergave/afdrukken). const scopeCtx = { leerlingen, subgroepen, leerpleinen }; const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" }; // Bij printen "alle klassen" tijdelijk de scope negeren. const effScope = printAllesActief ? { type: "alles", id: "" } : scope; const zichtbareBlokken = blokken.filter((b) => blokInScope(b, effScope, scopeCtx)); // Distinct vakken (voor de optionele kleurenlegenda bij het afdrukken). const legendaItems = (() => { const seen = new Set(); const out = []; zichtbareBlokken.forEach((b) => { if (!seen.has(b.activiteit_naam)) { seen.add(b.activiteit_naam); out.push({ naam: b.activiteit_naam, kleur: b.activiteit_kleur, icoon: b.activiteit_icoon }); } }); return out.sort((a, x) => a.naam.localeCompare(x.naam)); })(); // Conflicten over álle blokken (niet alleen de zichtbare scope), zodat de // melding compleet is en de betrokken blokken in het rooster omlijnd worden. const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten); 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 (cell && cell.dataset.vrij) { setError("Op een vrije/vakantiedag kun je geen blok plaatsen."); return; } 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); } // Print: pas de gekozen opties toe via body-classes + @page, dan afdrukken. function zetPaginaStijl(papier, orientatie) { let el = document.getElementById("rw-print-page"); if (!el) { el = document.createElement("style"); el.id = "rw-print-page"; document.head.appendChild(el); } el.textContent = `@page { size: ${papier} ${orientatie}; margin: 10mm; }`; } function startPrint() { const b = document.body.classList; b.toggle("print-lijst", printOpts.indeling === "lijst"); b.toggle("print-kolom", printOpts.indeling === "kolom"); b.toggle("print-kleur", !!printOpts.kleur); b.toggle("print-geen-begeleiding", !printOpts.begeleiding); b.toggle("print-geen-lokaal", !printOpts.lokaal); b.toggle("print-paginadag", !!printOpts.paginaPerDag); zetPaginaStijl(printOpts.papier, printOpts.orientatie); setPrintKop(printOpts.kop || ""); setPrintLegenda(!!printOpts.legenda); if (printOpts.bereik === "alles") setPrintAllesActief(true); setPrintOpen(false); setPrintPending(true); } useEffect(() => { if (!printPending) return; const id = setTimeout(() => { window.print(); setPrintPending(false); setPrintAllesActief(false); }, 120); return () => clearTimeout(id); }, [printPending]); // Direct vanuit een tijdvak in het rooster een vak toevoegen: tijdslot vast, // en het doel alvast op de actieve scope (klas/leerling) zetten. function openNieuwBlokVoorSlot(tijdslotId) { setBewerkBlok(null); const basis = { ...leeg, tijdslot: String(tijdslotId) }; if (scopeType === "klas" && scopeId) { basis.doel_type = "groep"; basis.doel = String(scopeId); } else if (scopeType === "leerling" && scopeId) { basis.doel_type = "leerling"; basis.doel = String(scopeId); } setForm(basis); resetNieuweSplit(); 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, geldig_van: form.geldig_van || null, geldig_tot: form.geldig_tot || null }; 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); } } // Geldt dit blok op deze datum (periode / eenmalig)? function blokGeldigOp(b, iso) { if (b.geldig_van && iso < b.geldig_van) return false; if (b.geldig_tot && iso > b.geldig_tot) return false; return true; } // Maak één specifieke dag leeg: laat alle blokken die dag vervallen (alleen // deze datum), zonder gevolgen voor andere dagen. async function leegDag(iso) { const ourWd = (new Date(iso).getDay() + 6) % 7; const dagBlokken = blokken.filter((b) => { const t = tijdsloten.find((x) => x.id === b.tijdslot); if (!t || t.dag !== ourWd || !blokGeldigOp(b, iso)) return false; const u = uitzOp(b.id, iso); return !(u && u.vervalt); }); if (dagBlokken.length === 0) { setError("Geen blokken om te laten vervallen op deze dag."); return; } if (!confirm(`Alle ${dagBlokken.length} blok(ken) op ${iso} laten vervallen? Dit geldt alleen voor deze dag.`)) return; try { for (const b of dagBlokken) await createResource("blok-uitzonderingen", { roosterblok: b.id, datum: iso, vervalt: true }); await laadWeek(); } catch (err) { setError(err.message); } } // Voeg een eenmalig (alleen vandaag geldig) blok toe op deze dag. function openEenmalig(iso) { setBewerkBlok(null); setForm({ ...leeg, geldig_van: iso, geldig_tot: iso }); resetNieuweSplit(); setToonForm(true); } // 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)); // Eén uniform vak-kaartje, gebruikt in álle situaties (los vak of vak per // subgroep) zodat de weergave overal hetzelfde oogt. function renderVakKaart(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_pictogram && } {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} {edit && } {edit && }
{(b.locatie_naam || b.lokaal_naam || b.begeleiders_namen.length > 0) && (
{b.locatie_naam && 📍 {b.locatie_naam}} {b.lokaal_naam && 🚪 {b.lokaal_naam}} {b.begeleiders_namen.length > 0 && ( 👤 {b.begeleiders_namen.map((x, idx) => ( {idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""} ))} )}
)} {(edit || detailsOpen) && 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 && ( )}
); } // Klas-/doelgroepkop boven de vakken: altijd duidelijk in beeld. const doelIcoon = (t) => (t === "leerling" ? "🧒" : t === "leerplein" ? "🏫" : "👥"); const klasKop = (naam, sub, count, icoon) => (
{icoon}
{naam}{sub}
{count != null && {count}}
); 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 doelen (individuele leerling e.d.): per doel een klas-kop + vakken. const losPerDoel = new Map(); losse.forEach((b) => { const k = `${b.doel_type}:${b.doel_naam}`; if (!losPerDoel.has(k)) losPerDoel.set(k, []); losPerDoel.get(k).push(b); }); losPerDoel.forEach((blks, k) => { const eerste = blks[0]; out.push(
{klasKop(eerste.doel_naam, eerste.doel_type, blks.length > 1 ? blks.length : null, doelIcoon(eerste.doel_type))}
1 ? "split-vakken meerdere-vakken" : "split-vakken"}>{blks.map((b) => renderVakKaart(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(
{klasKop(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"}
1 ? "split-vakken meerdere-vakken" : "split-vakken"}>{groepBlokken.map((b) => renderVakKaart(b, iso, kal))}
); })}
); return out; } perGroep.forEach((groepBlokken, gid) => { const groep = groepen.find((g) => g.id === gid); const groepNaam = groep ? groep.naam : "Groep"; const heeftSplitsing = groepBlokken.some((b) => b.subgroep); if (!heeftSplitsing) { out.push(
{klasKop(groepNaam, "groep", groepBlokken.length > 1 ? groepBlokken.length : null, "👥")}
1 ? "split-vakken meerdere-vakken" : "split-vakken"}>{groepBlokken.map((b) => renderVakKaart(b, iso, kal))}
); return; } 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(
{klasKop(groepNaam, "groep → subgroepen", kolomAantal, "👥")}
{heleGroep.length > 0 && (
Hele groep
1 ? "split-vakken meerdere-vakken" : "split-vakken"}>{heleGroep.map((b) => renderVakKaart(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"}
1 ? "split-vakken meerdere-vakken" : "split-vakken"}>{blocks.map((b) => renderVakKaart(b, iso, kal))}
); })}
); }); return out; } return (
{printKop || (edit ? "Roostermaker" : "Rooster")} {(schooljaren.find((j) => String(j.id) === String(schooljaar)) || {}).naam || ""}{schooljaar ? " · " : ""}{schooljaar ? weekTitel(maandag) : ""} · afgedrukt {fmtDM(new Date())}

{edit ? "Roostermaker" : "Rooster"}

{schooljaar ? `${weekTitel(maandag)} · ${zichtbareBlokken.length} blokken zichtbaar` : "Kies een schooljaar"} {conflictInfo.meldingen.length > 0 ? ` · ${conflictInfo.meldingen.length} conflicten` : ""}
{schooljaar && (
{weekTitel(maandag)}
)} {schooljaar && (edit ? tab === "week" : true) && (
{printAan && }
)}
{schooljaar && (edit ? tab === "week" : true) && (
{(groepen.length > 0 || leerpleinen.length > 0) && (
Toon
{groepen.map((g) => ( ))} {leerpleinen.map((lp) => ( ))}
)}
of één persoon {scopeType !== "alles" && scopeId && {zichtbareBlokken.length} blok(ken)}
)} {edit && (
{(blokwizardAan || autoroosterAan) && (
{genOpen && (
setGenOpen(false)}> {blokwizardAan && } {autoroosterAan && }
)}
)}
)} {edit && tab === "week" &&
N nieuw · pijltjes week · T vandaag · 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 })} />
{form.geldig_van && form.geldig_van === form.geldig_tot && eenmalig op {form.geldig_van}} {(form.geldig_van || form.geldig_tot) && }
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" && conflictInfo.meldingen.length > 0 && (
{conflictOpen && (
    {conflictInfo.meldingen.map((m, i) => (
  • {m.tekst}
  • ))}
)}
)} {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 && blokGeldigOp(b, iso)); return (
{t.start_tijd?.slice(0, 5)} {t.naam}
{renderSlotBlokken(slotBlokken, iso, kal)} {edit && !kal && ( )}
); })}
); })}
)} {schooljaar && tab === "week" && printLegenda && legendaItems.length > 0 && (
Legenda
{legendaItems.map((v) => ( {v.icoon || vakIcoon(v.naam)} {v.naam} ))}
)} {edit && tab === "wizard" && blokwizardAan && } {edit && tab === "auto" && autoroosterAan && } {tab === "instellingen" && (

Beheer hier alle bouwstenen van het rooster. Klik een onderdeel open om het te bewerken.

📅 Schooljaar & kalender

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

🕒 Tijdvakken

De dag- en tijdindeling waarop vakken geplaatst kunnen worden.

📚 Vakken & activiteiten

De vakken/activiteiten met kleur en icoon.

🚪 Lokalen

Ruimtes met capaciteit; meetellen in de dubbele-boekingcheck.

📍 Locaties

Vrije locatie-aanduidingen (bv. gymzaal, zwembad).

🖼️ Vormgeving
)} {edit && bewerkBlok && ( setBewerkBlok(null)} onKlaar={async () => { setBewerkBlok(null); await herlaadNaBlokWijziging(); }} /> )} {edit && ghost &&
{ghost.label}
} {printOpen && (
setPrintOpen(false)}>
e.stopPropagation()}>

Afdrukken

Kies wat en hoe je afdrukt.
)}
); } // Twee schermen op basis van dezelfde component. export function RoosterWeergavePage() { return ; } export function RoostermakerPage() { return ; } /* --------------- Perioderooster: alle klassen in één matrix --------------- */ // Bouwt een weekopzet voor alle klassen (nog zonder periode/kalender-koppeling): // per klas vakken in elk tijdvak, met directe conflictsignalering over alle // klassen heen. Bij voltooien wordt een periode gekozen en ingeladen. export function PeriodeRoosterPage() { const [schooljaren, setSchooljaren] = useState([]); const [schooljaar, setSchooljaar] = useState(""); const [tijdsloten, setTijdsloten] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [activiteiten, setActiviteiten] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [lokalen, setLokalen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]); const [blokken, setBlokken] = useState([]); const [error, setError] = useState(null); const [cel, setCel] = useState(null); // {tijdslot, groep} const [form, setForm] = useState({ activiteit: "", begeleiders: [], lokaal: "" }); const [bezig, setBezig] = useState(false); // Voltooien → periode kiezen/aanmaken + preview. const [periodes, setPeriodes] = useState([]); const [voltooiOpen, setVoltooiOpen] = useState(false); const [periodeKeuze, setPeriodeKeuze] = useState("_new"); // id of "_new" const [nieuwP, setNieuwP] = useState({ start_datum: "", eind_datum: "" }); const [preview, setPreview] = useState(null); // {toevoegen:[], verwijderen:[]} const [applyBezig, setApplyBezig] = useState(false); const [klaarMelding, setKlaarMelding] = useState(null); const [wisInfo, setWisInfo] = useState(null); // {van, tot, blokken} const [vakkleurVlak, setVakkleurState] = useState(() => { try { return localStorage.getItem("rw_vakkleur") === "vlak"; } catch { return false; } }); const setVakkleur = (v) => { setVakkleurState(v); try { localStorage.setItem("rw_vakkleur", v ? "vlak" : "streep"); } catch { /* niet beschikbaar */ } }; useEffect(() => { listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message)); listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("groepen").then(setGroepen).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("activiteiten").then(setActiviteiten).catch(() => {}); listResource("lokalen").then(setLokalen).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(() => {}); }, []); useEffect(() => { laadBlokken(); laadPeriodes(); }, [schooljaar]); // eslint-disable-line react-hooks/exhaustive-deps async function laadBlokken() { if (!schooljaar) { setBlokken([]); return; } try { setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}&sjabloon=1`)); setError(null); } catch (e) { setError(e.message); } } async function laadPeriodes() { if (!schooljaar) { setPeriodes([]); return; } try { setPeriodes(await listResource("periodes", `?schooljaar=${schooljaar}`)); } catch { setPeriodes([]); } } const ctx = { leerlingen, subgroepen, leerpleinen }; const conflictInfo = berekenRoosterConflicten(blokken, ctx, tijdsloten); const algemeneSloten = tijdsloten.filter((t) => !t.groep && t.actief !== false).sort((a, b) => a.dag - b.dag || a.volgorde - b.volgorde); const perDag = WEEKDAGEN.map((d) => ({ dag: d, sloten: algemeneSloten.filter((t) => t.dag === d.value) })).filter((r) => r.sloten.length > 0); const blokkenVan = (slotId, groepId) => blokken.filter((b) => b.tijdslot === slotId && bovenliggendeGroep(b, subgroepen) === groepId); const groepNaam = (id) => { const g = groepen.find((x) => x.id === id); return g ? g.naam : "?"; }; function openCel(slotId, groepId) { setForm({ activiteit: "", begeleiders: [], lokaal: "" }); setCel({ tijdslot: slotId, groep: groepId }); } function toggleBeg(id) { setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] }); } async function voegToe(e) { e.preventDefault(); if (!cel || !form.activiteit) return; setBezig(true); try { await createResource("roosterblokken", { schooljaar, tijdslot: cel.tijdslot, activiteit: form.activiteit, groep: cel.groep, subgroep: null, leerling: null, begeleiders: form.begeleiders, lokaal: form.lokaal || null, locatie: null, opmerkingen: "", geldig_van: null, geldig_tot: null, sjabloon: true }); setCel(null); setKlaarMelding(null); await laadBlokken(); } catch (err) { setError(err.message); } setBezig(false); } async function verwijder(id) { try { await deleteResource("roosterblokken", id); setKlaarMelding(null); await laadBlokken(); } catch (e) { setError(e.message); } } // ---- Voltooien: periode kiezen/aanmaken, preview en toepassen ---- const doelKey = (b) => `${b.tijdslot}|${b.groep || ""}|${b.subgroep || ""}|${b.leerling || ""}`; const overlaptPeriode = (b, van, tot) => { const bv = b.geldig_van || null, bt = b.geldig_tot || null; if (bt && bt < van) return false; if (bv && bv > tot) return false; return true; }; function gekozenPeriodeDatums() { if (periodeKeuze === "_new") return { van: nieuwP.start_datum, tot: nieuwP.eind_datum }; const p = periodes.find((x) => String(x.id) === String(periodeKeuze)); return p ? { van: p.start_datum, tot: p.eind_datum } : { van: "", tot: "" }; } function openVoltooien() { const volgend = (periodes.reduce((m, p) => Math.max(m, p.nummer), 0) || 0) + 1; setPeriodeKeuze(periodes.length ? String(periodes[0].id) : "_new"); setNieuwP({ start_datum: "", eind_datum: "", nummer: volgend }); setPreview(null); setWisInfo(null); setKlaarMelding(null); setVoltooiOpen(true); } async function maakPreview() { const { van, tot } = gekozenPeriodeDatums(); if (!van || !tot) { setError("Kies of vul een periode met begin- en einddatum."); return; } try { const live = await listResource("roosterblokken", `?schooljaar=${schooljaar}`); // sjabloon=False const keys = new Set(blokken.map(doelKey)); const overlappend = live.filter((b) => overlaptPeriode(b, van, tot)); const verwijderen = overlappend.filter((b) => keys.has(doelKey(b))); const behouden = overlappend.filter((b) => !keys.has(doelKey(b))); // Resultaat na toepassen = behouden bestaande blokken + nieuwe periodeblokken // (met de periodedatums). Zelfde conflictcheck als het Rooster. const nieuwe = blokken.map((b) => ({ ...b, geldig_van: van, geldig_tot: tot })); const resultaat = [...behouden, ...nieuwe]; const resConflict = berekenRoosterConflicten(resultaat, ctx, tijdsloten); setPreview({ toevoegen: blokken, verwijderen, conflicten: resConflict.meldingen, van, tot }); setError(null); } catch (e) { setError(e.message); } } async function bevestigVoltooien() { if (!preview) return; setApplyBezig(true); try { let periodeId = periodeKeuze; if (periodeKeuze === "_new") { const p = await createResource("periodes", { schooljaar, nummer: nieuwP.nummer, start_datum: preview.van, eind_datum: preview.tot }); periodeId = p.id; } for (const b of preview.verwijderen) { await deleteResource("roosterblokken", b.id); } for (const b of preview.toevoegen) { await createResource("roosterblokken", { schooljaar, tijdslot: b.tijdslot, activiteit: b.activiteit, groep: b.groep, subgroep: b.subgroep, leerling: b.leerling, begeleiders: (b.begeleiders_namen || []).map((x) => x.id), lokaal: b.lokaal || null, locatie: b.locatie || null, opmerkingen: b.opmerkingen || "", geldig_van: preview.van, geldig_tot: preview.tot, sjabloon: false, }); } await laadPeriodes(); setVoltooiOpen(false); setPreview(null); setKlaarMelding(`Perioderooster ingeladen (${preview.toevoegen.length} vak(ken) geplaatst, ${preview.verwijderen.length} overlappend verwijderd). De weekopzet blijft als sjabloon bewaard.`); } catch (e) { setError(e.message); } setApplyBezig(false); } // ---- Hele periode uit het echte rooster wissen ---- async function wisPeriodeControle() { const { van, tot } = gekozenPeriodeDatums(); if (!van || !tot) { setError("Kies een periode om te wissen."); return; } try { const live = await listResource("roosterblokken", `?schooljaar=${schooljaar}`); // sjabloon=False // Alleen periode-gebonden blokken die binnen deze periode vallen; vaste // (altijd geldige) blokken zonder datum blijven staan. const teWissen = live.filter((b) => b.geldig_van && b.geldig_tot && b.geldig_van >= van && b.geldig_tot <= tot); setPreview(null); setWisInfo({ van, tot, blokken: teWissen }); setError(null); } catch (e) { setError(e.message); } } async function bevestigWis() { if (!wisInfo) return; setApplyBezig(true); try { for (const b of wisInfo.blokken) { await deleteResource("roosterblokken", b.id); } const n = wisInfo.blokken.length; setWisInfo(null); setVoltooiOpen(false); setKlaarMelding(`Periode geleegd: ${n} blok(ken) uit het rooster verwijderd. Het sjabloon blijft bewaard.`); } catch (e) { setError(e.message); } setApplyBezig(false); } return (

Perioderooster

Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij voltooien kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.

{conflictInfo.meldingen.length ? `⚠ ${conflictInfo.meldingen.length} conflict(en)` : "✓ geen conflicten"}
{klaarMelding &&
{klaarMelding}
} {conflictInfo.meldingen.length > 0 && (
    {conflictInfo.meldingen.map((m, i) =>
  • {m.tekst}
  • )}
)} {!schooljaar &&

Kies eerst een schooljaar.

} {schooljaar && groepen.length === 0 &&

Maak eerst groepen (klassen) aan onder Organisatie.

} {schooljaar && groepen.length > 0 && perDag.length === 0 &&

Maak eerst algemene tijdvakken aan (Instellingen → Tijdvakken).

} {schooljaar && groepen.length > 0 && perDag.length > 0 && (
{groepen.map((g) => )} {perDag.map((rij) => ( {rij.sloten.map((t) => ( {groepen.map((g) => { const cellblk = blokkenVan(t.id, g.id); return ( ); })} ))} ))}
Tijdvak{g.naam}
{rij.dag.label}
{t.start_tijd?.slice(0, 5)} {t.naam} {cellblk.map((b) => (
{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}
{b.subgroep &&
{b.doel_naam}
} {(b.lokaal_naam || b.begeleiders_namen.length > 0) && (
{[b.lokaal_naam, ...b.begeleiders_namen.map((x) => x.naam)].filter(Boolean).join(" · ")}
)}
))}
)} {voltooiOpen && (
setVoltooiOpen(false)}>
e.stopPropagation()}>

Voltooien & periode inladen

De weekopzet wordt als rooster geplaatst in de gekozen periode. De opzet blijft als sjabloon bewaard.
{conflictInfo.meldingen.length > 0 &&
Let op: er zijn nog {conflictInfo.meldingen.length} conflict(en) in de opzet. Je kunt toch inladen, maar los ze bij voorkeur eerst op.
}
{periodeKeuze === "_new" && ( <> )}
{!preview && !wisInfo && (
{periodeKeuze !== "_new" && }
)} {wisInfo && ( <>

{wisInfo.blokken.length} periode-blok(ken) ({wisInfo.van} – {wisInfo.tot}) worden uit het rooster verwijderd. Vaste (altijd geldige) blokken blijven staan.

{wisInfo.blokken.length > 0 && (
    {wisInfo.blokken.slice(0, 30).map((b) =>
  • {b.doel_naam} · {b.activiteit_naam} ({b.tijdslot_label})
  • )} {wisInfo.blokken.length > 30 &&
  • …en nog {wisInfo.blokken.length - 30}
  • }
)}
)} {preview && ( <>

{preview.toevoegen.length} vak(ken) worden geplaatst voor periode {preview.van} – {preview.tot}.

{preview.verwijderen.length > 0 ? ( <>

{preview.verwijderen.length} bestaande blok(ken) overlappen en worden verwijderd:

    {preview.verwijderen.slice(0, 30).map((b) =>
  • {b.doel_naam} · {b.activiteit_naam} ({b.tijdslot_label})
  • )} {preview.verwijderen.length > 30 &&
  • …en nog {preview.verwijderen.length - 30}
  • }
) :

Geen overlappende bestaande blokken.

} {preview.conflicten.length > 0 ? ( <>

{preview.conflicten.length} conflict(en) in het rooster ná toepassen (nieuwe + behouden blokken):

    {preview.conflicten.map((m, i) =>
  • {m.tekst}
  • )}
) :

✓ geen conflicten ná toepassen

}
)}
)} {cel && (
setCel(null)}>
e.stopPropagation()}>

Vak toevoegen

{groepNaam(cel.groep)} · {(tijdsloten.find((t) => t.id === cel.tijdslot) || {}).naam}
Begeleiders
{medewerkers.map((m) => )} {medewerkers.length === 0 && Nog geen medewerkers.}
)}
); } /* -------------------------------- Huisstijl / logo ------------------------ */ // Klein logo dat in elke roosterweergave en op afdrukken verschijnt. export function RoosterLogo({ print = false }) { const [logo, setLogo] = useState(null); useEffect(() => { getHuisstijl().then((h) => setLogo(h && h.logo ? h.logo : null)).catch(() => {}); }, []); if (!logo) return null; return ; } // Pas thema-kleuren direct toe (zelfde logica als App.pasThemaToe; hier // gedupliceerd om een circulaire import te vermijden). function _themaDirect(h) { const root = document.documentElement; const rgba = (hex, a) => { if (!hex) return ""; const m = hex.replace("#", ""); const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m; if (v.length < 6) return ""; const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16); return [r, g, b].some(Number.isNaN) ? "" : `rgba(${r}, ${g}, ${b}, ${a})`; }; if (h && h.primaire_kleur) { root.style.setProperty("--brand", h.primaire_kleur); const s = rgba(h.primaire_kleur, 0.14); if (s) root.style.setProperty("--brand-soft", s); } if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur); } export function HuisstijlPage() { const [huis, setHuis] = useState(null); const [kwaliteit, setKwaliteit] = useState(null); const [bezig, setBezig] = useState(false); const [error, setError] = useState(null); const [form, setForm] = useState({ schoolnaam: "", primaire_kleur: "", accent_kleur: "" }); const [opslaanOk, setOpslaanOk] = useState(false); useEffect(() => { getHuisstijl().then((h) => { setHuis(h); setForm({ schoolnaam: h.schoolnaam || "", primaire_kleur: h.primaire_kleur || "", accent_kleur: h.accent_kleur || "" }); }).catch((e) => setError(e.message)); }, []); async function opslaanThema() { setBezig(true); setError(null); setOpslaanOk(false); try { const r = await opslaanVormgeving(form); setHuis(r); _themaDirect(r); setOpslaanOk(true); } catch (e) { setError(e.message); } setBezig(false); } async function herstelKleuren() { setForm((f) => ({ ...f, primaire_kleur: "", accent_kleur: "" })); setBezig(true); setError(null); try { const r = await opslaanVormgeving({ ...form, primaire_kleur: "", accent_kleur: "" }); setHuis(r); document.documentElement.style.removeProperty("--brand"); document.documentElement.style.removeProperty("--brand-soft"); document.documentElement.style.removeProperty("--accent"); } catch (e) { setError(e.message); } setBezig(false); } async function kies(e) { const f = e.target.files && e.target.files[0]; e.target.value = ""; if (!f) return; setBezig(true); setError(null); setKwaliteit(null); try { const r = await uploadLogo(f); setHuis(r); setKwaliteit(r.kwaliteit || null); } catch (err) { setError(err.message); if (err.data && err.data.kwaliteit) setKwaliteit(err.data.kwaliteit); } setBezig(false); } async function wis() { setBezig(true); setError(null); try { const r = await verwijderLogo(); setHuis(r); setKwaliteit(null); } catch (e) { setError(e.message); } setBezig(false); } return (

Schoolnaam & kleuren

De schoolnaam verschijnt in de balk en op afdrukken. De kleuren bepalen het thema van de hele app (knoppen, tabs, accenten).

{opslaanOk && Opgeslagen ✓}

Logo

Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een transparante PNG of SVG; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.

{huis && huis.logo ? Logo : Nog geen logo}
{huis && huis.logo && }
Toegestaan: PNG, SVG, JPG, GIF, WebP.
{kwaliteit && (
Kwaliteitscheck: {kwaliteit.info && (kwaliteit.info.breedte || kwaliteit.info.formaat) && (
Formaat {kwaliteit.info.formaat}{kwaliteit.info.breedte ? `, ${kwaliteit.info.breedte}×${kwaliteit.info.hoogte}px` : ""}{kwaliteit.info.transparant ? ", transparante achtergrond ✓" : ""}{kwaliteit.info.achtergrond ? `, achtergrond ${kwaliteit.info.achtergrond}` : ""}.
)} {kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length > 0 ?
    {kwaliteit.waarschuwingen.map((w, i) =>
  • {w}
  • )}
:
Geen problemen gevonden — mooi en netjes. ✓
}
)}
); } /* -------------------------------- 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); const [groepFilter, setGroepFilter] = useState(""); const [vakFilter, setVakFilter] = useState(""); const [leerlingFilter, setLeerlingFilter] = useState(""); const [historie, setHistorie] = useState(null); const [bewaarBusy, setBewaarBusy] = 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]); function laadHistorie(sjid) { if (!sjid) { setHistorie(null); return; } listResource("uren-momentopnames", `?schooljaar=${sjid}`).then(setHistorie).catch(() => setHistorie(null)); } useEffect(() => { laadHistorie(schooljaar); }, [schooljaar]); async function bewaarMomentopname() { if (!schooljaar) return; setBewaarBusy(true); try { await createResource("uren-momentopnames", { schooljaar: Number(schooljaar) }); laadHistorie(schooljaar); } catch (e) { setError(e.message); } finally { setBewaarBusy(false); } } const r1 = (n) => Math.round(n * 10) / 10; const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : []; const vakNamen = data ? [...new Set(data.per_groep.flatMap((g) => g.vakken.map((v) => v.vak)))].sort() : []; const perGroep = (data ? data.per_groep : []) .filter((g) => !groepFilter || g.groep === groepFilter) .map((g) => { const vakken = vakFilter ? g.vakken.filter((v) => v.vak === vakFilter) : g.vakken; return { ...g, vakken, totaal: r1(vakken.reduce((a, v) => a + v.uren, 0)) }; }) .filter((g) => g.vakken.length > 0); const perLeerling = (data ? data.per_leerling : []) .filter((l) => !leerlingFilter || l.leerling.toLowerCase().includes(leerlingFilter.toLowerCase())) .filter((l) => !groepFilter || l.groep === groepFilter) .filter((l) => !vakFilter || l.vakken.some((v) => v.vak === vakFilter)) .map((l) => { const vakken = vakFilter ? l.vakken.filter((v) => v.vak === vakFilter) : l.vakken; return { ...l, vakkenWeergave: vakken }; }); const filterActief = groepFilter || vakFilter || leerlingFilter; 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.

setLeerlingFilter(e.target.value)} /> {filterActief && }
{loading ?

Laden…

: data && ( <>

Per groep

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

Per leerling

{perLeerling.length === 0 && } {perLeerling.map((l) => ( ))}
LeerlingGroepVakkenStageTotaal
Geen resultaten.
{l.leerling} {l.groep || "—"} {l.vakkenWeergave.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"} {l.stage_uren ? `${l.stage_uren} u` : "—"} {l.totaal} u

Geschiedenis

{historie && historie.momentopnames.length > 0 ? ( {historie.momentopnames.map((m) => )} {historie.leerlingen .filter((r) => !leerlingFilter || r.leerling.toLowerCase().includes(leerlingFilter.toLowerCase())) .map((r) => ( {historie.momentopnames.map((m) => )} ))}
Leerling{new Date(m.gemaakt_op).toLocaleDateString("nl-NL")}
{r.leerling}{r.totalen[m.id] != null ? `${r.totalen[m.id]} u` : "—"}
) :

Nog geen momentopnames. Klik op "Bewaar huidige stand" om het verloop in de tijd te volgen.

} )}
); } /* ---------------- Eenvoudige weergave (ZML-leerling / ouder) -------------- */ export function EenvoudigRooster({ mode }) { const ouder = mode === "ouder"; const [schooljaren, setSchooljaren] = useState([]); const [schooljaar, setSchooljaar] = useState(""); const [tijdsloten, setTijdsloten] = useState([]); const [blokken, setBlokken] = useState([]); const [kalender, setKalender] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); const [scopeType, setScopeType] = useState("leerling"); const [scopeId, setScopeId] = useState(""); const [maandag, setMaandag] = useState(() => maandagVan(new Date())); const [dagIdx, setDagIdx] = useState(() => Math.min((new Date().getDay() + 6) % 7, 4)); const [error, setError] = useState(null); useEffect(() => { listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message)); listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("groepen").then(setGroepen).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([])); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); }, []); useEffect(() => { if (!schooljaar) return; listResource("roosterblokken", `?schooljaar=${schooljaar}`).then(setBlokken).catch((e) => setError(e.message)); listResource("kalenderdagen", `?schooljaar=${schooljaar}`).then(setKalender).catch(() => {}); }, [schooljaar]); const ctx = { leerlingen, subgroepen, leerpleinen }; const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" }; const scopeOpties = scopeType === "klas" ? groepen : leerlingen; const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i)); const kalenderOp = (iso) => kalender.find((k) => k.datum === iso); const blokGeldigOp = (b, iso) => !((b.geldig_van && iso < b.geldig_van) || (b.geldig_tot && iso > b.geldig_tot)); function dagBlokken(datum) { const iso = fmtISO(datum); const vrij = kalenderOp(iso); if (vrij) return { vrij, items: [] }; const wd = (datum.getDay() + 6) % 7; const slots = tijdsloten.filter((t) => t.dag === wd).sort((a, b) => a.volgorde - b.volgorde); const items = []; slots.forEach((t) => { blokken .filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso) && (scopeId ? blokInScope(b, scope, ctx) : false)) .forEach((b) => items.push({ t, b })); }); return { vrij: null, items }; } function blokKaart({ t, b }, groot) { const kleur = b.activiteit_kleur || "#2563eb"; return (
{t.start_tijd?.slice(0, 5)}
{b.activiteit_pictogram ? :
{b.activiteit_icoon || vakIcoon(b.activiteit_naam)}
}
{b.activiteit_naam}
); } function dagdeelSecties(items, groot) { const ocht = items.filter(({ t }) => (t.start_tijd || "") < "12:00"); const midd = items.filter(({ t }) => (t.start_tijd || "") >= "12:00"); const secties = [["Ochtend", "\u2600\uFE0F", ocht], ["Middag", "\uD83C\uDF24\uFE0F", midd]].filter(([, , arr]) => arr.length > 0); return secties.map(([label, icon, arr]) => (
{icon} {label}
{arr.map((x) => blokKaart(x, groot))}
)); } return (

{ouder ? "Weekrooster" : "Mijn rooster"}

{schooljaren.length > 1 && ( )}
{!scopeId &&

Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.

} {scopeId && ouder && (
{weekDatums.map((datum, i) => { const { vrij, items } = dagBlokken(datum); return (
{WEEKDAGEN[i].label} {fmtDM(datum)}
{vrij &&
{vrij.type_label}
} {!vrij && items.length === 0 &&
} {!vrij && dagdeelSecties(items, false)}
); })}
)} {scopeId && !ouder && (() => { const datum = plusDagen(maandag, dagIdx); const { vrij, items } = dagBlokken(datum); return (
{WEEKDAGEN[dagIdx].label} {fmtDM(datum)}
{vrij &&
{vrij.type_label}{vrij.omschrijving ? `: ${vrij.omschrijving}` : ""}
} {!vrij && items.length === 0 &&

Geen lessen op deze dag.

} {!vrij && dagdeelSecties(items, true)}
); })()}
); } export function LeerlingRoosterPage() { return ; } export function OuderRoosterPage() { return ; } /* -------------------------------- Vak-wizard ------------------------------ */ export function VakWizardPage() { const [stap, setStap] = useState(1); const [schooljaren, setSchooljaren] = useState([]); const [activiteiten, setActiviteiten] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [tijdsloten, setTijdsloten] = useState([]); const [locaties, setLocaties] = useState([]); const [lokalen, setLokalen] = useState([]); const [error, setError] = useState(null); const [klaar, setKlaar] = useState(false); const leeg = { schooljaar: "", activiteit: "", doel_type: "groep", doel: "", tijdslot: "", locatie: "", lokaal: "", begeleiders: [], geldig_van: "", geldig_tot: "", opmerkingen: "" }; const [f, setF] = useState(leeg); useEffect(() => { listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setF((x) => ({ ...x, schooljaar: String(j[0].id) })); }).catch((e) => setError(e.message)); listResource("activiteiten").then(setActiviteiten).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("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("locaties").then(setLocaties).catch(() => {}); listResource("lokalen").then(setLokalen).catch(() => setLokalen([])); }, []); const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepen : leerlingen; const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] }); const naamVan = (lijst, id) => { const o = lijst.find((x) => x.id === Number(id)); return o ? (o.naam || o.volledige_naam) : "—"; }; const tslabel = (id) => { const t = tijdsloten.find((x) => x.id === Number(id)); return t ? `${t.dag_label} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; }; const magVerder = (stap === 1 && f.activiteit) || (stap === 2 && f.doel) || (stap === 3 && f.tijdslot) || (stap === 4) || stap === 5; async function opslaan() { try { await createResource("roosterblokken", { schooljaar: f.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, groep: f.doel_type === "groep" ? f.doel : null, subgroep: f.doel_type === "subgroep" ? f.doel : null, leerling: f.doel_type === "leerling" ? f.doel : null, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen, geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null, }); setKlaar(true); } catch (e) { setError(e.message); } } function opnieuw() { setF({ ...leeg, schooljaar: f.schooljaar }); setStap(1); setKlaar(false); } if (klaar) { return (

Vak-wizard

✓ Het vak is ingepland.
); } const STAPPEN = ["Vak", "Voor wie", "Wanneer & waar", "Begeleiders", "Controle"]; return (

Vak-wizard

    {STAPPEN.map((label, i) =>
  1. i + 1 ? "done" : ""}>{i + 1}. {label}
  2. )}
{stap === 1 && (

Welk vak?

{schooljaren.length > 1 && ( )}
{activiteiten.map((a) => ( ))}
)} {stap === 2 && (

Voor wie?

)} {stap === 3 && (

Wanneer & waar?

)} {stap === 4 && (

Wie begeleidt? (optioneel)

{medewerkers.map((m) => )}
setF({ ...f, opmerkingen: e.target.value })} />
)} {stap === 5 && (

Klopt het zo?

  • Vak: {naamVan(activiteiten, f.activiteit)}
  • Voor: {naamVan(doelOpties, f.doel)} ({f.doel_type})
  • Wanneer: {tslabel(f.tijdslot)}
  • Waar: {f.lokaal ? naamVan(lokalen, f.lokaal) : (f.locatie ? naamVan(locaties, f.locatie) : "—")}
  • Begeleiding: {f.begeleiders.length ? f.begeleiders.map((id) => naamVan(medewerkers, id)).join(", ") : "—"}
  • {(f.geldig_van || f.geldig_tot) &&
  • Periode: {f.geldig_van || "…"} t/m {f.geldig_tot || "…"}
  • }
)}
{stap > 1 && } {stap < 5 && } {stap === 5 && }
); } /* ----------------------------- Personen-wizard ---------------------------- */ const PWROLLEN = [ { value: "leerling", label: "Leerling" }, { value: "leerkracht", label: "Leerkracht" }, { value: "praktijkondersteuner", label: "Praktijkondersteuner" }, { value: "klassenondersteuner", label: "Klassenondersteuner" }, ]; function detecteerScheiding(regel) { const tel = (c) => (regel.match(new RegExp("\\" + c, "g")) || []).length; const t = (regel.match(/\t/g) || []).length; const kand = [[";", tel(";")], [",", tel(",")], ["\t", t]]; kand.sort((a, b) => b[1] - a[1]); return kand[0][1] > 0 ? kand[0][0] : ","; } function parsePersonen(tekst, rol) { const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean); if (regels.length === 0) return []; const sep = detecteerScheiding(regels[0]); const splits = (r) => r.split(sep).map((c) => c.trim()); let kolommen = null; const eerste = splits(regels[0]).map((c) => c.toLowerCase()); const heeftHeader = eerste.some((c) => ["voornaam", "achternaam", "naam", "groep", "rol"].includes(c)); const rows = []; const start = heeftHeader ? 1 : 0; if (heeftHeader) kolommen = eerste; for (let i = start; i < regels.length; i++) { const cellen = splits(regels[i]); let voornaam = "", achternaam = "", groep = ""; if (kolommen) { const idx = (n) => kolommen.indexOf(n); if (idx("voornaam") >= 0) voornaam = cellen[idx("voornaam")] || ""; if (idx("achternaam") >= 0) achternaam = cellen[idx("achternaam")] || ""; if (idx("groep") >= 0) groep = cellen[idx("groep")] || ""; if (!voornaam && !achternaam && idx("naam") >= 0) { const delen = (cellen[idx("naam")] || "").split(" "); voornaam = delen.shift() || ""; achternaam = delen.join(" "); } } else if (cellen.length === 1) { const delen = cellen[0].split(" "); voornaam = delen.shift() || ""; achternaam = delen.join(" "); } else { voornaam = cellen[0] || ""; achternaam = cellen[1] || ""; groep = cellen[2] || ""; } if (voornaam || achternaam) rows.push(rol === "leerling" ? { voornaam, achternaam, groep } : { voornaam, achternaam }); } return rows; } export function PersonenWizardPage() { const [rol, setRol] = useState("leerling"); const [modus, setModus] = useState("enkel"); const [groepen, setGroepen] = useState([]); const [error, setError] = useState(null); const [melding, setMelding] = useState(null); // enkel const leeg = { voornaam: "", achternaam: "", groep: "" }; const [form, setForm] = useState(leeg); // bulk const [tekst, setTekst] = useState(""); const [groepAanmaken, setGroepAanmaken] = useState(false); const [bezig, setBezig] = useState(false); useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []); const isLeerling = rol === "leerling"; const rijen = modus === "bulk" ? parsePersonen(tekst, rol) : []; async function bewaarEnkel(e) { e.preventDefault(); setError(null); setMelding(null); try { await createResource("personen", { voornaam: form.voornaam, achternaam: form.achternaam, rol, groep: isLeerling && form.groep ? Number(form.groep) : null }); setMelding(`${form.voornaam} ${form.achternaam} toegevoegd.`); setForm(leeg); } catch (err) { setError(err.message); } } function leesBestand(file) { const reader = new FileReader(); reader.onload = () => setTekst(String(reader.result || "")); reader.readAsText(file); } async function importeer() { setError(null); setMelding(null); setBezig(true); try { const res = await createResource("personen-import", { rol, personen: rijen, groep_aanmaken: groepAanmaken }); let m = `${res.aangemaakt} ${isLeerling ? "leerling(en)" : "medewerker(s)"} geïmporteerd.`; if (res.meldingen && res.meldingen.length) m += ` ${res.meldingen.length} aandachtspunt(en).`; setMelding(m); setTekst(""); } catch (err) { setError(err.message); } finally { setBezig(false); } } return (

Personen toevoegen

Voeg leerlingen of medewerkers toe — één voor één of in bulk (plakken of CSV-bestand).

{melding &&
✓ {melding}
}
{modus === "enkel" && (
setForm({ ...form, voornaam: e.target.value })} /> setForm({ ...form, achternaam: e.target.value })} /> {isLeerling && ( )}
)} {modus === "bulk" && (

Plak rijen of upload een CSV. Kolommen: voornaam, achternaam{isLeerling ? ", groep" : ""} (komma, puntkomma of tab). Een koprij is optioneel.

{isLeerling && }