roosterwijs/frontend/src/pages.jsx
2026-06-14 15:57:12 +02:00

1998 lines
96 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,
} 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" };
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 }) {
const { items, error, loading, reload, setError } = useList(resource);
const leeg = {
naam: "", omschrijving: "",
...(kleur ? { kleur: "#2563eb" } : {}),
...(metTheorievak ? { theorievak: false } : {}),
};
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 } : {}),
});
}
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 })} />
)}
{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 }} />}
{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
/>
);
}
export function LocatiesPage() {
return (
<CatalogusPage
titel="Locaties"
uitleg="Lokalen en ruimtes. Worden gebruikt voor conflictdetectie."
resource="locaties"
/>
);
}
/* --------------------------------- Groepen -------------------------------- */
export function GroepenPage() {
const { items, error, loading, reload, setError } = useList("groepen");
const leeg = { naam: "", omschrijving: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource("groepen", editId, form);
else await createResource("groepen", form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze groep verwijderen?")) return;
try {
await deleteResource("groepen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Groepen</h1>
<p className="muted">Klassen waar leerlingen bij horen.</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 })} />
<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><th>Leerlingen</th><th></th></tr></thead>
<tbody>
{items.map((g) => (
<tr key={g.id}>
<td>{g.naam}</td>
<td className="muted">{g.omschrijving || "—"}</td>
<td>{g.aantal_leerlingen}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(g.id); setForm({ naam: g.naam, omschrijving: g.omschrijving }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(g.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen groepen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- 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(); }, []);
// Leerplein-koppeling loopt via de leerplein-API (de module bezit die relatie).
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">Leerpleinen:</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() {
const { items, error, loading, reload, setError } = useList("subgroepen");
const [leerlingen, setLeerlingen] = useState([]);
const [groepen, setGroepen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const leeg = { naam: "", omschrijving: "", groep: "", leden: [], begeleiders: [] };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => {
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
}, []);
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 = { ...form, groep: form.groep || null };
try {
if (editId) await updateResource("subgroepen", editId, payload);
else await createResource("subgroepen", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze subgroep verwijderen?")) return;
try {
await deleteResource("subgroepen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Subgroepen</h1>
<p className="muted">Groepjes leerlingen binnen een groep (bv. niveaugroep rekenen), met eigen begeleiders.</p>
<Foutmelding msg={error} />
<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 })} />
<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>
</div>
<div className="leden-keuze">
<div className="muted small">Leden (leerlingen):</div>
<div className="chips">
{leerlingen.map((l) => (
<button type="button" key={l.id} className={form.leden.includes(l.id) ? "chip on" : "chip"} onClick={() => toggle("leden", l.id)}>{l.volledige_naam}</button>
))}
{leerlingen.length === 0 && <span className="muted small">Nog geen leerlingen ingevoerd.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<div className="chips">
{medewerkers.map((m) => (
<button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggle("begeleiders", m.id)}>{m.volledige_naam}</button>
))}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div>
</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>Naam</th><th>Groep</th><th>Leden</th><th>Begeleiders</th><th></th></tr></thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>{s.naam}{s.omschrijving && <div className="muted small">{s.omschrijving}</div>}</td>
<td className="muted">{s.groep_naam || "—"}</td>
<td><span className="badge">{s.aantal_leden}</span> <span className="muted small">{s.leden_namen.map((x) => x.naam).join(", ")}</span></td>
<td className="muted small">{s.begeleiders_namen.map((x) => x.naam).join(", ") || "—"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(s.id); setForm({ naam: s.naam, omschrijving: s.omschrijving, groep: s.groep || "", leden: s.leden_namen.map((x) => x.id), begeleiders: s.begeleiders_namen.map((x) => x.id) }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="5" className="muted">Nog geen subgroepen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* ------------------------------- 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>
);
}
/* -------------------------------- Leerplein ------------------------------- */
// Module-scherm: koppel subgroepen aan groepen en bundel groepen, subgroepen
// en losse leerlingen onder een overkoepelend leerplein.
export function LeerpleinPage() {
const { items, error, loading, reload, setError } = useList("leerpleinen");
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const leeg = { naam: "", omschrijving: "", groepen: [], subgroepen: [], leerlingen: [], medewerkers: [] };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => {
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(() => {});
}, []);
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();
try {
if (editId) await updateResource("leerpleinen", editId, form);
else await createResource("leerpleinen", form);
setForm(leeg); setEditId(null); await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit leerplein verwijderen?")) return;
try { await deleteResource("leerpleinen", id); await reload(); } catch (err) { setError(err.message); }
}
return (
<section>
<h1>Leerplein</h1>
<p className="muted">Bundel groepen, subgroepen en losse leerlingen onder een overkoepelend leerplein.</p>
<Foutmelding msg={error} />
<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 })} />
</div>
<div className="leden-keuze">
<div className="muted small">Groepen:</div>
<div className="chips">
{groepen.map((g) => <button type="button" key={g.id} className={form.groepen.includes(g.id) ? "chip on" : "chip"} onClick={() => toggle("groepen", g.id)}>{g.naam}</button>)}
{groepen.length === 0 && <span className="muted small">Geen groepen.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Subgroepen:</div>
<div className="chips">
{subgroepen.map((s) => <button type="button" key={s.id} className={form.subgroepen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggle("subgroepen", s.id)}>{s.naam}</button>)}
{subgroepen.length === 0 && <span className="muted small">Geen subgroepen.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Losse leerlingen:</div>
<div className="chips">
{leerlingen.map((l) => <button type="button" key={l.id} className={form.leerlingen.includes(l.id) ? "chip on" : "chip"} onClick={() => toggle("leerlingen", l.id)}>{l.volledige_naam}</button>)}
{leerlingen.length === 0 && <span className="muted small">Geen leerlingen.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Medewerkers:</div>
<div className="chips">
{medewerkers.map((m) => <button type="button" key={m.id} className={form.medewerkers.includes(m.id) ? "chip on" : "chip"} onClick={() => toggle("medewerkers", m.id)}>{m.volledige_naam}</button>)}
{medewerkers.length === 0 && <span className="muted small">Geen medewerkers.</span>}
</div>
</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>Naam</th><th>Inhoud</th><th></th></tr></thead>
<tbody>
{items.map((lp) => (
<tr key={lp.id}>
<td>{lp.naam}{lp.omschrijving && <div className="muted small">{lp.omschrijving}</div>}</td>
<td className="small">
{lp.groepen_namen.length > 0 && <div>🗂 {lp.groepen_namen.map((x) => x.naam).join(", ")}</div>}
{lp.subgroepen_namen.length > 0 && <div>🔹 {lp.subgroepen_namen.map((x) => x.naam).join(", ")}</div>}
{lp.leerlingen_namen.length > 0 && <div>👤 {lp.leerlingen_namen.map((x) => x.naam).join(", ")}</div>}
{(lp.medewerkers_namen || []).length > 0 && <div>🧑🏫 {lp.medewerkers_namen.map((x) => x.naam).join(", ")}</div>}
{lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length + (lp.medewerkers_namen || []).length === 0 && <span className="muted">leeg</span>}
</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(lp.id); setForm({ naam: lp.naam, omschrijving: lp.omschrijving, groepen: lp.groepen_namen.map((x) => x.id), subgroepen: lp.subgroepen_namen.map((x) => x.id), leerlingen: lp.leerlingen_namen.map((x) => x.id), medewerkers: (lp.medewerkers_namen || []).map((x) => x.id) }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(lp.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen leerpleinen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* ---------------------------------- Stage --------------------------------- */
// Module-scherm: interne en externe stages. Beide met begeleider; intern + ruimte.
export function StagePage() {
const { items, error, loading, reload, setError } = useList("stages");
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [locaties, setLocaties] = useState([]);
const leeg = { leerling: "", type: "intern", begeleider: "", locatie: "", organisatie: "", dag: "", 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("locaties").then(setLocaties).catch(() => {});
}, []);
const isIntern = form.type === "intern";
async function opslaan(e) {
e.preventDefault();
const payload = {
leerling: form.leerling, type: form.type, begeleider: form.begeleider,
locatie: isIntern ? (form.locatie || null) : null,
organisatie: isIntern ? "" : form.organisatie,
dag: form.dag === "" ? null : Number(form.dag),
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); }
}
return (
<section>
<h1>Stage</h1>
<p className="muted">Interne en externe stages. Beide krijgen een begeleider; een interne stage ook een ruimte.</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.locatie} required onChange={(e) => setForm({ ...form, locatie: e.target.value })}>
<option value=""> ruimte </option>
{locaties.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 })} />
)}
<select value={form.dag} onChange={(e) => setForm({ ...form, dag: e.target.value })}>
<option value=""> dag (optioneel) </option>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<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>Dag</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.locatie_naam || "—") : (s.organisatie || "—")}</td>
<td className="muted">{s.dag_label || "—"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(s.id); setForm({ leerling: s.leerling, type: s.type, begeleider: s.begeleider, locatie: s.locatie || "", organisatie: s.organisatie || "", dag: s.dag ?? "", 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/leerplein/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, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx;
const [f, setF] = useState({
tijdslot: blok.tijdslot,
activiteit: blok.activiteit,
locatie: blok.locatie || "",
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, 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, ...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, ...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" onClick={onSluit}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop"><h2>Blok bewerken</h2><button className="link" onClick={onSluit}>sluiten </button></div>
<Foutmelding msg={error} />
<div className="form-row">
<select value={f.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} 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} 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>
</div>
<div className="form-row">
<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>
</div>
{f.doel_type === "subgroep" && (
<div className="form-row">
<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" onClick={maakSubgroep}>+ Subgroep aanmaken</button>
</div>
)}
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<div className="chips">
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
</div>
</div>
<div className="form-row"><input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} /></div>
<div className="form-row">
<button className="btn primary" onClick={opslaan}>Opslaan</button>
<button className="btn" onClick={verwijderen}>Verwijderen</button>
</div>
<hr className="modal-hr" />
<div className="muted small">Groep subgroep: deel dit vak op onder {splitGroepNaam} (zelfde tijd):</div>
<div className="chips">
{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>
<div className="form-row">
<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" onClick={maakSplitSub}>+ Subgroep</button>
</div>
<label className="veld"><input type="checkbox" checked={verwijderOrig} onChange={(e) => setVerwijderOrig(e.target.checked)} /> oorspronkelijke blok verwijderen na opdelen</label>
<div className="form-row">
<button className="btn primary" disabled={splitSubs.length === 0} onClick={opdelen}>Opdelen in {splitSubs.length} subgroep(en)</button>
</div>
<hr className="modal-hr" />
<div className="muted small">Kopiëren naar andere tijden/dagen:</div>
<div className="chips">
{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>
<div className="form-row">
<button className="btn" disabled={kopieDoelen.length === 0} onClick={kopieer}>Kopieer naar {kopieDoelen.length} tijdslot(en)</button>
</div>
</div>
</div>
);
}
/* ------------------------------- Groepsindeling --------------------------- */
// Verdeel een hoofdgroep (of leerplein) 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 leerplein 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">Leerplein</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 dit leerplein"}:</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 [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 [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/leerplein/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: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
const [form, setForm] = useState(leeg);
// 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("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(() => {});
// 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); }
}
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); setToonForm(false); return; }
if (typing) return;
if (e.key === "n" || e.key === "N") { setTab("week"); setToonForm((v) => !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);
}
const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen;
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 payload = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, groep: form.doel_type === "groep" ? form.doel : null, subgroep: form.doel_type === "subgroep" ? form.doel : null, leerling: form.doel_type === "leerling" ? form.doel : null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen };
try { await createResource("roosterblokken", payload); setForm({ ...leeg, doel_type: form.doel_type }); setToonForm(false); await laadBlokken(); } 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] || "") : ""; };
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" : "")}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur }}
onClick={edit ? () => setBewerkBlok(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_naam}</strong>
{edit && <button className="link 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.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>
)}
{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 className="link" onClick={(e) => { e.stopPropagation(); herstel(uit.id); }}>herstel</button></>}</div>}
{!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">Vervanger: {uit.vervanger_naam}</div>}
{edit && !kal && !uit && (
<button className="link danger small rb-verval no-print" onClick={(e) => { e.stopPropagation(); laatVervallen(b, iso); }}>dit vak laten vervallen</button>
)}
</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));
out.push(
<div className="blok-splitsing leerplein-splitsing" key={`leerplein-${scopeId}`}>
<div className="split-groep">{leerplein ? leerplein.naam : "Leerplein"}</div>
<div className="split-kolommen">
{[...perGroep.entries()].map(([gid, groepBlokken]) => {
const groep = groepen.find((g) => g.id === gid);
return (
<div className="split-kolom" key={gid}>
<div className="split-kop">{groep ? groep.naam : "Groep"}</div>
{groepBlokken.map((b) => renderRoosterBlok(b, iso, kal))}
</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))];
out.push(
<div className="blok-splitsing" key={`split-${gid}`}>
<div className="split-groep">{groep ? groep.naam : "Groep"}</div>
<div className="split-kolommen">
{heleGroep.length > 0 && (
<div className="split-kolom">
<div className="split-kop">Hele groep</div>
{heleGroep.map((b) => renderRoosterBlok(b, iso, kal))}
</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">{sub ? sub.naam : "Subgroep"}</div>
{blocks.map((b) => renderRoosterBlok(b, iso, kal))}
</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" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
{printAan && schooljaar && (edit ? tab === "week" : true) && (
<button className="btn print-knop" title="Rooster afdrukken" onClick={() => window.print()}>🖨 Afdrukken</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 leerplein</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>}
</div>
)}
{edit && (
<div className="tabbar no-print">
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
<button className={tab === "indeling" ? "tab on" : "tab"} onClick={() => setTab("indeling")}>Indeling</button>
<button className={tab === "conflicten" ? "tab on" : "tab"} onClick={() => setTab("conflicten")}>Conflicten</button>
<button className={tab === "schooljaar" ? "tab on" : "tab"} onClick={() => setTab("schooljaar")}>Schooljaar</button>
<button className={tab === "instellingen" ? "tab on" : "tab"} 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 && (
<form className="form-card no-print" onSubmit={opslaan}>
<div className="form-row">
<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>
</div>
<div className="form-row">
<select value={form.doel_type} onChange={(e) => setForm({ ...form, doel_type: e.target.value, doel: "" })}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={form.doel} required onChange={(e) => setForm({ ...form, doel: e.target.value })}>
<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>
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<div className="chips">
{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>
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
<button className="btn primary" type="submit">Blok toevoegen</button>
</div>
</form>
)}
{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 className="link small no-print dag-actie" onClick={() => dagHerstel(kal.id)}>weer lesdag</button>
: <button className="link danger small no-print dag-actie" onClick={() => dagVervallen(iso)}>dag laten vervallen</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} />}
{edit && schooljaar && tab === "indeling" && (
<Groepsindeling
groepen={groepen}
leerpleinen={leerpleinen}
subgroepen={subgroepen}
medewerkers={medewerkers}
blokken={blokken}
onWijzig={async () => {
setSubgroepen(await listResource("subgroepen"));
await laadBlokken();
}}
/>
)}
{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>
)}
{schooljaar && tab === "instellingen" && (
<div className="instellingen">
<p className="muted">Bouwstenen van het rooster beheer ze hier zonder het Rooster te verlaten.</p>
<TijdslotenPage />
<ActiviteitenPage />
<LocatiesPage />
</div>
)}
{edit && bewerkBlok && (
<BlokBewerker
blok={bewerkBlok}
ctx={{ activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }}
onSluit={() => setBewerkBlok(null)}
onKlaar={async () => { setBewerkBlok(null); await laadBlokken(); }}
/>
)}
{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" />; }