roosterwijs/frontend/src/pages.jsx
2026-06-17 14:48:04 +02:00

3332 lines
166 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react";
import {
listResource,
createResource,
updateResource,
deleteResource,
getModules,
getLokaalinstelling,
setLokaalinstelling,
uploadBestand,
} 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 <div className="banner error"> {msg}</div>;
}
/* ----------------------- 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 (
<section>
<h1>{titel}</h1>
{uitleg && <p className="muted">{uitleg}</p>}
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
{kleur && (
<input type="color" value={form.kleur} title="Kleur" onChange={(e) => setForm({ ...form, kleur: e.target.value })} />
)}
{metIcoon && (
<input className="icoon-input" maxLength={4} placeholder="icoon" title="Optioneel emoji; leeg = automatisch" value={form.icoon || ""} onChange={(e) => setForm({ ...form, icoon: e.target.value })} />
)}
{metTheorievak && (
<label className="veld">
<input type="checkbox" checked={!!form.theorievak} onChange={(e) => setForm({ ...form, theorievak: e.target.checked })} />
theorievak
</label>
)}
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && (
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>
)}
</form>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Omschrijving</th>
{metTheorievak && <th>Theorievak</th>}
{metPictogram && <th>Pictogram</th>}
{telKolom && <th>{telKolom}</th>}
<th></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td>
{kleur && <span className="kleur-stip" style={{ background: it.kleur }} />}
{metIcoon && <span className="vak-icoon">{it.icoon || vakIcoon(it.naam)} </span>}
{it.naam}
</td>
<td className="muted">{it.omschrijving || "—"}</td>
{metTheorievak && <td>{it.theorievak ? "✓" : "—"}</td>}
{metPictogram && (
<td className="picto-cel">
{it.pictogram && <img src={it.pictogram} alt="" className="picto-thumb" />}
<label className="link small">{it.pictogram ? "vervang" : "upload"}<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) uploadPicto(it.id, f); e.target.value = ""; }} /></label>
{it.pictogram && <button type="button" className="link small danger" onClick={() => verwijderPicto(it.id)}>verwijder</button>}
</td>
)}
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
<td className="row-actions">
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(it.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={kolommen} className="muted">Nog niets.</td></tr>
)}
</tbody>
</table>
)}
</section>
);
}
export function FunctiesPage() {
return (
<CatalogusPage
titel="Functies"
uitleg="Beheer de functies die medewerkers kunnen hebben (bv. Directie, Logopedist). Medewerkers kunnen meerdere functies hebben."
resource="functies"
telKolom="Medewerkers"
/>
);
}
export function ActiviteitenPage() {
return (
<CatalogusPage
titel="Activiteiten / vakken"
uitleg="Wat er gedaan wordt: rekenen, gym, therapie, pauze … Elke activiteit heeft een kleur. Markeer theorievakken; die koppelen automatisch aan nieuwe leerkrachten."
resource="activiteiten"
kleur
metTheorievak
metIcoon
metPictogram
/>
);
}
export function LocatiesPage() {
return (
<CatalogusPage
titel="Locaties"
uitleg="Vrije locatie-labels (bijv. externe plekken). Voor lesruimtes met dubbele-boekingcheck: zie Lokalen."
resource="locaties"
/>
);
}
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 (
<section>
<h1>Lokalen</h1>
<p className="muted">Lesruimtes die door lessen én interne stages gedeeld worden. Het systeem bewaakt dubbele boekingen per tijdslot.</p>
<Foutmelding msg={error} />
<Foutmelding msg={instError} />
<label className="veld" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={blokkeren} onChange={(e) => toggleBlokkeren(e.target.checked)} />
{" "}Dubbele boeking hard blokkeren (anders alleen waarschuwen in het conflicten-overzicht)
</label>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<input type="number" min="1" placeholder="Capaciteit (optioneel)" value={form.capaciteit} onChange={(e) => setForm({ ...form, capaciteit: e.target.value })} />
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Capaciteit</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{items.map((l) => (
<tr key={l.id}>
<td>{l.naam}</td>
<td className="muted">{l.capaciteit ?? "—"}</td>
<td className="muted">{l.omschrijving || "—"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(l.id); setForm({ naam: l.naam, omschrijving: l.omschrijving || "", capaciteit: l.capaciteit ?? "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(l.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen lokalen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* --------------------------------- Groepen -------------------------------- */
export function GroepenPage() {
return <GroepsindelingPage />;
}
/* -------------------------------- 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 (
<section>
<h1>Personen</h1>
<p className="muted">Personeel en leerlingen staan los van elkaar. Kies wat je wilt toevoegen.</p>
<Foutmelding msg={error} />
<div className="tabbar">
<button className={!importView && modus === "leerling" ? "tab on" : "tab"} onClick={() => { kiesModus("leerling"); setImportView(false); }}>Leerling toevoegen</button>
<button className={!importView && modus === "medewerker" ? "tab on" : "tab"} onClick={() => { kiesModus("medewerker"); setImportView(false); }}>Medewerker toevoegen</button>
{wizardAan && <button className={importView ? "tab on" : "tab"} onClick={() => setImportView(true)}>📋 Bulk / CSV-import</button>}
</div>
{importView ? <PersonenWizardPage /> : (<>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
{modus === "leerling" ? (
<>
<select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<label className="veld">Geb. <input type="date" value={form.geboortedatum || ""} onChange={(e) => setForm({ ...form, geboortedatum: e.target.value })} /></label>
</>
) : (
<>
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{STAFF_ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<select value={form.stamgroep || ""} onChange={(e) => setForm({ ...form, stamgroep: e.target.value })}>
<option value=""> stamgroep/plein </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</>
)}
</div>
{modus === "medewerker" && (
<>
<div className="leden-keuze">
<div className="muted small">Functies (klik om te kiezen, of typ een nieuwe):</div>
<div className="chips">
{functies.map((f) => <button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggle("functies", f.id)}>{f.naam}</button>)}
{functies.length === 0 && <span className="muted small">Nog geen functies.</span>}
</div>
<div className="form-row" style={{ marginTop: 6 }}>
<input
placeholder="Nieuwe functie"
value={nieuweFunctie}
onChange={(e) => setNieuweFunctie(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakFunctie(); } }}
/>
<button type="button" className="btn" onClick={maakFunctie}>+ Functie</button>
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Vakken die gegeven kunnen worden{form.rol === "leerkracht" ? " (theorievakken worden automatisch gekoppeld)" : ""}:</div>
<div className="chips">
{vakken.map((a) => <button type="button" key={a.id} className={form.vakken.includes(a.id) ? "chip on" : "chip"} onClick={() => toggle("vakken", a.id)}>{a.naam}{a.theorievak ? " ●" : ""}</button>)}
{vakken.length === 0 && <span className="muted small">Nog geen vakken/activiteiten.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Subgroepen (begeleiding):</div>
<div className="chips">
{subgroepen.map((s) => <button type="button" key={s.id} className={form.begeleide_subgroepen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggle("begeleide_subgroepen", s.id)}>{s.naam}</button>)}
{subgroepen.length === 0 && <span className="muted small">Nog geen subgroepen.</span>}
</div>
</div>
{leerpleinen.length > 0 && (
<div className="leden-keuze">
<div className="muted small">{BOVENGROEP_LABEL_MV}:</div>
<div className="chips">
{leerpleinen.map((lp) => <button type="button" key={lp.id} className={form.leerpleinen.includes(lp.id) ? "chip on" : "chip"} onClick={() => toggle("leerpleinen", lp.id)}>{lp.naam}</button>)}
</div>
</div>
)}
</>
)}
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => kiesModus(modus)}>Annuleren</button>}
</div>
</form>
<div className="filterbar">
<span className="muted">Toon:</span>
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
<button className={filter === "leerling" ? "chip on" : "chip"} onClick={() => setFilter("leerling")}>Leerlingen</button>
<button className={filter === "medewerker" ? "chip on" : "chip"} onClick={() => setFilter("medewerker")}>Medewerkers</button>
</div>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Rol</th><th>Details</th><th></th></tr></thead>
<tbody>
{zichtbaar.map((p) => (
<tr key={p.id}>
<td>{p.volledige_naam}</td>
<td>{p.rol_label}</td>
<td className="muted small">
{p.rol === "leerling" ? (
<>
{p.groep_naam ? <span>🗂 {p.groep_naam} </span> : null}
{p.geboortedatum ? <span>🎂 {p.geboortedatum}</span> : null}
{!p.groep_naam && !p.geboortedatum ? "—" : null}
</>
) : (
<>
{p.stamgroep_naam ? <div>🗂 {p.stamgroep_naam}</div> : null}
{p.functies_namen.length ? <div>🪪 {p.functies_namen.map((x) => x.naam).join(", ")}</div> : null}
{p.vakken_namen.length ? <div>📚 {p.vakken_namen.map((x) => x.naam).join(", ")}</div> : null}
{!p.stamgroep_naam && !p.functies_namen.length && !p.vakken_namen.length ? "—" : null}
</>
)}
</td>
<td className="row-actions">
<button className="link" onClick={() => bewerk(p)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
</td>
</tr>
))}
{zichtbaar.length === 0 && <tr><td colSpan="4" className="muted">Geen personen.</td></tr>}
</tbody>
</table>
)}
</>)}
</section>
);
}
/* ------------------------------- Subgroepen ------------------------------- */
export function SubgroepenPage() {
return <GroepsindelingPage />;
}
/* ------------------------------- 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 (
<section>
<h1>Tijdsloten</h1>
<p className="muted">Vaste tijden per schooldag. Laat de groep leeg voor de algemene tijden, of kies een groep voor afwijkende tijden.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. Blok 1)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<select value={form.dag} onChange={(e) => setForm({ ...form, dag: Number(e.target.value) })}>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input type="time" value={form.start_tijd} onChange={(e) => setForm({ ...form, start_tijd: e.target.value })} />
<input type="time" value={form.eind_tijd} onChange={(e) => setForm({ ...form, eind_tijd: e.target.value })} />
<input type="number" min="0" style={{ width: 80 }} title="Volgorde" value={form.volgorde} onChange={(e) => setForm({ ...form, volgorde: Number(e.target.value) })} />
<select value={form.groep || ""} title="Groep (leeg = algemeen)" onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value="">algemeen</option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Dag</th><th>Tijd</th><th>Naam</th><th>Geldt voor</th><th></th></tr></thead>
<tbody>
{items.map((t) => (
<tr key={t.id}>
<td>{t.dag_label}</td>
<td>{t.start_tijd?.slice(0, 5)}{t.eind_tijd?.slice(0, 5)}</td>
<td>{t.naam}</td>
<td className="muted">{t.groep_naam || "algemeen"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(t.id); setForm({ naam: t.naam, dag: t.dag, start_tijd: t.start_tijd?.slice(0, 5), eind_tijd: t.eind_tijd?.slice(0, 5), volgorde: t.volgorde, groep: t.groep || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(t.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="5" className="muted">Nog geen tijdsloten.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* --------------------------- 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 (
<section>
<h1>Schooljaar &amp; kalender</h1>
<p className="muted">Schooljaren met studiedagen, vrije dagen en vakanties. Op die dagen vervallen de roosterblokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. 2026-2027)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<label className="veld">Start <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">Eind <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Schooljaar</th><th>Periode</th><th></th></tr></thead>
<tbody>
{jaren.map((j) => (
<tr key={j.id} className={gekozen === j.id ? "rij-actief" : ""}>
<td>{j.naam}</td>
<td className="muted">{j.start_datum} t/m {j.eind_datum}</td>
<td className="row-actions">
<button className="link" onClick={() => setGekozen(gekozen === j.id ? null : j.id)}>
{gekozen === j.id ? "kalender verbergen" : "kalender beheren"}
</button>
<button className="link danger" onClick={() => verwijder(j.id)}>verwijderen</button>
</td>
</tr>
))}
{jaren.length === 0 && <tr><td colSpan="3" className="muted">Nog geen schooljaren.</td></tr>}
</tbody>
</table>
)}
{gekozen && <Kalender schooljaarId={gekozen} />}
</section>
);
}
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 (
<div className="subpaneel">
<h2>Kalender</h2>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input type="date" value={form.datum} required onChange={(e) => setForm({ ...form, datum: e.target.value })} />
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{DAGTYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input placeholder="Omschrijving (bv. Kerstvakantie)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<button className="btn primary" type="submit">Dag toevoegen</button>
</form>
<table className="grid">
<thead><tr><th>Datum</th><th>Type</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{d.datum}</td>
<td><span className={`tag dag-${d.type}`}>{d.type_label}</span></td>
<td className="muted">{d.omschrijving || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(d.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen bijzondere dagen.</td></tr>}
</tbody>
</table>
</div>
);
}
/* ------------------------------- 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 (
<section>
<h1>Afwezigheid</h1>
<p className="muted">Leg vast wie wanneer afwezig is. In het rooster zie je dat terug bij de betrokken blokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<select value={form.persoon} required onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
<option value=""> persoon </option>
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
</select>
<label className="veld">Van <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">T/m <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<input placeholder="Reden (optioneel)" value={form.reden} onChange={(e) => setForm({ ...form, reden: e.target.value })} />
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Persoon</th><th>Periode</th><th>Reden</th><th></th></tr></thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>{a.persoon_naam}</td>
<td className="muted">{a.start_datum} t/m {a.eind_datum}</td>
<td className="muted">{a.reden || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(a.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Geen afwezigheden.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* ----------------------------- 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 (
<form
className={klein ? "inline-add klein" : "inline-add"}
onSubmit={(e) => { e.preventDefault(); if (v.trim()) { onAdd(v.trim()); setV(""); } }}
>
<input placeholder={placeholder} value={v} onChange={(e) => setV(e.target.value)} />
<button className="btn klein" type="submit">+</button>
</form>
);
}
export function GroepsindelingPage() {
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 (
<span
key={(container ? container.kind + container.id : "pool") + "-" + p.id}
className={`chip-persoon ${rol === "leerling" ? "ll" : "mw"}${sel ? " geselecteerd" : ""}`}
onPointerDown={(e) => startDrag(e, { id: p.id, rol, naam: p.volledige_naam })}
title={p.volledige_naam}
>
<button type="button" className="chip-sel" title="(de)selecteer"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleSel(p.id); }}
>{sel ? "☑" : "☐"}</button>
<span className="chip-naam">{p.volledige_naam}</span>
{rol !== "leerling" && SROL[p.rol] && <span className="chip-rol">{SROL[p.rol]}</span>}
{container && (
<button type="button" className="chip-x" title="Uit deze groep halen"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); haalWeg(container, { id: p.id, rol }); }}
>×</button>
)}
</span>
);
};
function subKaart(s) {
const leeg = s.begeleiders_namen.length + s.leden_namen.length === 0;
return (
<div className="subgroep-kaart" key={s.id}>
<div className="kaart-kop sub-kop">
<strong>{s.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("subgroepen", s.id, s.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("subgroepen", s.id, `Subgroep '${s.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("subgroep", s.id)}>
<div className="chip-rij">
{s.begeleiders_namen.map((b) => chip(medewerkerById(b.id) || { id: b.id, volledige_naam: b.naam, rol: "leerkracht" }, "medewerker", { kind: "subgroep", id: s.id }))}
{s.leden_namen.map((l) => chip(leerlingById(l.id) || { id: l.id, volledige_naam: l.naam }, "leerling", { kind: "subgroep", id: s.id }))}
{leeg && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
</div>
);
}
function groepKaart(g) {
const orphan = !groepHeeftLeerplein(g.id);
const mws = groepMedewerkers(g.id);
const lls = groepLeerlingen(g.id);
return (
<div className="groep-kaart" key={g.id}>
<div className="kaart-kop groep-kop">
<strong>{g.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("groepen", g.id, g.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("groepen", g.id, `Groep '${g.naam}' verwijderen? (roosterblokken van deze groep vervallen)`)}>×</button>
</span>
</div>
<div {...zoneProps("groep", g.id)}>
<div className="chip-rij">
{mws.map((m) => chip(m, "medewerker", { kind: "groep", id: g.id }))}
{lls.map((l) => chip(l, "leerling", { kind: "groep", id: g.id }))}
{mws.length + lls.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
{orphan && (
<div className="koppel-rij">
<select value="" onChange={(e) => e.target.value && koppelGroep(g, Number(e.target.value))}>
<option value="">koppel aan bovenliggende groep</option>
{leerpleinen.map((lp) => <option key={lp.id} value={lp.id}>{lp.naam}</option>)}
</select>
</div>
)}
<div className="sub-lijst">
{groepSubs(g.id).map((s) => subKaart(s))}
<InlineAdd klein placeholder="+ subgroep" onAdd={(naam) => nieuweSubgroep(g, naam)} />
</div>
</div>
);
}
const orphanGroepen = groepen.filter((g) => !groepHeeftLeerplein(g.id));
return (
<section className="indeling">
<h1>Groepsindeling</h1>
<p className="muted">Sleep personen naar een bovenliggende groep, groep of subgroep. <span className="legenda ll">leerling</span> en <span className="legenda mw">personeel</span> hebben elk een eigen kleur. Vink meerdere personen aan om ze in één keer te plaatsen; × haalt iemand weer weg.</p>
<Foutmelding msg={error} />
<div className="indeling-toolbar no-print">
<span className="muted small">Tonen:</span>
<div className="segmented">
{[["allen", "Allen"], ["leerling", "Leerlingen"], ["personeel", "Personeel"]].map(([v, l]) => (
<button key={v} type="button" className={toon === v ? "seg on" : "seg"} onClick={() => setToon(v)}>{l}</button>
))}
</div>
</div>
{selectie.size > 0 && (
<div className="selectie-bar no-print">
<strong>{selectie.size}</strong> geselecteerd
<select value={plaatsDoel} onChange={(e) => setPlaatsDoel(e.target.value)}>
<option value=""> plaats in </option>
{containerOpties.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<button className="btn primary klein" disabled={!plaatsDoel} onClick={() => plaatsDoel && plaatsSelectie(parseZone(plaatsDoel))}>Plaats</button>
<button className="btn klein" onClick={wisSelectie}>Wis selectie</button>
</div>
)}
{loading ? <p>Laden</p> : (
<div className="indeling-layout">
<aside className="indeling-pool">
<h3>Nog niet ingedeeld</h3>
{toonRol("leerling") && (
<div className="pool-sectie">
<div className="pool-kop">Leerlingen</div>
<div className="chip-rij">
{losseLeerlingen.map((l) => chip(l, "leerling", null))}
{losseLeerlingen.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
</div>
</div>
)}
{toonRol("medewerker") && (
<div className="pool-sectie">
<div className="pool-kop">Personeel</div>
<div className="chip-rij">
{losseMedewerkers.map((m) => chip(m, "medewerker", null))}
{losseMedewerkers.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
</div>
</div>
)}
</aside>
<div className="indeling-board">
<InlineAdd placeholder="Nieuwe bovenliggende groep…" onAdd={nieuweBovengroep} />
{leerpleinen.map((lp) => {
const mwIds = lp.medewerkers || [];
const llIds = lp.leerlingen || [];
return (
<div className="bovengroep-kaart" key={lp.id}>
<div className="kaart-kop bovengroep-kop">
<strong>{lp.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("leerpleinen", lp.id, lp.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("leerpleinen", lp.id, `Bovenliggende groep '${lp.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("leerplein", lp.id)}>
<div className="zone-kop">Direct in deze bovenliggende groep</div>
<div className="chip-rij">
{mwIds.map((id) => chip(medewerkerById(id), "medewerker", { kind: "leerplein", id: lp.id }))}
{llIds.map((id) => chip(leerlingById(id), "leerling", { kind: "leerplein", id: lp.id }))}
{mwIds.length + llIds.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
<div className="groep-lijst">
{lpGroepen(lp).map((g) => groepKaart(g))}
<InlineAdd klein placeholder="+ groep" onAdd={(naam) => nieuweGroep(lp, naam)} />
</div>
</div>
);
})}
{leerpleinen.length === 0 && <div className="muted">Nog geen bovenliggende groepen. Maak er hierboven één aan.</div>}
{orphanGroepen.length > 0 && (
<div className="bovengroep-kaart los">
<div className="kaart-kop"><strong>Groepen zonder bovenliggende groep</strong></div>
<div className="groep-lijst">
{orphanGroepen.map((g) => groepKaart(g))}
</div>
</div>
)}
</div>
</div>
)}
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section>
);
}
/* ---------------------------------- 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 (
<section>
<h1>Stage</h1>
<p className="muted">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.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<select value={form.leerling} required onChange={(e) => setForm({ ...form, leerling: e.target.value })}>
<option value=""> leerling </option>
{leerlingen.map((l) => <option key={l.id} value={l.id}>{l.volledige_naam}</option>)}
</select>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="intern">Interne stage</option>
<option value="extern">Externe stage</option>
</select>
<select value={form.begeleider} required onChange={(e) => setForm({ ...form, begeleider: e.target.value })}>
<option value=""> begeleider </option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</div>
<div className="form-row">
{isIntern ? (
<select value={form.lokaal} required onChange={(e) => setForm({ ...form, lokaal: e.target.value })}>
<option value=""> lokaal </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
) : (
<input placeholder="Externe organisatie" value={form.organisatie} required onChange={(e) => setForm({ ...form, organisatie: e.target.value })} />
)}
</div>
<div className="form-row">
<label className="veld-kol">
<span className="veld-kop">Omvang</span>
<select value={form.omvang} onChange={(e) => setForm({ ...form, omvang: e.target.value })}>
{OMVANGEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
{isBlok ? (
<label className="veld-kol">
<span className="veld-kop">Roosterblok</span>
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> roosterblok </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{tijdslotLabel(t.id)}</option>)}
</select>
</label>
) : (
<label className="veld-kol">
<span className="veld-kop">Dag</span>
<select value={form.dag} required onChange={(e) => setForm({ ...form, dag: e.target.value })}>
<option value=""> dag </option>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
)}
{isDagdeel && (
<label className="veld-kol">
<span className="veld-kop">Dagdeel</span>
<select value={form.dagdeel} required onChange={(e) => setForm({ ...form, dagdeel: e.target.value })}>
<option value=""> kies dagdeel </option>
{DAGDELEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
)}
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Leerling</th><th>Type</th><th>Begeleider</th><th>Waar</th><th>Wanneer</th><th></th></tr></thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>{s.leerling_naam}</td>
<td>{s.type_label}</td>
<td>{s.begeleider_naam}</td>
<td className="muted">{s.type === "intern" ? (s.lokaal_naam || "—") : (s.organisatie || "—")}</td>
<td className="muted">{wanneerTekst(s)}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(s.id); setForm({ leerling: s.leerling, type: s.type, begeleider: s.begeleider, lokaal: s.lokaal || "", organisatie: s.organisatie || "", omvang: s.omvang || "hele_dag", dag: s.dag ?? "", dagdeel: s.dagdeel || "", tijdslot: s.tijdslot || "", omschrijving: s.omschrijving || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="6" className="muted">Nog geen stages.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- Gebruikers ------------------------------ */
const GEBRUIKERSROLLEN = [
{ value: "beheerder", label: "Beheerder" },
{ value: "medewerker", label: "Medewerker" },
{ value: "ouder", label: "Ouder" },
{ value: "leerling", label: "Leerling" },
];
export function GebruikersPage() {
const { items, error, loading, reload, setError } = useList("gebruikers");
const [personen, setPersonen] = useState([]);
const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
async function opslaan(e) {
e.preventDefault();
const payload = {
username: form.username, first_name: form.first_name, last_name: form.last_name,
email: form.email, rol: form.rol, persoon: form.persoon || null,
is_staff: form.is_staff, is_active: form.is_active,
};
if (form.password) payload.password = form.password;
try {
if (editId) await updateResource("gebruikers", editId, payload);
else await createResource("gebruikers", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Deze gebruiker verwijderen?")) return;
try { await deleteResource("gebruikers", id); await reload(); } catch (err) { setError(err.message); }
}
function bewerk(u) {
setEditId(u.id);
setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active });
}
return (
<section>
<h1>Gebruikers</h1>
<p className="muted">Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
<input type="password" placeholder={editId ? "Wachtwoord (leeg = ongewijzigd)" : "Wachtwoord"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div className="form-row">
<input placeholder="Voornaam" value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} />
<input placeholder="Achternaam" value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} />
<input type="email" placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="form-row">
<select value={form.persoon || ""} onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
<option value=""> koppel aan persoon (optioneel) </option>
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
</select>
<label className="veld"><input type="checkbox" checked={form.is_staff} onChange={(e) => setForm({ ...form, is_staff: e.target.checked })} /> beheerder (is_staff)</label>
<label className="veld"><input type="checkbox" checked={form.is_active} onChange={(e) => setForm({ ...form, is_active: e.target.checked })} /> actief</label>
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Gebruiker</th><th>Rol</th><th>Persoon</th><th>Status</th><th></th></tr></thead>
<tbody>
{items.map((u) => (
<tr key={u.id}>
<td>{u.username}{(u.first_name || u.last_name) && <div className="muted small">{u.first_name} {u.last_name}</div>}</td>
<td>{u.rol_label || "—"}{u.is_staff && <span className="tag core"> beheer</span>}</td>
<td className="muted">{u.persoon_naam || "—"}</td>
<td className="muted small">{u.is_active ? "actief" : "inactief"}</td>
<td className="row-actions">
<button className="link" onClick={() => bewerk(u)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(u.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="5" className="muted">Geen gebruikers.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- 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 <p className="muted">Kies eerst een schooljaar.</p>;
if (error) return <Foutmelding msg={error} />;
if (!data) return <p>Laden</p>;
return (
<div>
<p className="muted">{data.aantal === 0 ? "Geen conflicten gevonden 🎉" : `${data.fouten} fout(en) en ${data.waarschuwingen} waarschuwing(en).`}</p>
{data.conflicten.map((c, i) => (
<div className={c.ernst === "fout" ? "conflict fout" : "conflict waarschuwing"} key={i}>
<div className="conflict-kop"><strong>{c.tijdslot}</strong> <span className="tag">{c.type}</span></div>
<div>{c.detail}</div>
<div className="muted small">Blokken: {c.blokken.join(", ")}</div>
</div>
))}
</div>
);
}
/* ------------------------------ Datumhulpjes ------------------------------ */
function pad2(n) { return String(n).padStart(2, "0"); }
function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
function fmtDM(d) { return `${pad2(d.getDate())}-${pad2(d.getMonth() + 1)}`; }
function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; }
function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
/* ------------------------- Rooster-scope (filter/print) ------------------- */
// Welke leerlingen raakt een blok (via groep, subgroep of individueel)?
export function leerlingenVanBlok(blok, ctx) {
if (blok.groep) return ctx.leerlingen.filter((l) => l.groep === blok.groep).map((l) => l.id);
if (blok.subgroep) {
const s = ctx.subgroepen.find((x) => x.id === blok.subgroep);
return s ? s.leden_namen.map((m) => m.id) : [];
}
if (blok.leerling) return [blok.leerling];
return [];
}
// Welke leerlingen vallen binnen een gekozen scope (klas/bovenliggende groep/leerling)?
export function scopeLeerlingen(scope, ctx) {
if (scope.type === "leerling") return [scope.id];
if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id);
if (scope.type === "leerplein") {
const p = ctx.leerpleinen.find((x) => x.id === scope.id);
if (!p) return [];
const ids = new Set(p.leerlingen || []);
(p.groepen || []).forEach((g) => ctx.leerlingen.filter((l) => l.groep === g).forEach((l) => ids.add(l.id)));
(p.subgroepen || []).forEach((sid) => {
const s = ctx.subgroepen.find((x) => x.id === sid);
if (s) s.leden_namen.forEach((m) => ids.add(m.id));
});
return [...ids];
}
return [];
}
// Valt een blok binnen de gekozen scope?
export function blokInScope(blok, scope, ctx) {
if (scope.type === "alles" || !scope.id) return true;
if (scope.type === "personeel") return (blok.begeleiders || []).includes(scope.id);
const set = new Set(scopeLeerlingen(scope, ctx));
return leerlingenVanBlok(blok, ctx).some((id) => set.has(id));
}
function groepVanSubgroep(subgroepen, subgroepId) {
const sub = subgroepen.find((s) => s.id === subgroepId);
return sub ? sub.groep : null;
}
function bovenliggendeGroep(blok, subgroepen) {
if (blok.groep) return blok.groep;
if (blok.subgroep) return groepVanSubgroep(subgroepen, blok.subgroep);
return null;
}
/* ----------------------------- Blok bewerken/kopiëren --------------------- */
function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
const { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx;
const [f, setF] = useState({
tijdslot: blok.tijdslot,
activiteit: blok.activiteit,
locatie: blok.locatie || "",
lokaal: blok.lokaal || "",
doel_type: blok.doel_type,
doel: blok.groep || blok.subgroep || blok.leerling || "",
begeleiders: [...(blok.begeleiders || [])],
opmerkingen: blok.opmerkingen || "",
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 (
<div className="modal-overlay no-print" onClick={onSluit}>
<div className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Roosterblok bewerken" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>{blok.activiteit_naam || "Roosterblok"}</h2>
<div className="muted small">{blok.doel_naam || "Bewerken en splitsen"}</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={onSluit}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Vak bewerken</strong>
<span className="muted small">Wijzig, splits of kopieer dit blok</span>
</div>
<Foutmelding msg={error} />
<div className="editor-grid">
<select value={f.tijdslot} title="Tijdslot" onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
</select>
<select value={f.activiteit} title="Activiteit" onChange={(e) => setF({ ...f, activiteit: e.target.value })}>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
<select value={f.locatie} title="Locatie" onChange={(e) => setF({ ...f, locatie: e.target.value })}>
<option value=""> locatie </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={f.lokaal} title="Lokaal" onChange={(e) => setF({ ...f, lokaal: e.target.value })}>
<option value=""> lokaal </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
<input type="date" title="Geldig vanaf (leeg = altijd)" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} />
<input type="date" title="Geldig t/m (leeg = altijd)" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} />
</div>
<details className="editor-detail">
<summary>Doelgroep en begeleiding</summary>
<div className="editor-grid">
<select value={f.doel_type} onChange={(e) => {
setF({ ...f, doel_type: e.target.value, doel: "" });
if (e.target.value !== "subgroep") setSubgroepGroepFilter("");
}}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
<option value=""> kies {f.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
{f.doel_type === "subgroep" && (
<>
<select value={subgroepGroepFilter || ""} onChange={(e) => { setSubgroepGroepFilter(e.target.value ? Number(e.target.value) : ""); setF({ ...f, doel: "", doel_type: "subgroep" }); }}>
<option value=""> alle groepen </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<input placeholder="Nieuwe subgroep" value={nieuweSub} onChange={(e) => setNieuweSub(e.target.value)} />
<button type="button" className="btn icon-label" onClick={maakSubgroep} title="Subgroep aanmaken"> <span>Subgroep</span></button>
</>
)}
</div>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)} title={m.rol_label || "Begeleider"}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
</div>
</details>
<div className="editor-actions">
<button type="button" className="btn primary icon-label" onClick={opslaan} title="Opslaan"> <span>Opslaan</span></button>
<button type="button" className="btn icon-label" onClick={verwijderen} title="Verwijderen">🗑 <span>Verwijderen</span></button>
</div>
<details className="editor-detail split-editor" open>
<summary>Splitsen onder {splitGroepNaam}</summary>
<div className="editor-grid">
<select value={splitGroepId} onChange={(e) => { setSplitGroepId(e.target.value); setSplitSubs([]); }} title="Onder welke groep splitsen">
<option value=""> kies groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</div>
<div className="chips compact">
{splitOpties.map((s) => <button type="button" key={s.id} className={splitSubs.includes(s.id) ? "chip on" : "chip"} onClick={() => toggleSplit(s.id)}>{s.naam}</button>)}
{splitOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
</div>
{splitSubs.length > 0 && (
<div className="split-preview">
<div className="split-preview-root">{splitGroepNaam}</div>
<div className="split-preview-children">
{splitSubs.map((sid) => {
const sub = splitOpties.find((s) => s.id === sid) || subs.find((s) => s.id === sid);
return <span key={sid}>{sub ? sub.naam : "Subgroep"}</span>;
})}
</div>
</div>
)}
<div className="editor-grid">
<input placeholder="Nieuwe subgroep" value={splitNieuw} onChange={(e) => setSplitNieuw(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakSplitSub(); } }} />
<button type="button" className="btn icon-label" onClick={maakSplitSub} title="Subgroep toevoegen"> <span>Subgroep</span></button>
</div>
<label className="veld editor-check"><input type="checkbox" checked={verwijderOrig} onChange={(e) => setVerwijderOrig(e.target.checked)} /> oorspronkelijke blok verwijderen</label>
<button type="button" className="btn primary icon-label" disabled={splitSubs.length === 0} onClick={opdelen} title="Opdelen"> <span>{splitSubs.length}</span></button>
</details>
<details className="editor-detail">
<summary>Kopiëren</summary>
<div className="chips compact">
{tijdsloten.map((t) => <button type="button" key={t.id} className={kopieDoelen.includes(t.id) ? "chip on" : "chip"} onClick={() => toggleKopie(t.id)}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</button>)}
</div>
<button type="button" className="btn icon-label" disabled={kopieDoelen.length === 0} onClick={kopieer} title="Kopiëren"> <span>{kopieDoelen.length}</span></button>
</details>
</div>
</div>
</div>
);
}
/* ------------------------------- 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 (
<div>
<p className="muted">Kies een hoofdgroep of bovenliggende groep en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.</p>
<Foutmelding msg={error} />
<div className="rooster-balk">
<label className="veld">Type
<select value={type} onChange={(e) => { setType(e.target.value); setHoofdId(""); setNieuw({ naam: "", groep: "", begeleiders: [] }); }}>
<option value="groep">Groep</option>
<option value="leerplein">{BOVENGROEP_LABEL}</option>
</select>
</label>
<select value={hoofdId} onChange={(e) => setHoofdId(e.target.value)}>
<option value=""> kies {type} </option>
{opties.map((o) => <option key={o.id} value={o.id}>{o.naam}</option>)}
</select>
</div>
{hid && hoofd && (
<div className="indeling">
<div className="indeling-hoofd">{hoofd.naam} <span className="muted small"> {type}</span></div>
{type === "leerplein" ? (
<div className="indeling-groepen">
{groepenMetSubgroepen.map(({ groep, subs: groepSubs }) => (
<div className="indeling-groep" key={groep.id}>
<div className="indeling-groep-kop">{groep.naam}</div>
<div className="indeling-kolommen">
{groepSubs.map((s) => (
<div className="indeling-kolom" key={s.id}>
<div className="indeling-kop">{s.naam}</div>
<div className="small">Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}</div>
<div className="small">Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}</div>
<div className="small muted">{s.aantal_leden} leerling(en)</div>
<button className="link danger small" onClick={() => verwijderSub(s.id)}>verwijderen</button>
</div>
))}
{groepSubs.length === 0 && <div className="muted small">Nog geen subgroepen onder {groep.naam}.</div>}
</div>
</div>
))}
{leerpleinGroepen.length === 0 && <div className="muted">Nog geen groepen onder {hoofd.naam}.</div>}
</div>
) : (
<div className="indeling-kolommen">
{subs.map((s) => (
<div className="indeling-kolom" key={s.id}>
<div className="indeling-kop">{s.naam}</div>
<div className="small">Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}</div>
<div className="small">Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}</div>
<div className="small muted">{s.aantal_leden} leerling(en)</div>
<button className="link danger small" onClick={() => verwijderSub(s.id)}>verwijderen</button>
</div>
))}
{subs.length === 0 && <div className="muted">Nog geen subgroepen onder {hoofd.naam}.</div>}
</div>
)}
<form className="form-card" onSubmit={voegToe}>
<div className="muted small">Subgroep toevoegen onder {type === "groep" ? hoofd.naam : `een groep binnen deze ${BOVENGROEP_LABEL_LC}`}:</div>
<div className="form-row">
{type === "leerplein" && (
<select value={nieuw.groep || ""} required onChange={(e) => setNieuw({ ...nieuw, groep: e.target.value })}>
<option value=""> groep </option>
{leerpleinGroepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
)}
<input placeholder="Naam subgroep" value={nieuw.naam} required onChange={(e) => setNieuw({ ...nieuw, naam: e.target.value })} />
<button className="btn primary" type="submit">+ Subgroep</button>
</div>
<div className="leden-keuze">
<div className="muted small">Verantwoordelijke(n):</div>
<div className="chips">
{medewerkers.map((m) => <button type="button" key={m.id} className={nieuw.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}</button>)}
</div>
</div>
</form>
</div>
)}
</div>
);
}
/* ---------------------------------- 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 [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);
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)); }).catch(() => {});
}, []);
// Scope-context + gefilterde blokken (voor weergave/afdrukken).
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx));
const scopeOpties = scopeType === "klas" ? groepen
: scopeType === "leerplein" ? leerpleinen
: scopeType === "leerling" ? leerlingen
: scopeType === "personeel" ? medewerkers
: [];
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const vanISO = fmtISO(maandag);
const totISO = fmtISO(plusDagen(maandag, 6));
async function laadBlokken() {
if (!schooljaar) { setBlokken([]); return; }
try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`));
setKalender(await listResource("kalenderdagen", `?schooljaar=${schooljaar}`));
setError(null);
} catch (e) { setError(e.message); }
}
async function laadWeek() {
if (!schooljaar) return;
try {
setAfwezig(await listResource("afwezigheden", `?van=${vanISO}&tot=${totISO}`));
setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`));
} catch (e) { setError(e.message); }
}
async function herlaadNaBlokWijziging() {
try { setSubgroepen(await listResource("subgroepen")); } catch { /* blokken verversen blijft leidend */ }
await laadBlokken();
}
laadRef.current = laadBlokken;
useEffect(() => { laadBlokken(); }, [schooljaar]);
useEffect(() => { laadWeek(); }, [schooljaar, vanISO]);
// Listeners voor drag-and-drop, één keer opgezet.
useEffect(() => {
function move(e) {
if (!dragRef.current) return;
setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.activiteit_naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
setHoverSlot(cell ? Number(cell.dataset.tijdslot) : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
const doel = cell ? Number(cell.dataset.tijdslot) : null;
const blok = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverSlot(null);
if (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);
}
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) ? <div className="small rb-stage">📍 Op stage niet in de klas</div> : null;
}
const namen = leerlingenVanBlok(b, scopeCtx).filter((id) => opStage.has(id)).map(leerlingNaam);
if (namen.length === 0) return null;
return <div className="small rb-stage">📍 Op stage: {namen.join(", ")}</div>;
}
const editorCtx = { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten };
const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id;
const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b));
function renderRoosterBlok(b, iso, kal) {
const uit = uitzOp(b.id, iso);
const vervallen = !!kal || (uit && uit.vervalt);
return (
<div
className={(vervallen ? "rooster-blok vervallen" : "rooster-blok") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "")}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur }}
onClick={edit ? () => toggleOpenBlok(b) : undefined}
title={edit ? "Klik om te bewerken" : undefined}
>
<div className="rb-top">
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot"></span>}
{b.activiteit_pictogram && <img src={b.activiteit_pictogram} alt="" className="rb-picto" />}<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{edit && <button type="button" className="icon-btn" onClick={(e) => { e.stopPropagation(); toggleOpenBlok(b); }} title="Bewerken"></button>}
{edit && <button type="button" className="icon-btn danger rb-x" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
</div>
<div className="small">Doel: {b.doel_naam} <span className="muted">({b.doel_type})</span></div>
{b.locatie_naam && <div className="small">Locatie: {b.locatie_naam}</div>}
{b.lokaal_naam && <div className="small">Lokaal: {b.lokaal_naam}</div>}
{b.begeleiders_namen.length > 0 && (
<div className="small muted">Begeleiding: {b.begeleiders_namen.map((x, idx) => (
<span key={x.id} className={persoonAfwezig(x.id, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""}</span>
))}</div>
)}
{stageNoot(b)}
{kal && <div className="small rb-status">Vervalt - {kal.type_label}</div>}
{!kal && uit && uit.vervalt && <div className="small rb-status">Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <button type="button" className="icon-btn" title="Herstellen" onClick={(e) => { e.stopPropagation(); herstel(uit.id); }}></button>}</div>}
{!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">Vervanger: {uit.vervanger_naam}</div>}
{edit && !kal && !uit && (
<button type="button" className="icon-btn danger rb-verval no-print" title="Dit vak laten vervallen" onClick={(e) => { e.stopPropagation(); laatVervallen(b, iso); }}></button>
)}
</div>
);
}
function renderCompactBlok(b, iso, kal) {
const uit = uitzOp(b.id, iso);
const vervallen = !!kal || (uit && uit.vervalt);
return (
<div
className={(vervallen ? "split-vak vervallen" : "split-vak") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "")}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur }}
onClick={edit ? () => toggleOpenBlok(b) : undefined}
title={edit ? "Klik om te bewerken" : undefined}
>
<div className="split-vak-kop">
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot"></span>}
{b.activiteit_pictogram && <img src={b.activiteit_pictogram} alt="" className="rb-picto" />}<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{edit && <button type="button" className="icon-btn" onClick={(e) => { e.stopPropagation(); toggleOpenBlok(b); }} title="Bewerken"></button>}
{edit && <button type="button" className="icon-btn danger" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
</div>
<div className="split-vak-meta">
{b.locatie_naam && <span>{b.locatie_naam}</span>}
{b.lokaal_naam && <span>{b.lokaal_naam}</span>}
{b.begeleiders_namen.length > 0 && <span>{b.begeleiders_namen.map((x) => x.naam).join(", ")}</span>}
{vervallen && <span className="rb-status">Vervalt</span>}
</div>
{stageNoot(b)}
</div>
);
}
function renderSlotBlokken(slotBlokken, iso, kal) {
const losse = [];
const perGroep = new Map();
slotBlokken.forEach((b) => {
const gid = bovenliggendeGroep(b, subgroepen);
if (!gid) { losse.push(b); return; }
if (!perGroep.has(gid)) perGroep.set(gid, []);
perGroep.get(gid).push(b);
});
const out = losse.map((b) => renderRoosterBlok(b, iso, kal));
if (scopeType === "leerplein" && scopeId && perGroep.size > 0) {
const leerplein = leerpleinen.find((l) => l.id === Number(scopeId));
const kolommen = [...perGroep.entries()];
out.push(
<div className="blok-splitsing leerplein-splitsing" key={`leerplein-${scopeId}`}>
<div className="split-header">
<span className="split-icon"></span>
<div className="split-title">
<strong>{leerplein ? leerplein.naam : BOVENGROEP_LABEL}</strong>
<span>{BOVENGROEP_LABEL} groepen</span>
</div>
<span className="split-count">{kolommen.length}</span>
</div>
<div className="split-kolommen split-vertakking">
{kolommen.map(([gid, groepBlokken]) => {
const groep = groepen.find((g) => g.id === gid);
return (
<div className="split-kolom" key={gid}>
<div className="split-kop split-node-kop">{groep ? groep.naam : "Groep"}</div>
<div className="split-vakken">{groepBlokken.map((b) => renderCompactBlok(b, iso, kal))}</div>
</div>
);
})}
</div>
</div>
);
return out;
}
perGroep.forEach((groepBlokken, gid) => {
const heeftSplitsing = groepBlokken.some((b) => b.subgroep);
if (!heeftSplitsing) {
groepBlokken.forEach((b) => out.push(renderRoosterBlok(b, iso, kal)));
return;
}
const groep = groepen.find((g) => g.id === gid);
const heleGroep = groepBlokken.filter((b) => b.groep === gid);
const subIds = [...new Set(groepBlokken.filter((b) => b.subgroep).map((b) => b.subgroep))];
const kolomAantal = subIds.length + (heleGroep.length > 0 ? 1 : 0);
out.push(
<div className="blok-splitsing" key={`split-${gid}`}>
<div className="split-header">
<span className="split-icon"></span>
<div className="split-title">
<strong>{groep ? groep.naam : "Groep"}</strong>
<span>Groep subgroepen</span>
</div>
<span className="split-count">{kolomAantal}</span>
</div>
<div className="split-kolommen split-vertakking">
{heleGroep.length > 0 && (
<div className="split-kolom">
<div className="split-kop split-node-kop">Hele groep</div>
<div className="split-vakken">{heleGroep.map((b) => renderCompactBlok(b, iso, kal))}</div>
</div>
)}
{subIds.map((sid) => {
const sub = subgroepen.find((s) => s.id === sid);
const blocks = groepBlokken.filter((b) => b.subgroep === sid);
return (
<div className="split-kolom" key={sid}>
<div className="split-kop split-node-kop">{sub ? sub.naam : "Subgroep"}</div>
<div className="split-vakken">{blocks.map((b) => renderCompactBlok(b, iso, kal))}</div>
</div>
);
})}
</div>
</div>
);
});
return out;
}
return (
<section className="rooster-scherm">
<h1>{edit ? "Roostermaker" : "Rooster"}</h1>
<p className="muted no-print">
{edit
? "Sleep een blok naar een ander tijdslot om het te verplaatsen. Vrije dagen, afwezigheid en uitzonderingen worden meegenomen."
: "Bekijk het weekrooster. Vrije dagen, afwezigheid en uitzonderingen zijn verwerkt."}
</p>
<Foutmelding msg={error} />
<div className="rooster-balk no-print">
<label className="veld">Schooljaar
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> kies </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
</label>
{schooljaar && (
<div className="week-nav">
<button className="btn" onClick={() => setMaandag(plusDagen(maandag, -7))}> vorige</button>
<button className="btn" onClick={() => setMaandag(maandagVan(new Date()))}>deze week</button>
<button className="btn" onClick={() => setMaandag(plusDagen(maandag, 7))}>volgende </button>
<span className="week-label">week van {fmtDM(maandag)}</span>
</div>
)}
{edit && schooljaar && tab === "week" && <button className="btn primary icon-label" title="Vak toevoegen" onClick={toonForm ? sluitNieuwBlok : openNieuwBlok}>{toonForm ? "×" : ""} <span>{toonForm ? "Sluiten" : "Vak"}</span></button>}
{printAan && schooljaar && (edit ? tab === "week" : true) && (
<button className="btn print-knop icon-label" title="Rooster afdrukken" onClick={() => window.print()}>🖨 <span>Print</span></button>
)}
</div>
{schooljaar && (edit ? tab === "week" : true) && (
<div className="rooster-balk no-print scope-balk">
<label className="veld">Toon
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
<option value="alles">Alles</option>
<option value="klas">Per klas</option>
<option value="leerplein">Per {BOVENGROEP_LABEL_LC}</option>
<option value="leerling">Per leerling</option>
<option value="personeel">Per personeel</option>
</select>
</label>
{scopeType !== "alles" && (
<select value={scopeId} onChange={(e) => setScopeId(e.target.value)}>
<option value=""> kies </option>
{scopeOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
)}
{scopeType !== "alles" && scopeId && <span className="muted small">{zichtbareBlokken.length} blok(ken)</span>}
{(groepen.length > 0 || leerpleinen.length > 0) && (
<div className="klas-strip">
<button type="button" className={scopeType === "alles" ? "chip on" : "chip"} onClick={() => { setScopeType("alles"); setScopeId(""); }}>Alle</button>
{groepen.map((g) => (
<button type="button" key={"g" + g.id} className={scopeType === "klas" && Number(scopeId) === g.id ? "chip on" : "chip"} onClick={() => { setScopeType("klas"); setScopeId(String(g.id)); }}>{g.naam}</button>
))}
{leerpleinen.map((lp) => (
<button type="button" key={"lp" + lp.id} className={scopeType === "leerplein" && Number(scopeId) === lp.id ? "chip on chip-plein" : "chip chip-plein"} onClick={() => { setScopeType("leerplein"); setScopeId(String(lp.id)); }}>{lp.naam}</button>
))}
</div>
)}
</div>
)}
{edit && (
<div className="tabbar no-print">
<button className={tab === "week" ? "tab on" : "tab"} title="Weekrooster" onClick={() => setTab("week")}> Week</button>
<button className={tab === "conflicten" ? "tab on" : "tab"} title="Conflicten" onClick={() => setTab("conflicten")}> Conflicten</button>
<button className={tab === "schooljaar" ? "tab on" : "tab"} title="Schooljaar" onClick={() => setTab("schooljaar")}> Jaar</button>
{blokwizardAan && <button className={tab === "wizard" ? "tab on" : "tab"} title="Vak-wizard" onClick={() => setTab("wizard")}> Wizard</button>}
<button className={tab === "instellingen" ? "tab on" : "tab"} title="Instellingen" onClick={() => setTab("instellingen")}> Instellingen</button>
</div>
)}
{edit && tab === "week" && (
<p className="muted small no-print sneltoets-hint">Sneltoetsen: <b>N</b> nieuw blok · <b></b>/<b></b> week · <b>T</b> deze week · <b>Esc</b> sluiten</p>
)}
{edit && schooljaar && tab === "week" && toonForm && (
<div className="modal-overlay no-print" onClick={sluitNieuwBlok}>
<form className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Vak toevoegen" onSubmit={opslaan} onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>Vak toevoegen</h2>
<div className="muted small">Maak één blok of splits direct over subgroepen.</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={sluitNieuwBlok}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Basis</strong>
<span className="muted small">Tijd, vak en doel</span>
</div>
<div className="editor-grid">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
</select>
<select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}>
<option value=""> activiteit </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
<select value={form.locatie} onChange={(e) => setForm({ ...form, locatie: e.target.value })}>
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={form.lokaal} onChange={(e) => setForm({ ...form, lokaal: e.target.value })}>
<option value=""> lokaal (optioneel) </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
</div>
<div className="form-row periode-row">
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={form.geldig_van} onChange={(e) => setForm({ ...form, geldig_van: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={form.geldig_tot} onChange={(e) => setForm({ ...form, geldig_tot: e.target.value })} /></label>
{form.geldig_van && form.geldig_van === form.geldig_tot && <span className="muted small periode-hint">eenmalig op {form.geldig_van}</span>}
{(form.geldig_van || form.geldig_tot) && <button type="button" className="link small" onClick={() => setForm({ ...form, geldig_van: "", geldig_tot: "" })}>wis periode</button>}
</div>
<details className="editor-detail" open>
<summary>Doelgroep</summary>
<div className="editor-grid">
<select value={form.doel_type} onChange={(e) => { setForm({ ...form, doel_type: e.target.value, doel: "" }); resetNieuweSplit(); }}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={form.doel} required onChange={(e) => { setForm({ ...form, doel: e.target.value }); resetNieuweSplit(); }}>
<option value=""> kies {form.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
</details>
{form.doel_type === "groep" && form.doel && (
<details className="editor-detail split-editor" open>
<summary>Groep direct splitsen</summary>
<label className="veld editor-check">
<input type="checkbox" checked={splitNieuwBlok} onChange={(e) => { setSplitNieuwBlok(e.target.checked); if (!e.target.checked) setSplitNieuwDoelen([]); }} />
blok onder subgroepen plaatsen
</label>
{splitNieuwBlok && (
<>
<div className="chips compact">
{splitNieuweOpties.map((s) => <button type="button" key={s.id} className={splitNieuwDoelen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggleNieuwSplitDoel(s.id)}>{s.naam}</button>)}
{splitNieuweOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
</div>
{splitNieuwDoelen.length > 0 && (
<div className="split-preview">
<div className="split-preview-root">{splitNieuweGroep?.naam || "Groep"}</div>
<div className="split-preview-children">
{splitNieuwDoelen.map((sid) => <span key={sid}>{splitNieuweOpties.find((s) => s.id === sid)?.naam || "Subgroep"}</span>)}
</div>
</div>
)}
</>
)}
</details>
)}
<details className="editor-detail" open>
<summary>Begeleiders</summary>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div>
</details>
<div className="editor-actions">
<button className="btn primary icon-label" type="submit" disabled={splitNieuwBlok && splitNieuwDoelen.length === 0}> <span>{splitNieuwBlok ? `${splitNieuwDoelen.length} blok(ken)` : "Vak toevoegen"}</span></button>
<button type="button" className="btn" onClick={sluitNieuwBlok}>Annuleren</button>
</div>
</div>
</form>
</div>
)}
{schooljaar && tab === "week" && (
<div className="rooster-grid">
{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 (
<div className={kal ? "rooster-kolom vrij" : "rooster-kolom"} key={d.value}>
<div className="rooster-dag">
{d.label} <span className="muted">{fmtDM(datum)}</span>
{kal && <div className={`tag dag-${kal.type}`}>{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}</div>}
{edit && (kal
? <button type="button" className="icon-btn no-print dag-actie" title="Weer lesdag maken" onClick={() => dagHerstel(kal.id)}></button>
: <>
<button type="button" className="icon-btn danger no-print dag-actie" title="Hele dag laten vervallen (vrije dag)" onClick={() => dagVervallen(iso)}></button>
<button type="button" className="icon-btn no-print dag-actie" title="Dag leegmaken (alleen deze dag)" onClick={() => leegDag(iso)}>🧹</button>
<button type="button" className="icon-btn no-print dag-actie" title="Eenmalig blok op deze dag toevoegen" onClick={() => openEenmalig(iso)}></button>
</>
)}
</div>
{slots.length === 0 && <div className="muted small"></div>}
{slots.map((t) => {
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso));
return (
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id}
data-vrij={kal ? "1" : undefined}
key={t.id}
>
<div className="slot-tijd">{t.start_tijd?.slice(0, 5)} {t.naam}</div>
{renderSlotBlokken(slotBlokken, iso, kal)}
</div>
);
})}
</div>
);
})}
</div>
)}
{schooljaar && tab === "conflicten" && <Conflicten schooljaar={schooljaar} />}
{tab === "schooljaar" && (
<div className="instellingen">
<p className="muted">Maak hier snel een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).</p>
<SchooljaarPage />
</div>
)}
{edit && tab === "wizard" && blokwizardAan && <VakWizardPage />}
{tab === "instellingen" && (
<div className="instellingen">
<p className="muted">Bouwstenen van het rooster beheer ze hier zonder het Rooster te verlaten.</p>
<TijdslotenPage />
<ActiviteitenPage />
<LokalenPage />
<LocatiesPage />
</div>
)}
{edit && bewerkBlok && (
<BlokBewerker
blok={bewerkBlok}
ctx={editorCtx}
onSluit={() => setBewerkBlok(null)}
onKlaar={async () => { setBewerkBlok(null); await herlaadNaBlokWijziging(); }}
/>
)}
{edit && ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section>
);
}
// Twee schermen op basis van dezelfde component.
export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; }
export function RoostermakerPage() { return <RoosterScherm mode="edit" />; }
/* -------------------------------- 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 (
<section>
<h1>Urenoverzicht</h1>
<p className="muted">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.</p>
<Foutmelding msg={error} />
<div className="form-row uren-filters">
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> schooljaar </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
<select value={groepFilter} onChange={(e) => setGroepFilter(e.target.value)}>
<option value="">alle groepen</option>
{groepNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<select value={vakFilter} onChange={(e) => setVakFilter(e.target.value)}>
<option value="">alle vakken</option>
{vakNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<input placeholder="zoek leerling…" value={leerlingFilter} onChange={(e) => setLeerlingFilter(e.target.value)} />
{filterActief && <button type="button" className="link small" onClick={() => { setGroepFilter(""); setVakFilter(""); setLeerlingFilter(""); }}>wis filters</button>}
</div>
{loading ? <p>Laden</p> : data && (
<>
<h2>Per groep</h2>
<table className="grid">
<thead><tr><th>Groep</th><th>Vak</th><th>Uren (schooljaar)</th></tr></thead>
<tbody>
{perGroep.length === 0 && <tr><td colSpan="3" className="muted">Geen resultaten.</td></tr>}
{perGroep.flatMap((g) => [
...g.vakken.map((v, i) => (
<tr key={g.groep + "-" + v.vak}>
<td>{i === 0 ? <strong>{g.groep}</strong> : ""}</td>
<td>{vakIcoon(v.vak)} {v.vak}</td>
<td>{v.uren} u</td>
</tr>
)),
<tr key={g.groep + "-tot"} className="uren-totaal">
<td></td><td><em>totaal</em></td><td><strong>{g.totaal} u</strong></td>
</tr>,
])}
</tbody>
</table>
<h2>Per leerling</h2>
<table className="grid">
<thead><tr><th>Leerling</th><th>Groep</th><th>Vakken</th><th>Stage</th><th>Totaal</th></tr></thead>
<tbody>
{perLeerling.length === 0 && <tr><td colSpan="5" className="muted">Geen resultaten.</td></tr>}
{perLeerling.map((l) => (
<tr key={l.leerling}>
<td>{l.leerling}</td>
<td className="muted">{l.groep || "—"}</td>
<td className="muted small">{l.vakkenWeergave.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}</td>
<td>{l.stage_uren ? `${l.stage_uren} u` : "—"}</td>
<td><strong>{l.totaal} u</strong></td>
</tr>
))}
</tbody>
</table>
<div className="historie-kop">
<h2>Geschiedenis</h2>
<button type="button" className="btn klein" disabled={bewaarBusy} onClick={bewaarMomentopname}>{bewaarBusy ? "Bezig…" : "Bewaar huidige stand"}</button>
</div>
{historie && historie.momentopnames.length > 0 ? (
<table className="grid">
<thead><tr><th>Leerling</th>{historie.momentopnames.map((m) => <th key={m.id}>{new Date(m.gemaakt_op).toLocaleDateString("nl-NL")}</th>)}</tr></thead>
<tbody>
{historie.leerlingen
.filter((r) => !leerlingFilter || r.leerling.toLowerCase().includes(leerlingFilter.toLowerCase()))
.map((r) => (
<tr key={r.leerling}>
<td>{r.leerling}</td>
{historie.momentopnames.map((m) => <td key={m.id}>{r.totalen[m.id] != null ? `${r.totalen[m.id]} u` : "—"}</td>)}
</tr>
))}
</tbody>
</table>
) : <p className="muted">Nog geen momentopnames. Klik op "Bewaar huidige stand" om het verloop in de tijd te volgen.</p>}
</>
)}
</section>
);
}
/* ---------------- 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 (
<div className={groot ? "ez-blok groot" : "ez-blok"} key={b.id + "-" + t.id} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{t.start_tijd?.slice(0, 5)}</div>
{b.activiteit_pictogram
? <img className="ez-picto" src={b.activiteit_pictogram} alt="" />
: <div className="ez-emoji">{b.activiteit_icoon || vakIcoon(b.activiteit_naam)}</div>}
<div className="ez-vak" style={{ color: kleur }}>{b.activiteit_naam}</div>
</div>
);
}
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]) => (
<div className="ez-dagdeel" key={label}>
<div className="ez-dagdeel-kop"><span className="ez-dagdeel-icon">{icon}</span> {label}</div>
<div className="ez-dagdeel-blokken">{arr.map((x) => blokKaart(x, groot))}</div>
</div>
));
}
return (
<section className="ez">
<h1>{ouder ? "Weekrooster" : "Mijn rooster"}</h1>
<Foutmelding msg={error} />
<div className="form-row ez-bar no-print">
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
<option value="leerling">Per leerling</option>
<option value="klas">Per groep</option>
</select>
<select value={scopeId} onChange={(e) => setScopeId(e.target.value)}>
<option value=""> kies {scopeType === "klas" ? "groep" : "leerling"} </option>
{scopeOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, -7))}> vorige week</button>
<button type="button" className="btn" onClick={() => setMaandag(maandagVan(new Date()))}>deze week</button>
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, 7))}>volgende week </button>
</div>
{!scopeId && <p className="muted">Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.</p>}
{scopeId && ouder && (
<div className="ez-week">
{weekDatums.map((datum, i) => {
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-kolom" key={i}>
<div className="ez-dag-kop">{WEEKDAGEN[i].label}<span className="muted"> {fmtDM(datum)}</span></div>
{vrij && <div className="ez-vrij">{vrij.type_label}</div>}
{!vrij && items.length === 0 && <div className="muted small"></div>}
{!vrij && dagdeelSecties(items, false)}
</div>
);
})}
</div>
)}
{scopeId && !ouder && (() => {
const datum = plusDagen(maandag, dagIdx);
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-groot">
<div className="ez-dag-nav no-print">
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.max(0, d - 1))} disabled={dagIdx === 0}></button>
<div className="ez-dag-titel">{WEEKDAGEN[dagIdx].label} <span className="muted">{fmtDM(datum)}</span></div>
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.min(4, d + 1))} disabled={dagIdx === 4}></button>
</div>
{vrij && <div className="ez-vrij groot">{vrij.type_label}{vrij.omschrijving ? `: ${vrij.omschrijving}` : ""}</div>}
{!vrij && items.length === 0 && <p className="muted">Geen lessen op deze dag.</p>}
{!vrij && dagdeelSecties(items, true)}
</div>
);
})()}
</section>
);
}
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
export function OuderRoosterPage() { return <EenvoudigRooster mode="ouder" />; }
/* -------------------------------- 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 (
<section className="wizard">
<h1>Vak-wizard</h1>
<div className="banner ok"> Het vak is ingepland.</div>
<div className="form-row"><button className="btn primary" onClick={opnieuw}>Nog een vak inplannen</button></div>
</section>
);
}
const STAPPEN = ["Vak", "Voor wie", "Wanneer & waar", "Begeleiders", "Controle"];
return (
<section className="wizard">
<h1>Vak-wizard</h1>
<Foutmelding msg={error} />
<ol className="wizard-stappen">
{STAPPEN.map((label, i) => <li key={i} className={stap === i + 1 ? "on" : stap > i + 1 ? "done" : ""}>{i + 1}. {label}</li>)}
</ol>
{stap === 1 && (
<div className="wizard-stap">
<h2>Welk vak?</h2>
{schooljaren.length > 1 && (
<select value={f.schooljaar} onChange={(e) => setF({ ...f, schooljaar: e.target.value })}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<div className="keuze-grid">
{activiteiten.map((a) => (
<button type="button" key={a.id} className={Number(f.activiteit) === a.id ? "keuze on" : "keuze"} style={{ borderColor: a.kleur }} onClick={() => setF({ ...f, activiteit: a.id })}>
{a.pictogram ? <img src={a.pictogram} alt="" className="keuze-picto" /> : <span className="keuze-emoji">{a.icoon || vakIcoon(a.naam)}</span>}
<span style={{ color: a.kleur }}>{a.naam}</span>
</button>
))}
</div>
</div>
)}
{stap === 2 && (
<div className="wizard-stap">
<h2>Voor wie?</h2>
<div className="form-row">
<select value={f.doel_type} onChange={(e) => setF({ ...f, doel_type: e.target.value, doel: "" })}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
<option value=""> kies {f.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
</div>
)}
{stap === 3 && (
<div className="wizard-stap">
<h2>Wanneer & waar?</h2>
<div className="form-row">
<select value={f.tijdslot} onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {(t.start_tijd || "").slice(0, 5)} {t.naam}</option>)}
</select>
<select value={f.lokaal} onChange={(e) => setF({ ...f, lokaal: e.target.value })}>
<option value=""> lokaal (optioneel) </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={f.locatie} onChange={(e) => setF({ ...f, locatie: e.target.value })}>
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</div>
<div className="form-row">
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} /></label>
</div>
</div>
)}
{stap === 4 && (
<div className="wizard-stap">
<h2>Wie begeleidt? <span className="muted small">(optioneel)</span></h2>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
</div>
<input placeholder="Opmerking (optioneel)" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
</div>
)}
{stap === 5 && (
<div className="wizard-stap">
<h2>Klopt het zo?</h2>
<ul className="wizard-overzicht">
<li><b>Vak:</b> {naamVan(activiteiten, f.activiteit)}</li>
<li><b>Voor:</b> {naamVan(doelOpties, f.doel)} ({f.doel_type})</li>
<li><b>Wanneer:</b> {tslabel(f.tijdslot)}</li>
<li><b>Waar:</b> {f.lokaal ? naamVan(lokalen, f.lokaal) : (f.locatie ? naamVan(locaties, f.locatie) : "")}</li>
<li><b>Begeleiding:</b> {f.begeleiders.length ? f.begeleiders.map((id) => naamVan(medewerkers, id)).join(", ") : ""}</li>
{(f.geldig_van || f.geldig_tot) && <li><b>Periode:</b> {f.geldig_van || "…"} t/m {f.geldig_tot || "…"}</li>}
</ul>
</div>
)}
<div className="wizard-nav">
{stap > 1 && <button type="button" className="btn" onClick={() => setStap(stap - 1)}> Vorige</button>}
{stap < 5 && <button type="button" className="btn primary" disabled={!magVerder} onClick={() => setStap(stap + 1)}>Volgende </button>}
{stap === 5 && <button type="button" className="btn primary" onClick={opslaan}> Vak inplannen</button>}
</div>
</section>
);
}
/* ----------------------------- 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 (
<section className="wizard">
<h1>Personen toevoegen</h1>
<p className="muted">Voeg leerlingen of medewerkers toe één voor één of in bulk (plakken of CSV-bestand).</p>
<Foutmelding msg={error} />
{melding && <div className="banner ok"> {melding}</div>}
<div className="form-row">
<select value={rol} onChange={(e) => setRol(e.target.value)}>
{PWROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<div className="segmented">
<button type="button" className={modus === "enkel" ? "seg on" : "seg"} onClick={() => setModus("enkel")}>Eén toevoegen</button>
<button type="button" className={modus === "bulk" ? "seg on" : "seg"} onClick={() => setModus("bulk")}>Bulk / import</button>
</div>
</div>
{modus === "enkel" && (
<form className="form-card" onSubmit={bewaarEnkel}>
<div className="form-row">
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
{isLeerling && (
<select value={form.groep} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> groep (optioneel) </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
)}
<button className="btn primary" type="submit">Toevoegen</button>
</div>
</form>
)}
{modus === "bulk" && (
<div className="form-card">
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam{isLeerling ? ", groep" : ""}</b> (komma, puntkomma of tab). Een koprij is optioneel.</p>
<div className="form-row">
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const fl = e.target.files[0]; if (fl) leesBestand(fl); e.target.value = ""; }} /></label>
{isLeerling && <label className="veld"><input type="checkbox" checked={groepAanmaken} onChange={(e) => setGroepAanmaken(e.target.checked)} /> onbekende groep automatisch aanmaken</label>}
</div>
<textarea className="bulk-invoer" rows={6} placeholder={isLeerling ? "Jan;Jansen;Groep 1\nPiet;Pietersen;Groep 2" : "Lea;Kracht\nMo;Hassan"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
{rijen.length > 0 && (
<>
<div className="muted small">Voorbeeld ({rijen.length} {isLeerling ? "leerlingen" : "medewerkers"}):</div>
<table className="grid">
<thead><tr><th>Voornaam</th><th>Achternaam</th>{isLeerling && <th>Groep</th>}</tr></thead>
<tbody>
{rijen.slice(0, 8).map((r, i) => <tr key={i}><td>{r.voornaam}</td><td>{r.achternaam}</td>{isLeerling && <td className="muted">{r.groep || ""}</td>}</tr>)}
{rijen.length > 8 && <tr><td colSpan={isLeerling ? 3 : 2} className="muted small"> en {rijen.length - 8} meer</td></tr>}
</tbody>
</table>
</>
)}
<div className="form-row">
<button className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} ${isLeerling ? "leerlingen" : "medewerkers"}`}</button>
</div>
</div>
)}
</section>
);
}
/* ------------------------------- Auto-rooster ----------------------------- */
export function AutoRoosterPage() {
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [groepen, setGroepen] = useState([]);
const [groep, setGroep] = useState("");
const [activiteiten, setActiviteiten] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, begeleider: "", lokaal: "" }]);
const [voorstel, setVoorstel] = useState(null);
const [niet, setNiet] = useState([]);
const [error, setError] = useState(null);
const [bezig, setBezig] = useState(false);
const [resultaat, setResultaat] = useState(null);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
listResource("groepen").then(setGroepen).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => setLokalen([]));
}, []);
const setWens = (i, veld, val) => setWensen((ws) => ws.map((w, idx) => idx === i ? { ...w, [veld]: val } : w));
const addWens = () => setWensen((ws) => [...ws, { activiteit: "", aantal: 1, begeleider: "", lokaal: "" }]);
const delWens = (i) => setWensen((ws) => ws.filter((_, idx) => idx !== i));
async function genereer() {
setError(null); setResultaat(null); setVoorstel(null); setNiet([]);
if (!groep) { setError("Kies eerst een groep."); return; }
const geldig = wensen.filter((w) => w.activiteit && Number(w.aantal) > 0);
if (geldig.length === 0) { setError("Voeg minstens één wens toe (vak + aantal)."); return; }
setBezig(true);
try {
const r = await createResource("autorooster/voorstel", {
schooljaar: Number(schooljaar), groep: Number(groep),
wensen: geldig.map((w) => ({ activiteit: Number(w.activiteit), aantal: Number(w.aantal), begeleider: w.begeleider || null, lokaal: w.lokaal || null })),
});
setVoorstel(r.voorstel || []); setNiet(r.niet_geplaatst || []);
} catch (e) { setError(e.message); }
finally { setBezig(false); }
}
async function toepassen() {
if (!voorstel || voorstel.length === 0) return;
setBezig(true); setError(null);
let ok = 0; const mislukt = [];
for (const v of voorstel) {
try {
await createResource("roosterblokken", {
schooljaar: Number(schooljaar), tijdslot: v.tijdslot, activiteit: v.activiteit,
groep: v.groep, subgroep: null, leerling: null,
begeleiders: v.begeleider ? [v.begeleider] : [], lokaal: v.lokaal || null,
});
ok += 1;
} catch (e) { mislukt.push(`${v.activiteit_naam} (${v.tijdslot_label}): ${e.message}`); }
}
setResultaat({ ok, mislukt });
setVoorstel(null);
setBezig(false);
}
const perDag = {};
(voorstel || []).forEach((v) => { (perDag[v.dag] = perDag[v.dag] || []).push(v); });
return (
<section className="autorooster">
<h1>Automatisch rooster</h1>
<p className="muted">Geef per groep je wensen op; het systeem stelt een conflictvrij voorstel voor (geen dubbele begeleider of lokaal, gespreid over de week). Daarna pas je het gewoon aan in de Roostermaker.</p>
<Foutmelding msg={error} />
<div className="form-row">
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<select value={groep} onChange={(e) => setGroep(e.target.value)}>
<option value=""> kies groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</div>
<h2>Wensen</h2>
<table className="grid">
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Begeleider (optioneel)</th><th>Lokaal (optioneel)</th><th></th></tr></thead>
<tbody>
{wensen.map((w, i) => (
<tr key={i}>
<td>
<select value={w.activiteit} onChange={(e) => setWens(i, "activiteit", e.target.value)}>
<option value=""> vak </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
</td>
<td><input type="number" min="1" max="20" value={w.aantal} onChange={(e) => setWens(i, "aantal", e.target.value)} style={{ width: 70 }} /></td>
<td>
<select value={w.begeleider} onChange={(e) => setWens(i, "begeleider", e.target.value)}>
<option value=""></option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</td>
<td>
<select value={w.lokaal} onChange={(e) => setWens(i, "lokaal", e.target.value)}>
<option value=""></option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</td>
<td className="row-actions">{wensen.length > 1 && <button type="button" className="link danger" onClick={() => delWens(i)}>×</button>}</td>
</tr>
))}
</tbody>
</table>
<div className="form-row">
<button type="button" className="btn" onClick={addWens}>+ wens</button>
<button type="button" className="btn primary" disabled={bezig} onClick={genereer}>{bezig ? "Bezig…" : "Genereer voorstel"}</button>
</div>
{resultaat && (
<div className={resultaat.mislukt.length ? "banner error" : "banner ok"}>
{resultaat.ok} blok(ken) toegevoegd aan het rooster.{resultaat.mislukt.length > 0 && <> {resultaat.mislukt.length} mislukt: <span className="small">{resultaat.mislukt.join("; ")}</span></>}
{" "}Stel het verder af in de Roostermaker.
</div>
)}
{voorstel && (
<div className="voorstel">
<h2>Voorstel ({voorstel.length} blokken)</h2>
{niet.length > 0 && (
<div className="banner error">Niet alles paste: {niet.map((n) => `${n.activiteit_naam} (${n.tekort} te kort)`).join(", ")}. Voeg tijdsloten toe of verlaag het aantal.</div>
)}
{voorstel.length === 0 ? <p className="muted">Geen blokken voorgesteld.</p> : (
<div className="ez-week">
{WEEKDAGEN.map((d) => (
<div className="ez-dag-kolom" key={d.value}>
<div className="ez-dag-kop">{d.label}</div>
{(perDag[d.value] || []).length === 0 && <div className="muted small"></div>}
{(perDag[d.value] || []).map((v, i) => {
const kleur = (activiteiten.find((a) => a.id === v.activiteit) || {}).kleur || "#2563eb";
return (
<div className="ez-blok" key={i} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{v.tijdslot_label.split(" ")[1]}</div>
<div className="ez-vak" style={{ color: kleur }}>{v.activiteit_naam}</div>
{v.begeleider_naam && <div className="muted small">{v.begeleider_naam}</div>}
{v.lokaal_naam && <div className="muted small">{v.lokaal_naam}</div>}
</div>
);
})}
</div>
))}
</div>
)}
<div className="form-row">
<button type="button" className="btn primary" disabled={bezig || voorstel.length === 0} onClick={toepassen}>{bezig ? "Bezig…" : "Voorstel toepassen op rooster"}</button>
<button type="button" className="btn" onClick={() => setVoorstel(null)}>Verwerpen</button>
</div>
</div>
)}
</section>
);
}