roosterwijs/frontend/src/pages.jsx

2566 lines
124 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,
} 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 }) {
const { items, error, loading, reload, setError } = useList(resource);
const leeg = {
naam: "", omschrijving: "",
...(kleur ? { kleur: "#2563eb" } : {}),
...(metTheorievak ? { theorievak: false } : {}),
...(metIcoon ? { icoon: "" } : {}),
};
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource(resource, editId, form);
else await createResource(resource, form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Verwijderen?")) return;
try {
await deleteResource(resource, id);
await reload();
} catch (err) {
setError(err.message);
}
}
function bewerk(it) {
setEditId(it.id);
setForm({
naam: it.naam, omschrijving: it.omschrijving,
...(kleur ? { kleur: it.kleur } : {}),
...(metTheorievak ? { theorievak: it.theorievak } : {}),
...(metIcoon ? { icoon: it.icoon || "" } : {}),
});
}
return (
<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>}
{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>}
{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
/>
);
}
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("");
async function maakFunctie() {
const naam = nieuweFunctie.trim();
if (!naam) return;
try {
const f = await createResource("functies", { naam });
setFuncties((lijst) => [...lijst, f]);
setForm((prev) => ({ ...prev, functies: [...prev.functies, f.id] }));
setNieuweFunctie("");
} catch (e) { setError(e.message); }
}
function laadHulplijsten() {
listResource("groepen").then(setGroepen).catch(() => {});
listResource("functies").then(setFuncties).catch(() => {});
listResource("activiteiten").then(setVakken).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([]));
}
useEffect(() => { laadHulplijsten(); }, []);
// De bovenliggende-groep-koppeling loopt via de bestaande API.
async function syncLeerpleinen(persoonId, gekozenIds) {
const huidige = await listResource("leerpleinen").catch(() => []);
for (const lp of huidige) {
const inLijst = (lp.medewerkers || []).includes(persoonId);
const moet = gekozenIds.includes(lp.id);
if (moet && !inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: [...lp.medewerkers, persoonId] });
else if (!moet && inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: lp.medewerkers.filter((x) => x !== persoonId) });
}
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
}
function kiesModus(m) {
setModus(m);
setEditId(null);
setForm(m === "medewerker" ? leegMedewerker : leegLeerling);
}
function toggle(veld, id) {
setForm((f) => f[veld].includes(id) ? { ...f, [veld]: f[veld].filter((x) => x !== id) } : { ...f, [veld]: [...f[veld], id] });
}
async function opslaan(e) {
e.preventDefault();
const payload = modus === "medewerker"
? {
voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol,
stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken,
begeleide_subgroepen: form.begeleide_subgroepen,
}
: {
voornaam: form.voornaam, achternaam: form.achternaam, rol: "leerling",
groep: form.groep || null, geboortedatum: form.geboortedatum || null,
};
try {
const opgeslagen = editId
? await updateResource("personen", editId, payload)
: await createResource("personen", payload);
if (modus === "medewerker" && opgeslagen && opgeslagen.id) {
await syncLeerpleinen(opgeslagen.id, form.leerpleinen || []);
}
setForm(modus === "medewerker" ? leegMedewerker : leegLeerling);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze persoon verwijderen?")) return;
try { await deleteResource("personen", id); await reload(); } catch (err) { setError(err.message); }
}
function bewerk(p) {
setEditId(p.id);
if (p.rol === "leerling") {
setModus("leerling");
setForm({ voornaam: p.voornaam, achternaam: p.achternaam, groep: p.groep || "", geboortedatum: p.geboortedatum || "" });
} else {
setModus("medewerker");
setForm({
voornaam: p.voornaam, achternaam: p.achternaam, rol: p.rol,
stamgroep: p.stamgroep || "",
functies: p.functies_namen.map((x) => x.id),
vakken: p.vakken_namen.map((x) => x.id),
begeleide_subgroepen: (p.begeleide_subgroepen_namen || []).map((x) => x.id),
leerpleinen: leerpleinen.filter((lp) => (lp.medewerkers || []).includes(p.id)).map((lp) => lp.id),
});
}
}
const zichtbaar = filter === "leerling" ? items.filter((p) => p.rol === "leerling")
: filter === "medewerker" ? items.filter((p) => p.rol !== "leerling")
: items;
return (
<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={modus === "leerling" ? "tab on" : "tab"} onClick={() => kiesModus("leerling")}>Leerling toevoegen</button>
<button className={modus === "medewerker" ? "tab on" : "tab"} onClick={() => kiesModus("medewerker")}>Medewerker toevoegen</button>
</div>
<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 || "",
});
const [kopieDoelen, setKopieDoelen] = useState([]);
const [error, setError] = useState(null);
const [subs, setSubs] = useState(subgroepen);
const [nieuweSub, setNieuweSub] = useState("");
const [splitSubs, setSplitSubs] = useState([]);
const [splitNieuw, setSplitNieuw] = useState("");
const [verwijderOrig, setVerwijderOrig] = useState(true);
const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => (
blok.subgroep ? groepVanSubgroep(subgroepen, blok.subgroep) || "" : ""
));
const gekozenGroep = f.doel_type === "groep"
? (f.doel ? Number(f.doel) : null)
: f.doel_type === "subgroep"
? (subgroepGroepFilter || groepVanSubgroep(subs, Number(f.doel)))
: bovenliggendeGroep(blok, subs);
const subgroepOpties = gekozenGroep ? subs.filter((s) => s.groep === gekozenGroep) : subs;
const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepOpties : leerlingen;
const splitGroep = gekozenGroep || blok.groep || groepVanSubgroep(subs, blok.subgroep) || null;
const splitOpties = splitGroep ? subs.filter((s) => s.groep === splitGroep) : subs;
const splitGroepNaam = splitGroep ? (groepen.find((g) => g.id === splitGroep)?.naam || "groep") : "groep";
async function maakSubgroep() {
if (!nieuweSub.trim()) return;
try {
const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: gekozenGroep || null });
setSubs([...subs, s]);
setSubgroepGroepFilter(s.groep || "");
setF({ ...f, doel_type: "subgroep", doel: s.id });
setNieuweSub("");
} catch (e) { setError(e.message); }
}
const toggleSplit = (id) => setSplitSubs((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
async function maakSplitSub() {
if (!splitNieuw.trim()) return;
try {
const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroep });
setSubs([...subs, s]);
setSplitSubs([...splitSubs, s.id]);
setSplitNieuw("");
} catch (e) { setError(e.message); }
}
async function opdelen() {
if (splitSubs.length === 0) return;
try {
for (const sid of splitSubs) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen });
}
if (verwijderOrig) await deleteResource("roosterblokken", blok.id);
await onKlaar();
} catch (e) { setError(e.message); }
}
const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] });
const toggleKopie = (id) => setKopieDoelen((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
const doelPayload = () => ({
groep: f.doel_type === "groep" ? f.doel : null,
subgroep: f.doel_type === "subgroep" ? f.doel : null,
leerling: f.doel_type === "leerling" ? f.doel : null,
});
async function opslaan() {
try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); }
catch (e) { setError(e.message); }
}
async function kopieer() {
try {
for (const ts of kopieDoelen) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen });
}
await onKlaar();
} catch (e) { setError(e.message); }
}
async function verwijderen() {
if (!confirm("Dit blok (alle weken) verwijderen?")) return;
try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); }
}
return (
<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 })} />
</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="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 [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [locaties, setLocaties] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [blokken, setBlokken] = useState([]);
const [kalender, setKalender] = useState([]);
const [afwezig, setAfwezig] = useState([]);
const [uitzonderingen, setUitzonderingen] = useState([]);
const [stages, setStages] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false);
const [tab, setTab] = useState("week");
const [error, setError] = useState(null);
// Scope-filter (ook voor gericht afdrukken): per klas/bovenliggende groep/leerling/personeel.
const [scopeType, setScopeType] = useState("alles");
const [scopeId, setScopeId] = useState("");
// Blok bewerken/kopiëren (alleen in de Roostermaker).
const [bewerkBlok, setBewerkBlok] = useState(null);
const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
const [form, setForm] = useState(leeg);
const [splitNieuwBlok, setSplitNieuwBlok] = useState(false);
const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]);
// Drag-and-drop via pointer events (werkt op muis én touch).
const dragRef = useRef(null);
const laadRef = useRef(() => {});
const [ghost, setGhost] = useState(null);
const [hoverSlot, setHoverSlot] = useState(null);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("locaties").then(setLocaties).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
listResource("stages", "?actief=1").then(setStages).catch(() => setStages([]));
// Afdrukken alleen tonen als de print-module aanstaat.
getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {});
}, []);
// Scope-context + gefilterde blokken (voor weergave/afdrukken).
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx));
const scopeOpties = scopeType === "klas" ? groepen
: scopeType === "leerplein" ? leerpleinen
: scopeType === "leerling" ? leerlingen
: scopeType === "personeel" ? medewerkers
: [];
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const vanISO = fmtISO(maandag);
const totISO = fmtISO(plusDagen(maandag, 6));
async function laadBlokken() {
if (!schooljaar) { setBlokken([]); return; }
try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`));
setKalender(await listResource("kalenderdagen", `?schooljaar=${schooljaar}`));
setError(null);
} catch (e) { setError(e.message); }
}
async function laadWeek() {
if (!schooljaar) return;
try {
setAfwezig(await listResource("afwezigheden", `?van=${vanISO}&tot=${totISO}`));
setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`));
} catch (e) { setError(e.message); }
}
async function herlaadNaBlokWijziging() {
try { setSubgroepen(await listResource("subgroepen")); } catch { /* blokken verversen blijft leidend */ }
await laadBlokken();
}
laadRef.current = laadBlokken;
useEffect(() => { laadBlokken(); }, [schooljaar]);
useEffect(() => { laadWeek(); }, [schooljaar, vanISO]);
// Listeners voor drag-and-drop, één keer opgezet.
useEffect(() => {
function move(e) {
if (!dragRef.current) return;
setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.activiteit_naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
setHoverSlot(cell ? Number(cell.dataset.tijdslot) : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
const doel = cell ? Number(cell.dataset.tijdslot) : null;
const blok = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverSlot(null);
if (doel && doel !== blok.tijdslot) {
try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); }
catch (err) { setError(err.message); }
}
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
}, []);
// Sneltoetsen (alleen in de Roostermaker).
useEffect(() => {
if (!edit) return;
function onKey(e) {
const t = e.target;
const typing = t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable);
if (e.key === "Escape") { setBewerkBlok(null); sluitNieuwBlok(); return; }
if (typing) return;
if (e.key === "n" || e.key === "N") { setTab("week"); setBewerkBlok(null); setToonForm((v) => { if (v) resetNieuweSplit(); return !v; }); }
else if (e.key === "ArrowLeft") { setMaandag((m) => plusDagen(m, -7)); }
else if (e.key === "ArrowRight") { setMaandag((m) => plusDagen(m, 7)); }
else if (e.key === "t" || e.key === "T") { setMaandag(maandagVan(new Date())); }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [edit]);
function startDrag(e, blok) {
e.preventDefault();
dragRef.current = blok;
setGhost({ x: e.clientX, y: e.clientY, label: blok.activiteit_naam });
setHoverSlot(blok.tijdslot);
}
function resetNieuweSplit() {
setSplitNieuwBlok(false);
setSplitNieuwDoelen([]);
}
function openNieuwBlok() {
setBewerkBlok(null);
setToonForm(true);
}
function sluitNieuwBlok() {
setToonForm(false);
resetNieuweSplit();
}
function toggleNieuwSplitDoel(id) {
setSplitNieuwDoelen((doelen) => doelen.includes(id) ? doelen.filter((x) => x !== id) : [...doelen, id]);
}
const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen;
const splitNieuweOpties = form.doel_type === "groep" && form.doel
? subgroepen.filter((s) => s.groep === Number(form.doel))
: [];
const splitNieuweGroep = groepen.find((g) => g.id === Number(form.doel));
function toggleBegeleider(id) {
setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] });
}
async function opslaan(e) {
e.preventDefault();
const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen };
try {
if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) {
for (const subgroep of splitNieuwDoelen) {
await createResource("roosterblokken", { ...basis, groep: null, subgroep, leerling: null });
}
} else {
await createResource("roosterblokken", {
...basis,
groep: form.doel_type === "groep" ? form.doel : null,
subgroep: form.doel_type === "subgroep" ? form.doel : null,
leerling: form.doel_type === "leerling" ? form.doel : null,
});
}
setForm({ ...leeg, doel_type: form.doel_type });
sluitNieuwBlok();
await herlaadNaBlokWijziging();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit roosterblok (alle weken) verwijderen?")) return;
try { await deleteResource("roosterblokken", id); await laadBlokken(); } catch (err) { setError(err.message); }
}
async function laatVervallen(blok, iso) {
try { await createResource("blok-uitzonderingen", { roosterblok: blok.id, datum: iso, vervalt: true }); await laadWeek(); }
catch (err) { setError(err.message); }
}
async function herstel(uitId) {
try { await deleteResource("blok-uitzonderingen", uitId); await laadWeek(); } catch (err) { setError(err.message); }
}
async function dagVervallen(iso) {
try { await createResource("kalenderdagen", { schooljaar, datum: iso, type: "vrije_dag", omschrijving: "vervallen" }); await laadBlokken(); }
catch (err) { setError(err.message); }
}
async function dagHerstel(kalId) {
try { await deleteResource("kalenderdagen", kalId); await laadBlokken(); }
catch (err) { setError(err.message); }
}
// Hulpfuncties voor het effectieve rooster.
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
// Op id vergelijken, niet op naam: twee personen kunnen dezelfde naam hebben.
const persoonAfwezig = (id, iso) => afwezig.some((a) => a.persoon === id && a.start_datum <= iso && a.eind_datum >= iso);
const rolAfk = (id) => { const m = medewerkers.find((x) => x.id === id); return m ? (ROL_AFK[m.rol] || "") : ""; };
// Stage: welke leerlingen zijn op een gegeven tijdslot op stage (en dus niet in de klas)?
const tijdslotById = (id) => tijdsloten.find((t) => t.id === id);
const leerlingNaam = (id) => { const l = leerlingen.find((x) => x.id === id); return l ? l.volledige_naam : "?"; };
function stageDektTijdslot(stage, t) {
if (!t) return false;
if (stage.omvang === "blok") return stage.tijdslot === t.id;
if (stage.dag === null || stage.dag === undefined || stage.dag !== t.dag) return false;
if (stage.omvang === "hele_dag") return true;
const isOchtend = (t.start_tijd || "") < "12:00";
return (stage.dagdeel === "ochtend" && isOchtend) || (stage.dagdeel === "middag" && !isOchtend);
}
function leerlingenOpStageBij(t) {
const set = new Set();
for (const st of stages) { if (stageDektTijdslot(st, t)) set.add(st.leerling); }
return set;
}
function stageNoot(b) {
const t = tijdslotById(b.tijdslot);
const opStage = leerlingenOpStageBij(t);
if (opStage.size === 0) return null;
if (b.doel_type === "leerling") {
return opStage.has(b.leerling) ? <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>}
<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>}
<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>}
{edit && groepen.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.id}
className={scopeType === "klas" && Number(scopeId) === g.id ? "chip on" : "chip"}
onClick={() => { setScopeType("klas"); setScopeId(String(g.id)); }}
>
{g.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>
<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>
<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="Dag laten vervallen" onClick={() => dagVervallen(iso)}></button>
)}
</div>
{slots.length === 0 && <div className="muted small"></div>}
{slots.map((t) => {
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id);
return (
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id}
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>
)}
{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);
useEffect(() => {
listResource("schooljaren")
.then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); })
.catch((e) => setError(e.message));
}, []);
useEffect(() => {
if (!schooljaar) { setData(null); return; }
setLoading(true);
listResource("urenoverzicht", `?schooljaar=${schooljaar}`)
.then((d) => { setData(d); setError(null); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [schooljaar]);
return (
<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">
<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>
</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>
{data.per_groep.length === 0 && <tr><td colSpan="3" className="muted">Geen lesblokken gevonden.</td></tr>}
{data.per_groep.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>
{data.per_leerling.length === 0 && <tr><td colSpan="5" className="muted">Geen leerlingen met uren.</td></tr>}
{data.per_leerling.map((l) => (
<tr key={l.leerling}>
<td>{l.leerling}</td>
<td className="muted">{l.groep || "—"}</td>
<td className="muted small">{l.vakken.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>
</>
)}
</section>
);
}