diff --git a/.ui-backup/App.jsx.bak b/.ui-backup/App.jsx.bak new file mode 100644 index 0000000..2fdfd5f --- /dev/null +++ b/.ui-backup/App.jsx.bak @@ -0,0 +1,511 @@ +import { useEffect, useMemo, useState } from "react"; +import { getMe, login, logout, getModules, setModuleState, wachtwoordVergeten, getHuisstijl } from "./api.js"; +import Logo from "./Logo.jsx"; +import { + PersonenPage, FunctiesPage, + ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, + RoosterWeergavePage, RoostermakerPage, PeriodeRoosterPage, + AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, + LeerlingRoosterPage, OuderRoosterPage, + VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, + HuisstijlPage, AuditLogPage, ImportExportPage, VervangingPage, PortalPage, +} from "./pages.jsx"; + +const ICONS = { + calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", + key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", + away: "🌴", gear: "⚙️", door: "🚪", audit: "🧾", +}; + +// Iconen voor de groep-dropdowns in de bovenbalk. +const GROEP_ICON = { Rooster: "calendar", Organisatie: "users", Overzicht: "clock", Hulpmiddelen: "puzzle", Toegang: "key" }; + +const GROEPSINDELING_PADEN = new Set(["/groepsindeling", "/structuur", "/leerplein", "/groepen", "/subgroepen"]); +const GROEPSINDELING_ITEM = { label: "Groepsindeling", path: "/groepsindeling", icon: "layers", order: 24, group: "Organisatie" }; +const normaliseerPad = (path) => (GROEPSINDELING_PADEN.has(path) ? "/groepsindeling" : path); +const ROLE_NAV = { + roostermaker: new Set(["/rooster", "/perioderooster", "/roostermaker", "/vervanging", "/afwezigheid", "/auto-rooster", "/vak-wizard"]), + directie: null, + zorgcoordinator: new Set(["/rooster", "/groepsindeling", "/personen", "/afwezigheid", "/stage", "/uren", "/portaal"]), + stagebegeleider: new Set(["/rooster", "/stage", "/afwezigheid", "/portaal"]), + medewerker: new Set(["/rooster", "/groepsindeling", "/afwezigheid", "/stage", "/uren", "/portaal"]), + kijker: new Set(["/rooster", "/uren", "/portaal"]), + ouder: new Set(["/portaal", "/ouderrooster"]), + leerling: new Set(["/portaal", "/leerlingrooster"]), +}; +function magMenuZien(user, item) { + if (user.is_staff || user.role === "beheerder" || user.role === "directie") return true; + const allowed = ROLE_NAV[user.role]; + if (!allowed) return true; + return allowed.has(normaliseerPad(item.path)); +} + +// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een +// placeholder (worden in een latere fase ingevuld). +const PAGES = { + "/rooster": RoosterWeergavePage, + "/perioderooster": PeriodeRoosterPage, + "/roostermaker": RoostermakerPage, + "/groepsindeling": GroepsindelingPage, + "/personen": PersonenPage, + "/functies": FunctiesPage, + "/activiteiten": ActiviteitenPage, + "/locaties": LocatiesPage, + "/lokalen": LokalenPage, + "/tijdsloten": TijdslotenPage, + "/schooljaar": SchooljaarPage, + "/afwezigheid": AfwezigheidPage, + "/stage": StagePage, + "/gebruikers": GebruikersPage, + "/uren": UrenPage, + "/leerlingrooster": LeerlingRoosterPage, + "/ouderrooster": OuderRoosterPage, + "/vak-wizard": VakWizardPage, + "/personen-wizard": PersonenWizardPage, + "/auto-rooster": AutoRoosterPage, + "/vormgeving": HuisstijlPage, + "/auditlog": AuditLogPage, + "/import-export": ImportExportPage, + "/vervanging": VervangingPage, + "/portaal": PortalPage, +}; + +// Thema (vormgeving) app-breed toepassen via CSS-variabelen. +function hexNaarRgba(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); + if ([r, g, b].some(Number.isNaN)) return ""; + return `rgba(${r}, ${g}, ${b}, ${a})`; +} +export function pasThemaToe(h) { + const root = document.documentElement; + if (h && h.primaire_kleur) { + root.style.setProperty("--brand", h.primaire_kleur); + const soft = hexNaarRgba(h.primaire_kleur, 0.14); + if (soft) root.style.setProperty("--brand-soft", soft); + } + if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur); +} + +const RESET_PARAMS = (() => { + try { + const q = new URLSearchParams(window.location.search); + if (q.get("reset") && q.get("uid") && q.get("token")) return { uid: q.get("uid"), token: q.get("token") }; + } catch (e) { /* geen window */ } + return null; +})(); + +export default function App() { + // null = nog aan het controleren; {authenticated:false} = login tonen. + const [user, setUser] = useState(null); + const [checking, setChecking] = useState(true); + + useEffect(() => { + getMe() + .then(setUser) + .catch(() => setUser({ authenticated: false })) + .finally(() => setChecking(false)); + }, []); + + if (RESET_PARAMS) return ; + if (checking) return

Laden…

; + if (!user || !user.authenticated) { + return ; + } + return setUser({ authenticated: false })} />; +} + +/* --------------------------------- Login ---------------------------------- */ +function LoginPage({ onLogin }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [vergeten, setVergeten] = useState(false); + const [ident, setIdent] = useState(""); + const [verzonden, setVerzonden] = useState(false); + + async function submit(e) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + onLogin(await login(username, password)); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + } + async function vergetenSubmit(e) { + e.preventDefault(); + setBusy(true); setError(null); + try { + const r = await wachtwoordVergeten(ident); + setVerzonden(true); + setError(null); + void r; + } catch (err) { setError(err.message); } + finally { setBusy(false); } + } + + if (vergeten) { + return ( +
+
+
+ + Roosterwijs +
+ {verzonden ? ( + <> +

Als het account bestaat, is er een e-mail met een resetlink verstuurd. Controleer je mailbox.

+ + + ) : ( + <> +

Vul je gebruikersnaam of e-mailadres in; je ontvangt een resetlink.

+ {error &&
⚠ {error}
} + setIdent(e.target.value)} /> + + + + )} +
+
+ ); + } + + return ( +
+
+
+ + Roosterwijs +
+

Log in om verder te gaan.

+ {error &&
⚠ {error}
} + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + +
+
+ ); +} + +/* ---------------------------------- Shell --------------------------------- */ +function Shell({ user, onLogout }) { + const [modules, setModules] = useState([]); + const [active, setActive] = useState("/rooster"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [cogOpen, setCogOpen] = useState(false); + const [openMenu, setOpenMenu] = useState(null); + const [schoolnaam, setSchoolnaam] = useState(""); + + useEffect(() => { + getHuisstijl().then((h) => { pasThemaToe(h); setSchoolnaam(h && h.schoolnaam ? h.schoolnaam : ""); }).catch(() => {}); + }, []); + + async function load() { + setLoading(true); + try { + setModules(await getModules()); + setError(null); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); + + useEffect(() => { + const onNav = (event) => { + if (event.detail) setActive(normaliseerPad(event.detail)); + }; + window.addEventListener("roosterwijs:navigate", onNav); + return () => window.removeEventListener("roosterwijs:navigate", onNav); + }, []); + + async function doLogout() { + try { + await logout(); + } finally { + onLogout(); + } + } + + // Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden. + // Verouderde groepspaden blijven werken, maar verschijnen als één tab. + const { tabs, instellingenTabs, alleItems } = useMemo(() => { + const perPad = new Map(); + const voegToe = (raw) => { + const pad = normaliseerPad(raw.path); + const item = pad === "/groepsindeling" + ? { ...raw, ...GROEPSINDELING_ITEM, module: raw.module } + : { ...raw, path: pad }; + const huidig = perPad.get(item.path); + if (!huidig || item.order < huidig.order) perPad.set(item.path, item); + }; + for (const m of modules) { + if (!m.enabled) continue; + for (const it of m.menu_items) voegToe({ ...it, module: m.key }); + } + const isBeheerder = user.is_staff || user.role === "beheerder"; + if (isBeheerder) { + voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" }); + } + const items = [...perPad.values()].filter((item) => magMenuZien(user, item)); + items.sort((a, b) => a.order - b.order); + return { + tabs: items.filter((i) => i.group !== "Instellingen"), + instellingenTabs: items.filter((i) => i.group === "Instellingen"), + alleItems: items, + }; + }, [modules, user]); + + const eerstePad = alleItems[0]?.path || "/rooster"; + const activePad = normaliseerPad( + [...alleItems].some((item) => normaliseerPad(item.path) === normaliseerPad(active)) ? active : eerstePad + ); + const Page = PAGES[activePad]; + + // Groepeer de hoofdtabs: groepen met meerdere items worden een dropdown, + // groepen met één item en losse items blijven directe tabs. + const navEntries = (() => { + const directe = []; + const groepMap = new Map(); + for (const it of tabs) { + if (it.group) { if (!groepMap.has(it.group)) groepMap.set(it.group, []); groepMap.get(it.group).push(it); } + else directe.push(it); + } + const entries = directe.map((it) => ({ type: "tab", order: it.order, it })); + for (const [groep, items] of groepMap) { + if (items.length === 1) entries.push({ type: "tab", order: items[0].order, it: items[0] }); + else entries.push({ type: "group", order: Math.min(...items.map((i) => i.order)), groep, items }); + } + entries.sort((a, b) => a.order - b.order); + return entries; + })(); + + function TabKnop({ it, compact }) { + const pad = normaliseerPad(it.path); + return ( + + ); + } + + function NavDropdown({ groep, items }) { + const open = openMenu === groep; + const actief = items.some((i) => normaliseerPad(i.path) === activePad); + const icon = GROEP_ICON[groep] || items[0].icon; + return ( +
setOpenMenu(null)}> + +
+ {items.map((it) => { + const pad = normaliseerPad(it.path); + return ( + + ); + })} +
+
+ ); + } + + // Instellingen-cog: staat in de bovenbalk (buiten de scrollende tabbalk, + // anders knipt overflow het uitklapmenu weg). + const cogMenu = instellingenTabs.length > 0 ? ( +
setCogOpen(false)}> + +
+ {instellingenTabs.map((it) => { + const pad = normaliseerPad(it.path); + return ( + + ); + })} +
+
+ ) : null; + + return ( +
+
+
+
+ + Roosterwijs + {schoolnaam && · {schoolnaam}} +
+
+ {user.username} + + {cogMenu} +
+
+ +
+ +
+ {error &&
⚠ {error}
} + {loading ? ( +

Laden…

+ ) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? ( + + ) : Page ? ( + + ) : ( + + )} +
+
+ ); +} + +function Placeholder({ path, menu }) { + const item = menu.find((m) => m.path === path); + return ( +
+

{item ? item.label : "Welkom"}

+

+ Dit scherm wordt in een volgende fase ingevuld. Fase 1 levert het + kerndomein: personen, groepen en subgroepen. +

+
+ ); +} + +function ModuleManager({ modules, onChange }) { + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + + async function toggle(m) { + setBusy(m.key); + setMsg(null); + try { + await setModuleState(m.key, !m.enabled); + await onChange(); + } catch (e) { + setMsg(e.message); + } finally { + setBusy(null); + } + } + + return ( +
+

Modulebeheer

+

+ Schakel modules per school in of uit. De kern staat altijd aan. +

+ {msg &&
⚠ {msg}
} +
+ {modules.map((m) => ( +
+
+
+
{m.name}
+
{m.category} · v{m.version}
+
+ toggle(m)} + /> +
+

{m.description}

+
+ {m.core ? ( + Kern · altijd aan + ) : ( + + {m.enabled ? "Ingeschakeld" : "Uitgeschakeld"} + + )} + {m.depends_on.length > 0 && ( + vereist: {m.depends_on.join(", ")} + )} +
+
+ ))} +
+
+ ); +} + +function Toggle({ checked, disabled, onChange }) { + return ( + + ); +} diff --git a/.ui-backup/pages.jsx.bak b/.ui-backup/pages.jsx.bak new file mode 100644 index 0000000..aefc403 --- /dev/null +++ b/.ui-backup/pages.jsx.bak @@ -0,0 +1,4588 @@ +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 && ( + + )} + +
NaamOmschrijvingTheorievakPictogram{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 && } +
+