roosterwijs/frontend/src/pages.jsx
2026-07-04 23:23:58 +02:00

4588 lines
234 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 { Fragment, useEffect, useRef, useState } from "react";
import {
listResource,
createResource,
updateResource,
deleteResource,
getModules,
getAuditLog,
getLokaalinstelling,
setLokaalinstelling,
exportUrl,
importPersonenCsv,
getPortal,
getVervangSuggesties,
pasVervangingToe,
uploadBestand,
wachtwoordReset,
adminWachtwoordReset,
gebruikersImport,
getHuisstijl,
uploadLogo,
verwijderLogo,
opslaanVormgeving,
} from "./api.js";
const ROLLEN = [
{ value: "leerling", label: "Leerling" },
{ value: "leerkracht", label: "Leerkracht" },
{ value: "praktijkondersteuner", label: "Praktijkondersteuner" },
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
];
// Korte aanduiding van een medewerkersrol (compact in de roostervakjes).
const ROL_AFK = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" };
// Automatisch een passend icoon bij een vak/activiteit op basis van de naam.
const VAK_ICONEN = [
[/reken|wiskund|getal/i, "🔢"],
[/taal|lees|spell|begrijpend|nederlands|woordenschat/i, "📖"],
[/engels|frans|duits|spaans/i, "🗣️"],
[/gym|beweg|sport|zwem|motoriek/i, "🤸"],
[/muziek|zang/i, "🎵"],
[/teken|kunst|knutsel|creatief|handvaardig|drama/i, "🎨"],
[/pauze|speel|buiten/i, "☕"],
[/eten|lunch|fruit/i, "🍎"],
[/natuur|biolog|wereld|aardrijk|geschied/i, "🌍"],
[/koken|huishoud/i, "🍳"],
[/techniek|bouw|hout/i, "🔧"],
[/computer|digit|ict|programmeer|media/i, "💻"],
[/therap|logop|fysio|sova|sociale|gedrag/i, "💬"],
[/stage|werk|prakt/i, "🧑‍🏭"],
[/kring|start|dag|inloop/i, "⭕"],
];
function vakIcoon(naam) {
if (!naam) return "📚";
for (const [re, icon] of VAK_ICONEN) if (re.test(naam)) return icon;
return "📚";
}
const BOVENGROEP_LABEL = "Bovenliggende groep";
const BOVENGROEP_LABEL_LC = "bovenliggende groep";
const BOVENGROEP_LABEL_MV = "Bovenliggende groepen";
const WEEKDAGEN = [
{ value: 0, label: "Maandag" },
{ value: 1, label: "Dinsdag" },
{ value: 2, label: "Woensdag" },
{ value: 3, label: "Donderdag" },
{ value: 4, label: "Vrijdag" },
];
const DAGTYPES = [
{ value: "studiedag", label: "Studiedag" },
{ value: "vrije_dag", label: "Vrije dag" },
{ value: "vakantie", label: "Vakantie" },
];
function useList(resource, query = "") {
const [items, setItems] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function reload() {
setLoading(true);
try {
setItems(await listResource(resource, query));
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
reload();
}, [resource, query]);
return { items, error, loading, reload, setError };
}
function Foutmelding({ msg }) {
if (!msg) return null;
return <div className="banner error"> {msg}</div>;
}
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties).
function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false, metPictogram = false }) {
const { items, error, loading, reload, setError } = useList(resource);
const leeg = {
naam: "", omschrijving: "",
...(kleur ? { kleur: "#2563eb" } : {}),
...(metTheorievak ? { theorievak: false } : {}),
...(metIcoon ? { icoon: "" } : {}),
};
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0) + (metPictogram ? 1 : 0);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource(resource, editId, form);
else await createResource(resource, form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Verwijderen?")) return;
try {
await deleteResource(resource, id);
await reload();
} catch (err) {
setError(err.message);
}
}
async function uploadPicto(id, file) {
try { await uploadBestand(resource, id, "pictogram", file); await reload(); }
catch (err) { setError(err.message); }
}
async function verwijderPicto(id) {
try { await updateResource(resource, id, { pictogram: null }); await reload(); }
catch (err) { setError(err.message); }
}
function bewerk(it) {
setEditId(it.id);
setForm({
naam: it.naam, omschrijving: it.omschrijving,
...(kleur ? { kleur: it.kleur } : {}),
...(metTheorievak ? { theorievak: it.theorievak } : {}),
...(metIcoon ? { icoon: it.icoon || "" } : {}),
});
}
return (
<section>
<h1>{titel}</h1>
{uitleg && <p className="muted">{uitleg}</p>}
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
{kleur && (
<input type="color" value={form.kleur} title="Kleur" onChange={(e) => setForm({ ...form, kleur: e.target.value })} />
)}
{metIcoon && (
<input className="icoon-input" maxLength={4} placeholder="icoon" title="Optioneel emoji; leeg = automatisch" value={form.icoon || ""} onChange={(e) => setForm({ ...form, icoon: e.target.value })} />
)}
{metTheorievak && (
<label className="veld">
<input type="checkbox" checked={!!form.theorievak} onChange={(e) => setForm({ ...form, theorievak: e.target.checked })} />
theorievak
</label>
)}
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && (
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>
)}
</form>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Omschrijving</th>
{metTheorievak && <th>Theorievak</th>}
{metPictogram && <th>Pictogram</th>}
{telKolom && <th>{telKolom}</th>}
<th></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td>
{kleur && <span className="kleur-stip" style={{ background: it.kleur }} />}
{metIcoon && <span className="vak-icoon">{it.icoon || vakIcoon(it.naam)} </span>}
{it.naam}
</td>
<td className="muted">{it.omschrijving || "—"}</td>
{metTheorievak && <td>{it.theorievak ? "✓" : "—"}</td>}
{metPictogram && (
<td className="picto-cel">
{it.pictogram && <img src={it.pictogram} alt="" className="picto-thumb" />}
<label className="link small">{it.pictogram ? "vervang" : "upload"}<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) uploadPicto(it.id, f); e.target.value = ""; }} /></label>
{it.pictogram && <button type="button" className="link small danger" onClick={() => verwijderPicto(it.id)}>verwijder</button>}
</td>
)}
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
<td className="row-actions">
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(it.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={kolommen} className="muted">Nog niets.</td></tr>
)}
</tbody>
</table>
)}
</section>
);
}
export function FunctiesPage() {
return (
<CatalogusPage
titel="Functies"
uitleg="Beheer de functies die medewerkers kunnen hebben (bv. Directie, Logopedist). Medewerkers kunnen meerdere functies hebben."
resource="functies"
telKolom="Medewerkers"
/>
);
}
export function ActiviteitenPage() {
return (
<CatalogusPage
titel="Activiteiten / vakken"
uitleg="Wat er gedaan wordt: rekenen, gym, therapie, pauze … Elke activiteit heeft een kleur. Markeer theorievakken; die koppelen automatisch aan nieuwe leerkrachten."
resource="activiteiten"
kleur
metTheorievak
metIcoon
metPictogram
/>
);
}
export function LocatiesPage() {
return (
<CatalogusPage
titel="Locaties"
uitleg="Vrije locatie-labels (bijv. externe plekken). Voor lesruimtes met dubbele-boekingcheck: zie Lokalen."
resource="locaties"
/>
);
}
export function LokalenPage() {
const { items, error, loading, reload, setError } = useList("lokalen");
const leeg = { naam: "", omschrijving: "", capaciteit: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const [blokkeren, setBlokkeren] = useState(false);
const [instError, setInstError] = useState(null);
useEffect(() => {
getLokaalinstelling().then((d) => setBlokkeren(!!d.dubbel_blokkeren)).catch(() => {});
}, []);
async function toggleBlokkeren(val) {
setBlokkeren(val);
try { await setLokaalinstelling({ dubbel_blokkeren: val }); setInstError(null); }
catch (err) { setInstError(err.message); setBlokkeren(!val); }
}
async function opslaan(e) {
e.preventDefault();
const payload = { naam: form.naam, omschrijving: form.omschrijving, capaciteit: form.capaciteit === "" ? null : Number(form.capaciteit) };
try {
if (editId) await updateResource("lokalen", editId, payload);
else await createResource("lokalen", payload);
setForm(leeg); setEditId(null); await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit lokaal verwijderen?")) return;
try { await deleteResource("lokalen", id); await reload(); } catch (err) { setError(err.message); }
}
return (
<section>
<h1>Lokalen</h1>
<p className="muted">Lesruimtes die door lessen én interne stages gedeeld worden. Het systeem bewaakt dubbele boekingen per tijdslot.</p>
<Foutmelding msg={error} />
<Foutmelding msg={instError} />
<label className="veld" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={blokkeren} onChange={(e) => toggleBlokkeren(e.target.checked)} />
{" "}Dubbele boeking hard blokkeren (anders alleen waarschuwen in het conflicten-overzicht)
</label>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<input type="number" min="1" placeholder="Capaciteit (optioneel)" value={form.capaciteit} onChange={(e) => setForm({ ...form, capaciteit: e.target.value })} />
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Capaciteit</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{items.map((l) => (
<tr key={l.id}>
<td>{l.naam}</td>
<td className="muted">{l.capaciteit ?? "—"}</td>
<td className="muted">{l.omschrijving || "—"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(l.id); setForm({ naam: l.naam, omschrijving: l.omschrijving || "", capaciteit: l.capaciteit ?? "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(l.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen lokalen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* --------------------------------- Groepen -------------------------------- */
export function GroepenPage() {
return <GroepsindelingPage />;
}
/* -------------------------------- Personen -------------------------------- */
const STAFF_ROLLEN = ROLLEN.filter((r) => r.value !== "leerling");
export function PersonenPage() {
const { items, error, loading, reload, setError } = useList("personen");
const [groepen, setGroepen] = useState([]);
const [functies, setFuncties] = useState([]);
const [vakken, setVakken] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
// Twee losse flows: medewerker en leerling.
const [modus, setModus] = useState("leerling");
const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [], begeleide_subgroepen: [], leerpleinen: [] };
const leegLeerling = { voornaam: "", achternaam: "", groep: "", geboortedatum: "" };
const [form, setForm] = useState(leegLeerling);
const [editId, setEditId] = useState(null);
const [filter, setFilter] = useState("");
const [nieuweFunctie, setNieuweFunctie] = useState("");
const [wizardAan, setWizardAan] = useState(false);
const [importView, setImportView] = useState(false);
async function maakFunctie() {
const naam = nieuweFunctie.trim();
if (!naam) return;
try {
const f = await createResource("functies", { naam });
setFuncties((lijst) => [...lijst, f]);
setForm((prev) => ({ ...prev, functies: [...prev.functies, f.id] }));
setNieuweFunctie("");
} catch (e) { setError(e.message); }
}
function laadHulplijsten() {
listResource("groepen").then(setGroepen).catch(() => {});
listResource("functies").then(setFuncties).catch(() => {});
listResource("activiteiten").then(setVakken).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([]));
getModules().then((ms) => setWizardAan(ms.some((m) => m.key === "personenwizard" && m.enabled))).catch(() => {});
}
useEffect(() => { laadHulplijsten(); }, []);
// De bovenliggende-groep-koppeling loopt via de bestaande API.
async function syncLeerpleinen(persoonId, gekozenIds) {
const huidige = await listResource("leerpleinen").catch(() => []);
for (const lp of huidige) {
const inLijst = (lp.medewerkers || []).includes(persoonId);
const moet = gekozenIds.includes(lp.id);
if (moet && !inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: [...lp.medewerkers, persoonId] });
else if (!moet && inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: lp.medewerkers.filter((x) => x !== persoonId) });
}
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
}
function kiesModus(m) {
setModus(m);
setEditId(null);
setForm(m === "medewerker" ? leegMedewerker : leegLeerling);
}
function toggle(veld, id) {
setForm((f) => f[veld].includes(id) ? { ...f, [veld]: f[veld].filter((x) => x !== id) } : { ...f, [veld]: [...f[veld], id] });
}
async function opslaan(e) {
e.preventDefault();
const payload = modus === "medewerker"
? {
voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol,
stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken,
begeleide_subgroepen: form.begeleide_subgroepen,
}
: {
voornaam: form.voornaam, achternaam: form.achternaam, rol: "leerling",
groep: form.groep || null, geboortedatum: form.geboortedatum || null,
};
try {
const opgeslagen = editId
? await updateResource("personen", editId, payload)
: await createResource("personen", payload);
if (modus === "medewerker" && opgeslagen && opgeslagen.id) {
await syncLeerpleinen(opgeslagen.id, form.leerpleinen || []);
}
setForm(modus === "medewerker" ? leegMedewerker : leegLeerling);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze persoon verwijderen?")) return;
try { await deleteResource("personen", id); await reload(); } catch (err) { setError(err.message); }
}
function bewerk(p) {
setEditId(p.id);
if (p.rol === "leerling") {
setModus("leerling");
setForm({ voornaam: p.voornaam, achternaam: p.achternaam, groep: p.groep || "", geboortedatum: p.geboortedatum || "" });
} else {
setModus("medewerker");
setForm({
voornaam: p.voornaam, achternaam: p.achternaam, rol: p.rol,
stamgroep: p.stamgroep || "",
functies: p.functies_namen.map((x) => x.id),
vakken: p.vakken_namen.map((x) => x.id),
begeleide_subgroepen: (p.begeleide_subgroepen_namen || []).map((x) => x.id),
leerpleinen: leerpleinen.filter((lp) => (lp.medewerkers || []).includes(p.id)).map((lp) => lp.id),
});
}
}
const zichtbaar = filter === "leerling" ? items.filter((p) => p.rol === "leerling")
: filter === "medewerker" ? items.filter((p) => p.rol !== "leerling")
: items;
return (
<section>
<h1>Personen</h1>
<p className="muted">Personeel en leerlingen staan los van elkaar. Kies wat je wilt toevoegen.</p>
<Foutmelding msg={error} />
<div className="tabbar">
<button className={!importView && modus === "leerling" ? "tab on" : "tab"} onClick={() => { kiesModus("leerling"); setImportView(false); }}><span className="ico">🧒</span><span className="tab-tekst">Leerling</span></button>
<button className={!importView && modus === "medewerker" ? "tab on" : "tab"} onClick={() => { kiesModus("medewerker"); setImportView(false); }}><span className="ico">🧑🏫</span><span className="tab-tekst">Medewerker</span></button>
{wizardAan && <button className={importView ? "tab on" : "tab"} onClick={() => setImportView(true)}><span className="ico">📋</span><span className="tab-tekst">Bulk / CSV-import</span></button>}
</div>
{importView ? <PersonenWizardPage /> : (<>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
{modus === "leerling" ? (
<>
<select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<label className="veld">Geb. <input type="date" value={form.geboortedatum || ""} onChange={(e) => setForm({ ...form, geboortedatum: e.target.value })} /></label>
</>
) : (
<>
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{STAFF_ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<select value={form.stamgroep || ""} onChange={(e) => setForm({ ...form, stamgroep: e.target.value })}>
<option value=""> stamgroep/plein </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</>
)}
</div>
{modus === "medewerker" && (
<>
<div className="leden-keuze">
<div className="muted small">Functies (klik om te kiezen, of typ een nieuwe):</div>
<div className="chips">
{functies.map((f) => <button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggle("functies", f.id)}>{f.naam}</button>)}
{functies.length === 0 && <span className="muted small">Nog geen functies.</span>}
</div>
<div className="form-row" style={{ marginTop: 6 }}>
<input
placeholder="Nieuwe functie"
value={nieuweFunctie}
onChange={(e) => setNieuweFunctie(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakFunctie(); } }}
/>
<button type="button" className="btn" onClick={maakFunctie}>+ Functie</button>
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Vakken die gegeven kunnen worden{form.rol === "leerkracht" ? " (theorievakken worden automatisch gekoppeld)" : ""}:</div>
<div className="chips">
{vakken.map((a) => <button type="button" key={a.id} className={form.vakken.includes(a.id) ? "chip on" : "chip"} onClick={() => toggle("vakken", a.id)}>{a.naam}{a.theorievak ? " ●" : ""}</button>)}
{vakken.length === 0 && <span className="muted small">Nog geen vakken/activiteiten.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Subgroepen (begeleiding):</div>
<div className="chips">
{subgroepen.map((s) => <button type="button" key={s.id} className={form.begeleide_subgroepen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggle("begeleide_subgroepen", s.id)}>{s.naam}</button>)}
{subgroepen.length === 0 && <span className="muted small">Nog geen subgroepen.</span>}
</div>
</div>
{leerpleinen.length > 0 && (
<div className="leden-keuze">
<div className="muted small">{BOVENGROEP_LABEL_MV}:</div>
<div className="chips">
{leerpleinen.map((lp) => <button type="button" key={lp.id} className={form.leerpleinen.includes(lp.id) ? "chip on" : "chip"} onClick={() => toggle("leerpleinen", lp.id)}>{lp.naam}</button>)}
</div>
</div>
)}
</>
)}
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => kiesModus(modus)}>Annuleren</button>}
</div>
</form>
<div className="filterbar">
<span className="muted">Toon:</span>
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
<button className={filter === "leerling" ? "chip on" : "chip"} onClick={() => setFilter("leerling")}>Leerlingen</button>
<button className={filter === "medewerker" ? "chip on" : "chip"} onClick={() => setFilter("medewerker")}>Medewerkers</button>
</div>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Rol</th><th>Details</th><th></th></tr></thead>
<tbody>
{zichtbaar.map((p) => (
<tr key={p.id}>
<td>{p.volledige_naam}</td>
<td>{p.rol_label}</td>
<td className="muted small">
{p.rol === "leerling" ? (
<>
{p.groep_naam ? <span>🗂 {p.groep_naam} </span> : null}
{p.geboortedatum ? <span>🎂 {p.geboortedatum}</span> : null}
{!p.groep_naam && !p.geboortedatum ? "—" : null}
</>
) : (
<>
{p.stamgroep_naam ? <div>🗂 {p.stamgroep_naam}</div> : null}
{p.functies_namen.length ? <div>🪪 {p.functies_namen.map((x) => x.naam).join(", ")}</div> : null}
{p.vakken_namen.length ? <div>📚 {p.vakken_namen.map((x) => x.naam).join(", ")}</div> : null}
{!p.stamgroep_naam && !p.functies_namen.length && !p.vakken_namen.length ? "—" : null}
</>
)}
</td>
<td className="row-actions">
<button className="link" onClick={() => bewerk(p)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
</td>
</tr>
))}
{zichtbaar.length === 0 && <tr><td colSpan="4" className="muted">Geen personen.</td></tr>}
</tbody>
</table>
)}
</>)}
</section>
);
}
/* ------------------------------- Subgroepen ------------------------------- */
export function SubgroepenPage() {
return <GroepsindelingPage />;
}
/* ------------------------------- Tijdsloten ------------------------------- */
export function TijdslotenPage() {
const { items, error, loading, reload, setError } = useList("tijdsloten");
const [groepen, setGroepen] = useState([]);
const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1, groep: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []);
async function opslaan(e) {
e.preventDefault();
try {
const payload = { ...form, groep: form.groep || null };
if (editId) await updateResource("tijdsloten", editId, payload);
else await createResource("tijdsloten", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Dit tijdslot verwijderen?")) return;
try {
await deleteResource("tijdsloten", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Tijdsloten</h1>
<p className="muted">Vaste tijden per schooldag. Laat de groep leeg voor de algemene tijden, of kies een groep voor afwijkende tijden.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. Blok 1)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<select value={form.dag} onChange={(e) => setForm({ ...form, dag: Number(e.target.value) })}>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input type="time" value={form.start_tijd} onChange={(e) => setForm({ ...form, start_tijd: e.target.value })} />
<input type="time" value={form.eind_tijd} onChange={(e) => setForm({ ...form, eind_tijd: e.target.value })} />
<input type="number" min="0" style={{ width: 80 }} title="Volgorde" value={form.volgorde} onChange={(e) => setForm({ ...form, volgorde: Number(e.target.value) })} />
<select value={form.groep || ""} title="Groep (leeg = algemeen)" onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value="">algemeen</option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Dag</th><th>Tijd</th><th>Naam</th><th>Geldt voor</th><th></th></tr></thead>
<tbody>
{items.map((t) => (
<tr key={t.id}>
<td>{t.dag_label}</td>
<td>{t.start_tijd?.slice(0, 5)}{t.eind_tijd?.slice(0, 5)}</td>
<td>{t.naam}</td>
<td className="muted">{t.groep_naam || "algemeen"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(t.id); setForm({ naam: t.naam, dag: t.dag, start_tijd: t.start_tijd?.slice(0, 5), eind_tijd: t.eind_tijd?.slice(0, 5), volgorde: t.volgorde, groep: t.groep || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(t.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="5" className="muted">Nog geen tijdsloten.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* --------------------------- Schooljaar & kalender ------------------------ */
export function SchooljaarPage() {
const { items: jaren, error, loading, reload, setError } = useList("schooljaren");
const leeg = { naam: "", start_datum: "", eind_datum: "" };
const [form, setForm] = useState(leeg);
const [gekozen, setGekozen] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("schooljaren", form);
setForm(leeg);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Dit schooljaar (incl. kalender) verwijderen?")) return;
try {
await deleteResource("schooljaren", id);
if (gekozen === id) setGekozen(null);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Schooljaar &amp; kalender</h1>
<p className="muted">Schooljaren met studiedagen, vrije dagen en vakanties. Op die dagen vervallen de roosterblokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. 2026-2027)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<label className="veld">Start <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">Eind <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Schooljaar</th><th>Periode</th><th></th></tr></thead>
<tbody>
{jaren.map((j) => (
<tr key={j.id} className={gekozen === j.id ? "rij-actief" : ""}>
<td>{j.naam}</td>
<td className="muted">{j.start_datum} t/m {j.eind_datum}</td>
<td className="row-actions">
<button className="link" onClick={() => setGekozen(gekozen === j.id ? null : j.id)}>
{gekozen === j.id ? "kalender verbergen" : "kalender beheren"}
</button>
<button className="link danger" onClick={() => verwijder(j.id)}>verwijderen</button>
</td>
</tr>
))}
{jaren.length === 0 && <tr><td colSpan="3" className="muted">Nog geen schooljaren.</td></tr>}
</tbody>
</table>
)}
{gekozen && <Kalender schooljaarId={gekozen} />}
</section>
);
}
function Kalender({ schooljaarId }) {
const { items, error, reload, setError } = useList("kalenderdagen", `?schooljaar=${schooljaarId}`);
const leeg = { datum: "", type: "vrije_dag", omschrijving: "" };
const [form, setForm] = useState(leeg);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("kalenderdagen", { ...form, schooljaar: schooljaarId });
setForm(leeg);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
try {
await deleteResource("kalenderdagen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<div className="subpaneel">
<h2>Kalender</h2>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input type="date" value={form.datum} required onChange={(e) => setForm({ ...form, datum: e.target.value })} />
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{DAGTYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input placeholder="Omschrijving (bv. Kerstvakantie)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<button className="btn primary" type="submit">Dag toevoegen</button>
</form>
<table className="grid">
<thead><tr><th>Datum</th><th>Type</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{d.datum}</td>
<td><span className={`tag dag-${d.type}`}>{d.type_label}</span></td>
<td className="muted">{d.omschrijving || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(d.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen bijzondere dagen.</td></tr>}
</tbody>
</table>
</div>
);
}
/* ------------------------------- Afwezigheid ------------------------------ */
export function AfwezigheidPage() {
const { items, error, loading, reload, setError } = useList("afwezigheden");
const [personen, setPersonen] = useState([]);
const leeg = { persoon: "", start_datum: "", eind_datum: "", reden: "" };
const [form, setForm] = useState(leeg);
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("afwezigheden", form);
setForm(leeg);
await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Deze afwezigheid verwijderen?")) return;
try { await deleteResource("afwezigheden", id); await reload(); } catch (err) { setError(err.message); }
}
return (
<section>
<h1>Afwezigheid</h1>
<p className="muted">Leg vast wie wanneer afwezig is. In het rooster zie je dat terug bij de betrokken blokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<select value={form.persoon} required onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
<option value=""> persoon </option>
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
</select>
<label className="veld">Van <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">T/m <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<input placeholder="Reden (optioneel)" value={form.reden} onChange={(e) => setForm({ ...form, reden: e.target.value })} />
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Persoon</th><th>Periode</th><th>Reden</th><th></th></tr></thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>{a.persoon_naam}</td>
<td className="muted">{a.start_datum} t/m {a.eind_datum}</td>
<td className="muted">{a.reden || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(a.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Geen afwezigheden.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* ----------------------------- Groepsindeling ----------------------------- */
// Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep.
const SROL = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" };
function InlineAdd({ placeholder, onAdd, klein }) {
const [v, setV] = useState("");
return (
<form
className={klein ? "inline-add klein" : "inline-add"}
onSubmit={(e) => { e.preventDefault(); if (v.trim()) { onAdd(v.trim()); setV(""); } }}
>
<input placeholder={placeholder} value={v} onChange={(e) => setV(e.target.value)} />
<button className="btn klein" type="submit">+</button>
</form>
);
}
export function GroepsindelingPage() {
const [leerpleinen, setLeerpleinen] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [toon, setToon] = useState("allen"); // allen | leerling | personeel
const [selectie, setSelectie] = useState(new Set()); // persoon-ids
const [plaatsDoel, setPlaatsDoel] = useState(""); // "kind:id"
const dragRef = useRef(null);
const [ghost, setGhost] = useState(null);
const [hoverZone, setHoverZone] = useState(null);
async function laadAlles() {
setLoading(true);
try {
const [lp, gr, sg, ll, ps] = await Promise.all([
listResource("leerpleinen"),
listResource("groepen"),
listResource("subgroepen"),
listResource("personen", "?rol=leerling"),
listResource("personen"),
]);
setLeerpleinen(lp);
setGroepen(gr);
setSubgroepen(sg);
setLeerlingen(ll);
setMedewerkers(ps.filter((p) => p.rol !== "leerling"));
setError(null);
} catch (err) { setError(err.message); } finally { setLoading(false); }
}
useEffect(() => { laadAlles(); }, []);
const leerlingById = (id) => leerlingen.find((l) => l.id === id);
const medewerkerById = (id) => medewerkers.find((m) => m.id === id);
const isLeerlingId = (id) => leerlingen.some((l) => l.id === id);
const toonRol = (rol) => toon === "allen" || (toon === "leerling" ? rol === "leerling" : rol !== "leerling");
const groepLeerlingen = (gid) => leerlingen.filter((l) => l.groep === gid);
const groepMedewerkers = (gid) => medewerkers.filter((m) => m.stamgroep === gid);
const groepSubs = (gid) => subgroepen.filter((s) => s.groep === gid);
const lpGroepen = (lp) => groepen.filter((g) => (lp.groepen || []).includes(g.id));
const groepHeeftLeerplein = (gid) => leerpleinen.some((lp) => (lp.groepen || []).includes(gid));
const ingedeeldeLeerlingen = (() => {
const s = new Set();
leerlingen.forEach((l) => { if (l.groep) s.add(l.id); });
leerpleinen.forEach((lp) => (lp.leerlingen || []).forEach((id) => s.add(id)));
subgroepen.forEach((sg) => (sg.leden_namen || []).forEach((x) => s.add(x.id)));
return s;
})();
const ingedeeldeMedewerkers = (() => {
const s = new Set();
medewerkers.forEach((m) => { if (m.stamgroep) s.add(m.id); });
leerpleinen.forEach((lp) => (lp.medewerkers || []).forEach((id) => s.add(id)));
subgroepen.forEach((sg) => (sg.begeleiders_namen || []).forEach((x) => s.add(x.id)));
return s;
})();
const losseLeerlingen = leerlingen.filter((l) => !ingedeeldeLeerlingen.has(l.id));
const losseMedewerkers = medewerkers.filter((m) => !ingedeeldeMedewerkers.has(m.id));
const toggleSel = (id) => setSelectie((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const wisSelectie = () => setSelectie(new Set());
const containerOpties = [];
leerpleinen.forEach((lp) => {
containerOpties.push({ value: `leerplein:${lp.id}`, label: `${lp.naam} (bovenliggende groep)` });
lpGroepen(lp).forEach((g) => {
containerOpties.push({ value: `groep:${g.id}`, label: `${lp.naam} ${g.naam} (groep)` });
groepSubs(g.id).forEach((s) => containerOpties.push({ value: `subgroep:${s.id}`, label: `${lp.naam} ${g.naam} ${s.naam} (subgroep)` }));
});
});
function startDrag(e, payload) {
e.preventDefault();
dragRef.current = payload;
const meerdere = selectie.has(payload.id) && selectie.size > 1;
setGhost({ x: e.clientX, y: e.clientY, label: meerdere ? `${selectie.size} personen` : payload.naam });
}
useEffect(() => {
function move(e) {
if (!dragRef.current) return;
const meerdere = selectie.has(dragRef.current.id) && selectie.size > 1;
setGhost({ x: e.clientX, y: e.clientY, label: meerdere ? `${selectie.size} personen` : dragRef.current.naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const z = el && el.closest("[data-drop-kind]");
setHoverZone(z ? `${z.dataset.dropKind}:${z.dataset.dropId}` : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const z = el && el.closest("[data-drop-kind]");
const payload = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverZone(null);
if (z) {
const zone = { kind: z.dataset.dropKind, id: Number(z.dataset.dropId) };
if (selectie.has(payload.id) && selectie.size > 1) await plaatsSelectie(zone);
else await drop(zone, payload);
}
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
}, [leerpleinen, groepen, subgroepen, leerlingen, medewerkers, selectie]);
async function drop(zone, p) {
const { kind, id } = zone;
try {
if (kind === "groep") {
await updateResource("personen", p.id, p.rol === "leerling" ? { groep: id } : { stamgroep: id });
} else if (kind === "leerplein") {
const lp = leerpleinen.find((x) => x.id === id); if (!lp) return;
if (p.rol === "leerling") {
if (!(lp.leerlingen || []).includes(p.id)) await updateResource("leerpleinen", id, { leerlingen: [...(lp.leerlingen || []), p.id] });
} else if (!(lp.medewerkers || []).includes(p.id)) {
await updateResource("leerpleinen", id, { medewerkers: [...(lp.medewerkers || []), p.id] });
}
} else if (kind === "subgroep") {
const s = subgroepen.find((x) => x.id === id); if (!s) return;
if (p.rol === "leerling") {
const ids = s.leden_namen.map((x) => x.id);
if (!ids.includes(p.id)) await updateResource("subgroepen", id, { leden: [...ids, p.id] });
} else {
const ids = s.begeleiders_namen.map((x) => x.id);
if (!ids.includes(p.id)) await updateResource("subgroepen", id, { begeleiders: [...ids, p.id] });
}
}
await laadAlles();
} catch (e) { setError(e.message); }
}
async function plaatsSelectie(zone) {
const ids = [...selectie];
if (ids.length === 0) return;
const { kind, id } = zone;
const ll = ids.filter(isLeerlingId);
const mw = ids.filter((x) => !isLeerlingId(x));
try {
if (kind === "groep") {
for (const pid of ids) await updateResource("personen", pid, isLeerlingId(pid) ? { groep: id } : { stamgroep: id });
} else if (kind === "leerplein") {
const lp = leerpleinen.find((x) => x.id === id); if (!lp) return;
if (ll.length) await updateResource("leerpleinen", id, { leerlingen: [...new Set([...(lp.leerlingen || []), ...ll])] });
if (mw.length) await updateResource("leerpleinen", id, { medewerkers: [...new Set([...(lp.medewerkers || []), ...mw])] });
} else if (kind === "subgroep") {
const s = subgroepen.find((x) => x.id === id); if (!s) return;
if (ll.length) await updateResource("subgroepen", id, { leden: [...new Set([...s.leden_namen.map((x) => x.id), ...ll])] });
if (mw.length) await updateResource("subgroepen", id, { begeleiders: [...new Set([...s.begeleiders_namen.map((x) => x.id), ...mw])] });
}
wisSelectie();
setPlaatsDoel("");
await laadAlles();
} catch (e) { setError(e.message); }
}
const parseZone = (str) => { const [kind, id] = str.split(":"); return { kind, id: Number(id) }; };
async function haalWeg(container, p) {
const { kind, id } = container;
try {
if (kind === "groep") {
await updateResource("personen", p.id, p.rol === "leerling" ? { groep: null } : { stamgroep: null });
} else if (kind === "leerplein") {
const lp = leerpleinen.find((x) => x.id === id); if (!lp) return;
await updateResource("leerpleinen", id, p.rol === "leerling"
? { leerlingen: (lp.leerlingen || []).filter((x) => x !== p.id) }
: { medewerkers: (lp.medewerkers || []).filter((x) => x !== p.id) });
} else if (kind === "subgroep") {
const s = subgroepen.find((x) => x.id === id); if (!s) return;
await updateResource("subgroepen", id, p.rol === "leerling"
? { leden: s.leden_namen.map((x) => x.id).filter((x) => x !== p.id) }
: { begeleiders: s.begeleiders_namen.map((x) => x.id).filter((x) => x !== p.id) });
}
await laadAlles();
} catch (e) { setError(e.message); }
}
async function nieuweBovengroep(naam) { try { await createResource("leerpleinen", { naam }); await laadAlles(); } catch (e) { setError(e.message); } }
async function nieuweGroep(lp, naam) { try { const g = await createResource("groepen", { naam }); await updateResource("leerpleinen", lp.id, { groepen: [...(lp.groepen || []), g.id] }); await laadAlles(); } catch (e) { setError(e.message); } }
async function nieuweSubgroep(g, naam) { try { await createResource("subgroepen", { naam, groep: g.id }); await laadAlles(); } catch (e) { setError(e.message); } }
async function koppelGroep(g, lpId) { const lp = leerpleinen.find((x) => x.id === lpId); if (!lp) return; try { await updateResource("leerpleinen", lpId, { groepen: [...(lp.groepen || []), g.id] }); await laadAlles(); } catch (e) { setError(e.message); } }
async function hernoem(resource, id, huidig) { const naam = prompt("Nieuwe naam:", huidig); if (naam && naam.trim()) { try { await updateResource(resource, id, { naam: naam.trim() }); await laadAlles(); } catch (e) { setError(e.message); } } }
async function verwijder(resource, id, vraag) { if (!confirm(vraag)) return; try { await deleteResource(resource, id); await laadAlles(); } catch (e) { setError(e.message); } }
const zoneProps = (kind, id) => ({
"data-drop-kind": kind, "data-drop-id": id,
className: "dropzone" + (hoverZone === `${kind}:${id}` ? " over" : ""),
});
const chip = (p, rol, container) => {
if (!p || !toonRol(rol)) return null;
const sel = selectie.has(p.id);
return (
<span
key={(container ? container.kind + container.id : "pool") + "-" + p.id}
className={`chip-persoon ${rol === "leerling" ? "ll" : "mw"}${sel ? " geselecteerd" : ""}`}
onPointerDown={(e) => startDrag(e, { id: p.id, rol, naam: p.volledige_naam })}
title={p.volledige_naam}
>
<button type="button" className="chip-sel" title="(de)selecteer"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleSel(p.id); }}
>{sel ? "☑" : "☐"}</button>
<span className="chip-naam">{p.volledige_naam}</span>
{rol !== "leerling" && SROL[p.rol] && <span className="chip-rol">{SROL[p.rol]}</span>}
{container && (
<button type="button" className="chip-x" title="Uit deze groep halen"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); haalWeg(container, { id: p.id, rol }); }}
>×</button>
)}
</span>
);
};
function subKaart(s) {
const leeg = s.begeleiders_namen.length + s.leden_namen.length === 0;
return (
<div className="subgroep-kaart" key={s.id}>
<div className="kaart-kop sub-kop">
<strong>{s.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("subgroepen", s.id, s.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("subgroepen", s.id, `Subgroep '${s.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("subgroep", s.id)}>
<div className="chip-rij">
{s.begeleiders_namen.map((b) => chip(medewerkerById(b.id) || { id: b.id, volledige_naam: b.naam, rol: "leerkracht" }, "medewerker", { kind: "subgroep", id: s.id }))}
{s.leden_namen.map((l) => chip(leerlingById(l.id) || { id: l.id, volledige_naam: l.naam }, "leerling", { kind: "subgroep", id: s.id }))}
{leeg && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
</div>
);
}
function groepKaart(g) {
const orphan = !groepHeeftLeerplein(g.id);
const mws = groepMedewerkers(g.id);
const lls = groepLeerlingen(g.id);
return (
<div className="groep-kaart" key={g.id}>
<div className="kaart-kop groep-kop">
<strong>{g.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("groepen", g.id, g.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("groepen", g.id, `Groep '${g.naam}' verwijderen? (roosterblokken van deze groep vervallen)`)}>×</button>
</span>
</div>
<div {...zoneProps("groep", g.id)}>
<div className="chip-rij">
{mws.map((m) => chip(m, "medewerker", { kind: "groep", id: g.id }))}
{lls.map((l) => chip(l, "leerling", { kind: "groep", id: g.id }))}
{mws.length + lls.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
{orphan && (
<div className="koppel-rij">
<select value="" onChange={(e) => e.target.value && koppelGroep(g, Number(e.target.value))}>
<option value="">koppel aan bovenliggende groep</option>
{leerpleinen.map((lp) => <option key={lp.id} value={lp.id}>{lp.naam}</option>)}
</select>
</div>
)}
<div className="sub-lijst">
{groepSubs(g.id).map((s) => subKaart(s))}
<InlineAdd klein placeholder="+ subgroep" onAdd={(naam) => nieuweSubgroep(g, naam)} />
</div>
</div>
);
}
const orphanGroepen = groepen.filter((g) => !groepHeeftLeerplein(g.id));
return (
<section className="indeling">
<h1>Groepsindeling</h1>
<p className="muted">Sleep personen naar een bovenliggende groep, groep of subgroep. <span className="legenda ll">leerling</span> en <span className="legenda mw">personeel</span> hebben elk een eigen kleur. Vink meerdere personen aan om ze in één keer te plaatsen; × haalt iemand weer weg.</p>
<Foutmelding msg={error} />
<div className="indeling-toolbar no-print">
<span className="muted small">Tonen:</span>
<div className="segmented">
{[["allen", "Allen"], ["leerling", "Leerlingen"], ["personeel", "Personeel"]].map(([v, l]) => (
<button key={v} type="button" className={toon === v ? "seg on" : "seg"} onClick={() => setToon(v)}>{l}</button>
))}
</div>
</div>
{selectie.size > 0 && (
<div className="selectie-bar no-print">
<strong>{selectie.size}</strong> geselecteerd
<select value={plaatsDoel} onChange={(e) => setPlaatsDoel(e.target.value)}>
<option value=""> plaats in </option>
{containerOpties.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<button className="btn primary klein" disabled={!plaatsDoel} onClick={() => plaatsDoel && plaatsSelectie(parseZone(plaatsDoel))}>Plaats</button>
<button className="btn klein" onClick={wisSelectie}>Wis selectie</button>
</div>
)}
{loading ? <p>Laden</p> : (
<div className="indeling-layout">
<aside className="indeling-pool">
<h3>Nog niet ingedeeld</h3>
{toonRol("leerling") && (
<div className="pool-sectie">
<div className="pool-kop">Leerlingen</div>
<div className="chip-rij">
{losseLeerlingen.map((l) => chip(l, "leerling", null))}
{losseLeerlingen.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
</div>
</div>
)}
{toonRol("medewerker") && (
<div className="pool-sectie">
<div className="pool-kop">Personeel</div>
<div className="chip-rij">
{losseMedewerkers.map((m) => chip(m, "medewerker", null))}
{losseMedewerkers.length === 0 && <span className="muted small"> allemaal ingedeeld </span>}
</div>
</div>
)}
</aside>
<div className="indeling-board">
<InlineAdd placeholder="Nieuwe bovenliggende groep…" onAdd={nieuweBovengroep} />
{leerpleinen.map((lp) => {
const mwIds = lp.medewerkers || [];
const llIds = lp.leerlingen || [];
return (
<div className="bovengroep-kaart" key={lp.id}>
<div className="kaart-kop bovengroep-kop">
<strong>{lp.naam}</strong>
<span className="kaart-acties">
<button className="link small" onClick={() => hernoem("leerpleinen", lp.id, lp.naam)}>naam</button>
<button className="link small danger" onClick={() => verwijder("leerpleinen", lp.id, `Bovenliggende groep '${lp.naam}' verwijderen?`)}>×</button>
</span>
</div>
<div {...zoneProps("leerplein", lp.id)}>
<div className="zone-kop">Direct in deze bovenliggende groep</div>
<div className="chip-rij">
{mwIds.map((id) => chip(medewerkerById(id), "medewerker", { kind: "leerplein", id: lp.id }))}
{llIds.map((id) => chip(leerlingById(id), "leerling", { kind: "leerplein", id: lp.id }))}
{mwIds.length + llIds.length === 0 && <span className="muted small">sleep hier personen heen</span>}
</div>
</div>
<div className="groep-lijst">
{lpGroepen(lp).map((g) => groepKaart(g))}
<InlineAdd klein placeholder="+ groep" onAdd={(naam) => nieuweGroep(lp, naam)} />
</div>
</div>
);
})}
{leerpleinen.length === 0 && <div className="muted">Nog geen bovenliggende groepen. Maak er hierboven één aan.</div>}
{orphanGroepen.length > 0 && (
<div className="bovengroep-kaart los">
<div className="kaart-kop"><strong>Groepen zonder bovenliggende groep</strong></div>
<div className="groep-lijst">
{orphanGroepen.map((g) => groepKaart(g))}
</div>
</div>
)}
</div>
</div>
)}
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section>
);
}
/* ---------------------------------- Stage --------------------------------- */
// Module-scherm: interne en externe stages.
const OMVANGEN = [
{ value: "hele_dag", label: "Hele dag" },
{ value: "dagdeel", label: "Dagdeel" },
{ value: "blok", label: "Eén blok" },
];
const DAGDELEN = [
{ value: "ochtend", label: "Ochtend" },
{ value: "middag", label: "Middag" },
];
export function StagePage() {
const { items, error, loading, reload, setError } = useList("stages");
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [tijdsloten, setTijdsloten] = useState([]);
const leeg = { leerling: "", type: "intern", begeleider: "", lokaal: "", organisatie: "", omvang: "hele_dag", dag: "", dagdeel: "", tijdslot: "", omschrijving: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => {
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => setLokalen([]));
}, []);
const isIntern = form.type === "intern";
const isBlok = form.omvang === "blok";
const isDagdeel = form.omvang === "dagdeel";
async function opslaan(e) {
e.preventDefault();
const payload = {
leerling: form.leerling, type: form.type, begeleider: form.begeleider,
lokaal: isIntern ? (form.lokaal || null) : null,
organisatie: isIntern ? "" : form.organisatie,
omvang: form.omvang,
dag: isBlok ? null : (form.dag === "" ? null : Number(form.dag)),
dagdeel: isDagdeel ? form.dagdeel : "",
tijdslot: isBlok ? (form.tijdslot || null) : null,
omschrijving: form.omschrijving,
};
try {
if (editId) await updateResource("stages", editId, payload);
else await createResource("stages", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze stage verwijderen?")) return;
try { await deleteResource("stages", id); await reload(); } catch (err) { setError(err.message); }
}
const dagLabel = (v) => (WEEKDAGEN.find((d) => d.value === v) || {}).label || "—";
const tijdslotLabel = (id) => { const t = tijdsloten.find((x) => x.id === id); return t ? `${t.dag_label || ""} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; };
function wanneerTekst(s) {
if (s.omvang === "blok") return `Blok: ${s.tijdslot_label || tijdslotLabel(s.tijdslot)}`;
if (s.omvang === "dagdeel") return `${dagLabel(s.dag)}${s.dagdeel_label || s.dagdeel}`;
return `${dagLabel(s.dag)} — hele dag`;
}
return (
<section>
<h1>Stage</h1>
<p className="muted">Interne en externe stages. Beide krijgen een begeleider; een interne stage gebruikt een lokaal (met dubbele-boekingcheck). Stel in per hele dag, dagdeel of los blok.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<select value={form.leerling} required onChange={(e) => setForm({ ...form, leerling: e.target.value })}>
<option value=""> leerling </option>
{leerlingen.map((l) => <option key={l.id} value={l.id}>{l.volledige_naam}</option>)}
</select>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="intern">Interne stage</option>
<option value="extern">Externe stage</option>
</select>
<select value={form.begeleider} required onChange={(e) => setForm({ ...form, begeleider: e.target.value })}>
<option value=""> begeleider </option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</div>
<div className="form-row">
{isIntern ? (
<select value={form.lokaal} required onChange={(e) => setForm({ ...form, lokaal: e.target.value })}>
<option value=""> lokaal </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
) : (
<input placeholder="Externe organisatie" value={form.organisatie} required onChange={(e) => setForm({ ...form, organisatie: e.target.value })} />
)}
</div>
<div className="form-row">
<label className="veld-kol">
<span className="veld-kop">Omvang</span>
<select value={form.omvang} onChange={(e) => setForm({ ...form, omvang: e.target.value })}>
{OMVANGEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
{isBlok ? (
<label className="veld-kol">
<span className="veld-kop">Roosterblok</span>
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> roosterblok </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{tijdslotLabel(t.id)}</option>)}
</select>
</label>
) : (
<label className="veld-kol">
<span className="veld-kop">Dag</span>
<select value={form.dag} required onChange={(e) => setForm({ ...form, dag: e.target.value })}>
<option value=""> dag </option>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
)}
{isDagdeel && (
<label className="veld-kol">
<span className="veld-kop">Dagdeel</span>
<select value={form.dagdeel} required onChange={(e) => setForm({ ...form, dagdeel: e.target.value })}>
<option value=""> kies dagdeel </option>
{DAGDELEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
)}
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Leerling</th><th>Type</th><th>Begeleider</th><th>Waar</th><th>Wanneer</th><th></th></tr></thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>{s.leerling_naam}</td>
<td>{s.type_label}</td>
<td>{s.begeleider_naam}</td>
<td className="muted">{s.type === "intern" ? (s.lokaal_naam || "—") : (s.organisatie || "—")}</td>
<td className="muted">{wanneerTekst(s)}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(s.id); setForm({ leerling: s.leerling, type: s.type, begeleider: s.begeleider, lokaal: s.lokaal || "", organisatie: s.organisatie || "", omvang: s.omvang || "hele_dag", dag: s.dag ?? "", dagdeel: s.dagdeel || "", tijdslot: s.tijdslot || "", omschrijving: s.omschrijving || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="6" className="muted">Nog geen stages.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- Gebruikers ------------------------------ */
const GEBRUIKERSROLLEN = [
{ value: "beheerder", label: "Beheerder" },
{ value: "medewerker", label: "Medewerker" },
{ value: "roostermaker", label: "Roostermaker" },
{ value: "directie", label: "Directie" },
{ value: "zorgcoordinator", label: "Zorgcoordinator" },
{ value: "stagebegeleider", label: "Stagebegeleider" },
{ value: "kijker", label: "Alleen lezen" },
{ value: "ouder", label: "Ouder" },
{ value: "leerling", label: "Leerling" },
];
export function GebruikersPage() {
const { items, error, loading, reload, setError } = useList("gebruikers");
const [personen, setPersonen] = useState([]);
const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const [gegenereerd, setGegenereerd] = useState(null);
const [view, setView] = useState("beheer");
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
function kiesPersoon(id) {
const pp = personen.find((x) => x.id === Number(id));
setForm((f) => {
const nf = { ...f, persoon: id };
if (pp) {
if (!f.first_name) nf.first_name = pp.voornaam || "";
if (!f.last_name) nf.last_name = pp.achternaam || "";
if (!f.username) nf.username = `${pp.voornaam || ""}.${pp.achternaam || ""}`.toLowerCase().replace(/\s+/g, "");
}
return nf;
});
}
async function genereerWachtwoord() {
setError(null); setGegenereerd(null);
if (editId) {
try { const r = await adminWachtwoordReset(editId); setGegenereerd(r.wachtwoord); }
catch (e) { setError(e.message); }
} else {
const tekens = "abcdefghjkmnpqrstuvwxyz23456789";
const bytes = (window.crypto || {}).getRandomValues ? window.crypto.getRandomValues(new Uint8Array(10)) : Array.from({ length: 10 }, () => Math.floor(Math.random() * 256));
const w = Array.from(bytes).map((b) => tekens[b % tekens.length]).join("");
setForm((f) => ({ ...f, password: w }));
setGegenereerd(w);
}
}
async function opslaan(e) {
e.preventDefault();
const payload = {
username: form.username, first_name: form.first_name, last_name: form.last_name,
email: form.email, rol: form.rol, persoon: form.persoon || null,
is_staff: form.is_staff, is_active: form.is_active,
};
if (form.password) payload.password = form.password;
try {
if (editId) await updateResource("gebruikers", editId, payload);
else await createResource("gebruikers", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Deze gebruiker verwijderen?")) return;
try { await deleteResource("gebruikers", id); await reload(); } catch (err) { setError(err.message); }
}
function bewerk(u) {
setEditId(u.id);
setGegenereerd(null);
setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active });
}
return (
<section>
<h1>Gebruikers</h1>
<p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
<Foutmelding msg={error} />
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> geef dit door aan de gebruiker; laat het daarna wijzigen.</div>}
<div className="tabbar">
<button className={view === "beheer" ? "tab on" : "tab"} onClick={() => setView("beheer")}><span className="ico">🔑</span><span className="tab-tekst">Beheer</span></button>
<button className={view === "import" ? "tab on" : "tab"} onClick={() => setView("import")}><span className="ico">📋</span><span className="tab-tekst">Bulk import</span></button>
</div>
{view === "import" ? <GebruikersImportWizard personen={personen} onKlaar={reload} /> : (<>
<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="text" placeholder={editId ? "Wachtwoord (leeg = ongewijzigd)" : "Wachtwoord"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
<button type="button" className="btn" onClick={genereerWachtwoord} title="Genereer een tijdelijk wachtwoord">Genereer</button>
<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) => kiesPersoon(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)}`; }
const MAANDEN = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"];
function isoWeeknr(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dag = (t.getUTCDay() + 6) % 7;
t.setUTCDate(t.getUTCDate() - dag + 3);
const eersteDo = new Date(Date.UTC(t.getUTCFullYear(), 0, 4));
return 1 + Math.round(((t - eersteDo) / 86400000 - 3 + ((eersteDo.getUTCDay() + 6) % 7)) / 7);
}
function weekTitel(maandag) {
const vrij = plusDagen(maandag, 4);
const reeks = maandag.getMonth() === vrij.getMonth()
? `${maandag.getDate()}\u2013${vrij.getDate()} ${MAANDEN[vrij.getMonth()]}`
: `${maandag.getDate()} ${MAANDEN[maandag.getMonth()]} \u2013 ${vrij.getDate()} ${MAANDEN[vrij.getMonth()]}`;
return `Week ${isoWeeknr(maandag)} \u00b7 ${reeks}`;
}
function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; }
function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
/* ------------------------- Rooster-scope (filter/print) ------------------- */
// Welke leerlingen raakt een blok (via groep, subgroep of individueel)?
export function leerlingenVanBlok(blok, ctx) {
if (blok.groep) return ctx.leerlingen.filter((l) => l.groep === blok.groep).map((l) => l.id);
if (blok.subgroep) {
const s = ctx.subgroepen.find((x) => x.id === blok.subgroep);
return s ? s.leden_namen.map((m) => m.id) : [];
}
if (blok.leerling) return [blok.leerling];
return [];
}
// Welke leerlingen vallen binnen een gekozen scope (klas/bovenliggende groep/leerling)?
export function scopeLeerlingen(scope, ctx) {
if (scope.type === "leerling") return [scope.id];
if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id);
if (scope.type === "leerplein") {
const p = ctx.leerpleinen.find((x) => x.id === scope.id);
if (!p) return [];
const ids = new Set(p.leerlingen || []);
(p.groepen || []).forEach((g) => ctx.leerlingen.filter((l) => l.groep === g).forEach((l) => ids.add(l.id)));
(p.subgroepen || []).forEach((sid) => {
const s = ctx.subgroepen.find((x) => x.id === sid);
if (s) s.leden_namen.forEach((m) => ids.add(m.id));
});
return [...ids];
}
return [];
}
// Valt een blok binnen de gekozen scope?
export function blokInScope(blok, scope, ctx) {
if (scope.type === "alles" || !scope.id) return true;
if (scope.type === "personeel") return (blok.begeleiders || []).includes(scope.id);
const set = new Set(scopeLeerlingen(scope, ctx));
return leerlingenVanBlok(blok, ctx).some((id) => set.has(id));
}
// Bereken conflicten direct uit de geladen blokken (zelfde tijdslot): begeleider
// dubbel, lokaal dubbel of een leerling dubbel ingeroosterd. Houdt rekening met
// geldigheidsperiodes (blokken die elkaar niet in de tijd overlappen botsen niet).
// Leesbare tekstkleur (zwart of wit) op een gegeven achtergrondkleur, op basis
// van de waargenomen helderheid — zo blijft de tekst altijd genoeg contrast houden.
export function tekstOpKleur(hex) {
if (!hex || typeof hex !== "string") return "";
const m = hex.replace("#", "").trim();
const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
if (v.length < 6) return "";
const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16);
if ([r, g, b].some(Number.isNaN)) return "";
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? "#16181d" : "#ffffff";
}
export function berekenRoosterConflicten(blokken, ctx, tijdsloten) {
const blokIds = new Set();
const teksten = new Map(); // tekst -> ernst (dedupe)
const slotLabel = (id) => {
const t = (tijdsloten || []).find((x) => x.id === id);
if (!t) return "";
const dag = (WEEKDAGEN.find((d) => d.value === t.dag) || {}).label || "";
return `${dag} ${(t.start_tijd || "").slice(0, 5)}`.trim();
};
const leerlingNaam = (id) => {
const l = (ctx.leerlingen || []).find((x) => x.id === id);
return l ? l.volledige_naam : "Leerling";
};
const overlapt = (a, b) => {
const at = a.geldig_tot || null, av = a.geldig_van || null;
const bt = b.geldig_tot || null, bv = b.geldig_van || null;
if (at && bv && at < bv) return false;
if (bt && av && bt < av) return false;
return true;
};
const perSlot = new Map();
blokken.forEach((b) => {
if (!perSlot.has(b.tijdslot)) perSlot.set(b.tijdslot, []);
perSlot.get(b.tijdslot).push(b);
});
perSlot.forEach((lijst, slotId) => {
if (lijst.length < 2) return;
const lab = slotLabel(slotId);
const flag = (a, b, tekst, ernst) => {
if (!overlapt(a, b)) return;
blokIds.add(a.id);
blokIds.add(b.id);
if (!teksten.has(tekst)) teksten.set(tekst, ernst);
};
for (let i = 0; i < lijst.length; i++) {
for (let j = i + 1; j < lijst.length; j++) {
const a = lijst[i], b = lijst[j];
(a.begeleiders_namen || []).forEach((p) => {
if ((b.begeleiders_namen || []).some((q) => q.id === p.id)) {
flag(a, b, `${p.naam} staat dubbel ingepland — ${lab}`, "fout");
}
});
if (a.lokaal && b.lokaal && a.lokaal === b.lokaal) {
flag(a, b, `Lokaal ${a.lokaal_naam} dubbel geboekt — ${lab}`, "waarschuwing");
}
const lb = new Set(leerlingenVanBlok(b, ctx));
leerlingenVanBlok(a, ctx)
.filter((id) => lb.has(id))
.forEach((id) => flag(a, b, `${leerlingNaam(id)} is dubbel ingeroosterd — ${lab}`, "fout"));
}
}
});
const meldingen = [...teksten.entries()]
.map(([tekst, ernst]) => ({ tekst, ernst }))
.sort((x, y) => (x.ernst === y.ernst ? 0 : x.ernst === "fout" ? -1 : 1));
return { blokIds, meldingen };
}
function groepVanSubgroep(subgroepen, subgroepId) {
const sub = subgroepen.find((s) => s.id === subgroepId);
return sub ? sub.groep : null;
}
function bovenliggendeGroep(blok, subgroepen) {
if (blok.groep) return blok.groep;
if (blok.subgroep) return groepVanSubgroep(subgroepen, blok.subgroep);
return null;
}
/* ----------------------------- Blok bewerken/kopiëren --------------------- */
function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
const { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx;
const [f, setF] = useState({
tijdslot: blok.tijdslot,
activiteit: blok.activiteit,
locatie: blok.locatie || "",
lokaal: blok.lokaal || "",
doel_type: blok.doel_type,
doel: blok.groep || blok.subgroep || blok.leerling || "",
begeleiders: [...(blok.begeleiders || [])],
opmerkingen: blok.opmerkingen || "",
geldig_van: blok.geldig_van || "",
geldig_tot: blok.geldig_tot || "",
});
const [kopieDoelen, setKopieDoelen] = useState([]);
const [error, setError] = useState(null);
const [subs, setSubs] = useState(subgroepen);
const [nieuweSub, setNieuweSub] = useState("");
const [splitSubs, setSplitSubs] = useState([]);
const [splitNieuw, setSplitNieuw] = useState("");
const [verwijderOrig, setVerwijderOrig] = useState(true);
const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => (
blok.subgroep ? groepVanSubgroep(subgroepen, blok.subgroep) || "" : ""
));
const [splitGroepId, setSplitGroepId] = useState(() => String(
blok.groep || groepVanSubgroep(subgroepen, blok.subgroep)
|| (blok.leerling ? ((leerlingen.find((l) => l.id === blok.leerling) || {}).groep || "") : "") || ""
));
const gekozenGroep = f.doel_type === "groep"
? (f.doel ? Number(f.doel) : null)
: f.doel_type === "subgroep"
? (subgroepGroepFilter || groepVanSubgroep(subs, Number(f.doel)))
: bovenliggendeGroep(blok, subs);
const subgroepOpties = gekozenGroep ? subs.filter((s) => s.groep === gekozenGroep) : subs;
const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepOpties : leerlingen;
const splitGroepNum = splitGroepId ? Number(splitGroepId) : null;
const splitOpties = splitGroepNum ? subs.filter((s) => s.groep === splitGroepNum) : [];
const splitGroepNaam = splitGroepNum ? (groepen.find((g) => g.id === splitGroepNum)?.naam || "groep") : "(kies groep)";
async function maakSubgroep() {
if (!nieuweSub.trim()) return;
const g = subgroepGroepFilter || gekozenGroep;
if (!g) { setError("Kies eerst een groep voor de nieuwe subgroep."); return; }
try {
const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: Number(g) });
setSubs([...subs, s]);
setSubgroepGroepFilter(s.groep || "");
setF({ ...f, doel_type: "subgroep", doel: s.id });
setNieuweSub("");
} catch (e) { setError(e.message); }
}
const toggleSplit = (id) => setSplitSubs((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
async function maakSplitSub() {
if (!splitNieuw.trim()) return;
if (!splitGroepNum) { setError("Kies eerst een groep om onder te splitsen."); return; }
try {
const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroepNum });
setSubs([...subs, s]);
setSplitSubs([...splitSubs, s.id]);
setSplitNieuw("");
} catch (e) { setError(e.message); }
}
async function opdelen() {
if (splitSubs.length === 0) return;
try {
for (const sid of splitSubs) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null });
}
if (verwijderOrig) await deleteResource("roosterblokken", blok.id);
await onKlaar();
} catch (e) { setError(e.message); }
}
const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] });
const toggleKopie = (id) => setKopieDoelen((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
const doelPayload = () => ({
groep: f.doel_type === "groep" ? f.doel : null,
subgroep: f.doel_type === "subgroep" ? f.doel : null,
leerling: f.doel_type === "leerling" ? f.doel : null,
});
async function opslaan() {
try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); await onKlaar(); }
catch (e) { setError(e.message); }
}
async function kopieer() {
try {
for (const ts of kopieDoelen) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null });
}
await onKlaar();
} catch (e) { setError(e.message); }
}
async function verwijderen() {
if (!confirm("Dit blok (alle weken) verwijderen?")) return;
try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); }
}
return (
<div className="modal-overlay no-print" onClick={onSluit}>
<div className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Roosterblok bewerken" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>{blok.activiteit_naam || "Roosterblok"}</h2>
<div className="muted small">{blok.doel_naam || "Bewerken en splitsen"}</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={onSluit}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Vak bewerken</strong>
<span className="muted small">Wijzig, splits of kopieer dit blok</span>
</div>
<Foutmelding msg={error} />
<div className="editor-grid">
<select value={f.tijdslot} title="Tijdslot" onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
</select>
<select value={f.activiteit} title="Activiteit" onChange={(e) => setF({ ...f, activiteit: e.target.value })}>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
<select value={f.locatie} title="Locatie" onChange={(e) => setF({ ...f, locatie: e.target.value })}>
<option value=""> locatie </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={f.lokaal} title="Lokaal" onChange={(e) => setF({ ...f, lokaal: e.target.value })}>
<option value=""> lokaal </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
<input type="date" title="Geldig vanaf (leeg = altijd)" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} />
<input type="date" title="Geldig t/m (leeg = altijd)" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} />
</div>
<details className="editor-detail">
<summary>Doelgroep en begeleiding</summary>
<div className="editor-grid">
<select value={f.doel_type} onChange={(e) => {
setF({ ...f, doel_type: e.target.value, doel: "" });
if (e.target.value !== "subgroep") setSubgroepGroepFilter("");
}}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
<option value=""> kies {f.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
{f.doel_type === "subgroep" && (
<>
<select value={subgroepGroepFilter || ""} onChange={(e) => { setSubgroepGroepFilter(e.target.value ? Number(e.target.value) : ""); setF({ ...f, doel: "", doel_type: "subgroep" }); }}>
<option value=""> alle groepen </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<input placeholder="Nieuwe subgroep" value={nieuweSub} onChange={(e) => setNieuweSub(e.target.value)} />
<button type="button" className="btn icon-label" onClick={maakSubgroep} title="Subgroep aanmaken"> <span>Subgroep</span></button>
</>
)}
</div>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)} title={m.rol_label || "Begeleider"}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
</div>
</details>
<div className="editor-actions">
<button type="button" className="btn primary icon-label" onClick={opslaan} title="Opslaan"> <span>Opslaan</span></button>
<button type="button" className="btn icon-label" onClick={verwijderen} title="Verwijderen">🗑 <span>Verwijderen</span></button>
</div>
<details className="editor-detail split-editor" open>
<summary>Splitsen onder {splitGroepNaam}</summary>
<div className="editor-grid">
<select value={splitGroepId} onChange={(e) => { setSplitGroepId(e.target.value); setSplitSubs([]); }} title="Onder welke groep splitsen">
<option value=""> kies groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</div>
<div className="chips compact">
{splitOpties.map((s) => <button type="button" key={s.id} className={splitSubs.includes(s.id) ? "chip on" : "chip"} onClick={() => toggleSplit(s.id)}>{s.naam}</button>)}
{splitOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
</div>
{splitSubs.length > 0 && (
<div className="split-preview">
<div className="split-preview-root">{splitGroepNaam}</div>
<div className="split-preview-children">
{splitSubs.map((sid) => {
const sub = splitOpties.find((s) => s.id === sid) || subs.find((s) => s.id === sid);
return <span key={sid}>{sub ? sub.naam : "Subgroep"}</span>;
})}
</div>
</div>
)}
<div className="editor-grid">
<input placeholder="Nieuwe subgroep" value={splitNieuw} onChange={(e) => setSplitNieuw(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); maakSplitSub(); } }} />
<button type="button" className="btn icon-label" onClick={maakSplitSub} title="Subgroep toevoegen"> <span>Subgroep</span></button>
</div>
<label className="veld editor-check"><input type="checkbox" checked={verwijderOrig} onChange={(e) => setVerwijderOrig(e.target.checked)} /> oorspronkelijke blok verwijderen</label>
<button type="button" className="btn primary icon-label" disabled={splitSubs.length === 0} onClick={opdelen} title="Opdelen"> <span>{splitSubs.length}</span></button>
</details>
<details className="editor-detail">
<summary>Kopiëren</summary>
<div className="chips compact">
{tijdsloten.map((t) => <button type="button" key={t.id} className={kopieDoelen.includes(t.id) ? "chip on" : "chip"} onClick={() => toggleKopie(t.id)}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</button>)}
</div>
<button type="button" className="btn icon-label" disabled={kopieDoelen.length === 0} onClick={kopieer} title="Kopiëren"> <span>{kopieDoelen.length}</span></button>
</details>
</div>
</div>
</div>
);
}
/* ------------------------------- Groepsindeling --------------------------- */
// Verdeel een hoofdgroep (of bovenliggende groep) kolomsgewijs in subgroepen: per kolom
// de subgroep met haar verantwoordelijke(n) en de vakken die er gegeven worden.
function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken, onWijzig }) {
const [type, setType] = useState("groep");
const [hoofdId, setHoofdId] = useState("");
const [nieuw, setNieuw] = useState({ naam: "", groep: "", begeleiders: [] });
const [error, setError] = useState(null);
const opties = type === "groep" ? groepen : leerpleinen;
const hid = hoofdId ? Number(hoofdId) : null;
const hoofd = opties.find((o) => o.id === hid);
let subs = [];
if (hid && type === "groep") subs = subgroepen.filter((s) => s.groep === hid);
if (hid && type === "leerplein" && hoofd) subs = subgroepen.filter((s) => (hoofd.subgroepen || []).includes(s.id));
const leerpleinGroepen = type === "leerplein" && hoofd
? groepen.filter((g) => (hoofd.groepen || []).includes(g.id))
: [];
const groepenMetSubgroepen = type === "leerplein"
? leerpleinGroepen.map((g) => ({ groep: g, subs: subgroepen.filter((s) => s.groep === g.id) }))
: [];
const vakkenVan = (subId) => [...new Set(blokken.filter((b) => b.subgroep === subId).map((b) => b.activiteit_naam))];
const toggleBeg = (id) => setNieuw((n) => n.begeleiders.includes(id) ? { ...n, begeleiders: n.begeleiders.filter((x) => x !== id) } : { ...n, begeleiders: [...n.begeleiders, id] });
async function voegToe(e) {
e.preventDefault();
try {
await createResource("subgroepen", { naam: nieuw.naam, groep: type === "groep" ? hid : nieuw.groep, begeleiders: nieuw.begeleiders });
setNieuw({ naam: "", groep: "", begeleiders: [] });
await onWijzig();
} catch (err) { setError(err.message); }
}
async function verwijderSub(id) {
if (!confirm("Deze subgroep verwijderen?")) return;
try { await deleteResource("subgroepen", id); await onWijzig(); } catch (err) { setError(err.message); }
}
return (
<div>
<p className="muted">Kies een hoofdgroep of bovenliggende groep en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.</p>
<Foutmelding msg={error} />
<div className="rooster-balk">
<label className="veld">Type
<select value={type} onChange={(e) => { setType(e.target.value); setHoofdId(""); setNieuw({ naam: "", groep: "", begeleiders: [] }); }}>
<option value="groep">Groep</option>
<option value="leerplein">{BOVENGROEP_LABEL}</option>
</select>
</label>
<select value={hoofdId} onChange={(e) => setHoofdId(e.target.value)}>
<option value=""> kies {type} </option>
{opties.map((o) => <option key={o.id} value={o.id}>{o.naam}</option>)}
</select>
</div>
{hid && hoofd && (
<div className="indeling">
<div className="indeling-hoofd">{hoofd.naam} <span className="muted small"> {type}</span></div>
{type === "leerplein" ? (
<div className="indeling-groepen">
{groepenMetSubgroepen.map(({ groep, subs: groepSubs }) => (
<div className="indeling-groep" key={groep.id}>
<div className="indeling-groep-kop">{groep.naam}</div>
<div className="indeling-kolommen">
{groepSubs.map((s) => (
<div className="indeling-kolom" key={s.id}>
<div className="indeling-kop">{s.naam}</div>
<div className="small">Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}</div>
<div className="small">Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}</div>
<div className="small muted">{s.aantal_leden} leerling(en)</div>
<button className="link danger small" onClick={() => verwijderSub(s.id)}>verwijderen</button>
</div>
))}
{groepSubs.length === 0 && <div className="muted small">Nog geen subgroepen onder {groep.naam}.</div>}
</div>
</div>
))}
{leerpleinGroepen.length === 0 && <div className="muted">Nog geen groepen onder {hoofd.naam}.</div>}
</div>
) : (
<div className="indeling-kolommen">
{subs.map((s) => (
<div className="indeling-kolom" key={s.id}>
<div className="indeling-kop">{s.naam}</div>
<div className="small">Verantwoordelijke: {s.begeleiders_namen && s.begeleiders_namen.length ? s.begeleiders_namen.map((x) => x.naam).join(", ") : "geen"}</div>
<div className="small">Vakken: {vakkenVan(s.id).join(", ") || "nog geen vakken"}</div>
<div className="small muted">{s.aantal_leden} leerling(en)</div>
<button className="link danger small" onClick={() => verwijderSub(s.id)}>verwijderen</button>
</div>
))}
{subs.length === 0 && <div className="muted">Nog geen subgroepen onder {hoofd.naam}.</div>}
</div>
)}
<form className="form-card" onSubmit={voegToe}>
<div className="muted small">Subgroep toevoegen onder {type === "groep" ? hoofd.naam : `een groep binnen deze ${BOVENGROEP_LABEL_LC}`}:</div>
<div className="form-row">
{type === "leerplein" && (
<select value={nieuw.groep || ""} required onChange={(e) => setNieuw({ ...nieuw, groep: e.target.value })}>
<option value=""> groep </option>
{leerpleinGroepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
)}
<input placeholder="Naam subgroep" value={nieuw.naam} required onChange={(e) => setNieuw({ ...nieuw, naam: e.target.value })} />
<button className="btn primary" type="submit">+ Subgroep</button>
</div>
<div className="leden-keuze">
<div className="muted small">Verantwoordelijke(n):</div>
<div className="chips">
{medewerkers.map((m) => <button type="button" key={m.id} className={nieuw.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}</button>)}
</div>
</div>
</form>
</div>
)}
</div>
);
}
/* ---------------------------------- Rooster ------------------------------- */
// Eén component met twee modi:
// - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen)
// - mode="edit" -> Roostermaker (volledige bewerking + drag-and-drop)
export function RoosterScherm({ mode }) {
const edit = mode === "edit";
const [printAan, setPrintAan] = useState(false);
const [blokwizardAan, setBlokwizardAan] = useState(false);
const [autoroosterAan, setAutoroosterAan] = useState(false);
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [locaties, setLocaties] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [blokken, setBlokken] = useState([]);
const [kalender, setKalender] = useState([]);
const [afwezig, setAfwezig] = useState([]);
const [uitzonderingen, setUitzonderingen] = useState([]);
const [stages, setStages] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false);
const [tab, setTab] = useState("week");
const [error, setError] = useState(null);
// Scope-filter (ook voor gericht afdrukken): per klas/bovenliggende groep/leerling/personeel.
const [scopeType, setScopeType] = useState("alles");
const [scopeId, setScopeId] = useState("");
// Blok bewerken/kopiëren (alleen in de Roostermaker).
const [bewerkBlok, setBewerkBlok] = useState(null);
const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "", geldig_van: "", geldig_tot: "" };
const [form, setForm] = useState(leeg);
const [splitNieuwBlok, setSplitNieuwBlok] = useState(false);
const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]);
// Drag-and-drop via pointer events (werkt op muis én touch).
const dragRef = useRef(null);
const laadRef = useRef(() => {});
const [ghost, setGhost] = useState(null);
const [hoverSlot, setHoverSlot] = useState(null);
const [conflictOpen, setConflictOpen] = useState(false);
const [genOpen, setGenOpen] = useState(false);
const [weergave, setWeergaveState] = useState(() => { try { return localStorage.getItem("rw_weergave") || "kolom"; } catch { return "kolom"; } });
const setWeergave = (v) => { setWeergaveState(v); try { localStorage.setItem("rw_weergave", v); } catch { /* niet beschikbaar */ } };
const [vakkleurVlak, setVakkleurState] = useState(() => { try { return localStorage.getItem("rw_vakkleur") === "vlak"; } catch { return false; } });
const setVakkleur = (v) => { setVakkleurState(v); try { localStorage.setItem("rw_vakkleur", v ? "vlak" : "streep"); } catch { /* niet beschikbaar */ } };
const [detailsOpen, setDetailsOpenState] = useState(() => { try { return localStorage.getItem("rw_rooster_details") === "1"; } catch { return false; } });
const setDetailsOpen = (v) => { setDetailsOpenState(v); try { localStorage.setItem("rw_rooster_details", v ? "1" : "0"); } catch { /* niet beschikbaar */ } };
// Printopties
const [printOpen, setPrintOpen] = useState(false);
const [printPending, setPrintPending] = useState(false);
const [printAllesActief, setPrintAllesActief] = useState(false);
const [printKop, setPrintKop] = useState("");
const [printLegenda, setPrintLegenda] = useState(false);
const [printOpts, setPrintOpts] = useState({ bereik: "huidig", indeling: "kolom", kleur: false, begeleiding: true, lokaal: true, legenda: false, paginaPerDag: false, papier: "A4", orientatie: "landscape" });
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("locaties").then(setLocaties).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
listResource("stages", "?actief=1").then(setStages).catch(() => setStages([]));
// Afdrukken alleen tonen als de print-module aanstaat.
getModules().then((ms) => {
setPrintAan(ms.some((m) => m.key === "printing" && m.enabled));
setBlokwizardAan(ms.some((m) => m.key === "blokwizard" && m.enabled));
setAutoroosterAan(ms.some((m) => m.key === "autorooster" && m.enabled));
}).catch(() => {});
}, []);
// Scope-context + gefilterde blokken (voor weergave/afdrukken).
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
// Bij printen "alle klassen" tijdelijk de scope negeren.
const effScope = printAllesActief ? { type: "alles", id: "" } : scope;
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, effScope, scopeCtx));
// Distinct vakken (voor de optionele kleurenlegenda bij het afdrukken).
const legendaItems = (() => {
const seen = new Set(); const out = [];
zichtbareBlokken.forEach((b) => { if (!seen.has(b.activiteit_naam)) { seen.add(b.activiteit_naam); out.push({ naam: b.activiteit_naam, kleur: b.activiteit_kleur, icoon: b.activiteit_icoon }); } });
return out.sort((a, x) => a.naam.localeCompare(x.naam));
})();
// Conflicten over álle blokken (niet alleen de zichtbare scope), zodat de
// melding compleet is en de betrokken blokken in het rooster omlijnd worden.
const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten);
const scopeOpties = scopeType === "klas" ? groepen
: scopeType === "leerplein" ? leerpleinen
: scopeType === "leerling" ? leerlingen
: scopeType === "personeel" ? medewerkers
: [];
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const vanISO = fmtISO(maandag);
const totISO = fmtISO(plusDagen(maandag, 6));
async function laadBlokken() {
if (!schooljaar) { setBlokken([]); return; }
try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`));
setKalender(await listResource("kalenderdagen", `?schooljaar=${schooljaar}`));
setError(null);
} catch (e) { setError(e.message); }
}
async function laadWeek() {
if (!schooljaar) return;
try {
setAfwezig(await listResource("afwezigheden", `?van=${vanISO}&tot=${totISO}`));
setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`));
} catch (e) { setError(e.message); }
}
async function herlaadNaBlokWijziging() {
try { setSubgroepen(await listResource("subgroepen")); } catch { /* blokken verversen blijft leidend */ }
await laadBlokken();
}
laadRef.current = laadBlokken;
useEffect(() => { laadBlokken(); }, [schooljaar]);
useEffect(() => { laadWeek(); }, [schooljaar, vanISO]);
// Listeners voor drag-and-drop, één keer opgezet.
useEffect(() => {
function move(e) {
if (!dragRef.current) return;
setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.activiteit_naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
setHoverSlot(cell ? Number(cell.dataset.tijdslot) : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
const doel = cell ? Number(cell.dataset.tijdslot) : null;
const blok = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverSlot(null);
if (cell && cell.dataset.vrij) { setError("Op een vrije/vakantiedag kun je geen blok plaatsen."); return; }
if (doel && doel !== blok.tijdslot) {
try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); }
catch (err) { setError(err.message); }
}
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
}, []);
// Sneltoetsen (alleen in de Roostermaker).
useEffect(() => {
if (!edit) return;
function onKey(e) {
const t = e.target;
const typing = t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable);
if (e.key === "Escape") { setBewerkBlok(null); sluitNieuwBlok(); return; }
if (typing) return;
if (e.key === "n" || e.key === "N") { setTab("week"); setBewerkBlok(null); setToonForm((v) => { if (v) resetNieuweSplit(); return !v; }); }
else if (e.key === "ArrowLeft") { setMaandag((m) => plusDagen(m, -7)); }
else if (e.key === "ArrowRight") { setMaandag((m) => plusDagen(m, 7)); }
else if (e.key === "t" || e.key === "T") { setMaandag(maandagVan(new Date())); }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [edit]);
function startDrag(e, blok) {
e.preventDefault();
dragRef.current = blok;
setGhost({ x: e.clientX, y: e.clientY, label: blok.activiteit_naam });
setHoverSlot(blok.tijdslot);
}
function resetNieuweSplit() {
setSplitNieuwBlok(false);
setSplitNieuwDoelen([]);
}
function openNieuwBlok() {
setBewerkBlok(null);
setToonForm(true);
}
// Print: pas de gekozen opties toe via body-classes + @page, dan afdrukken.
function zetPaginaStijl(papier, orientatie) {
let el = document.getElementById("rw-print-page");
if (!el) { el = document.createElement("style"); el.id = "rw-print-page"; document.head.appendChild(el); }
el.textContent = `@page { size: ${papier} ${orientatie}; margin: 10mm; }`;
}
function startPrint() {
const b = document.body.classList;
b.toggle("print-lijst", printOpts.indeling === "lijst");
b.toggle("print-kolom", printOpts.indeling === "kolom");
b.toggle("print-kleur", !!printOpts.kleur);
b.toggle("print-geen-begeleiding", !printOpts.begeleiding);
b.toggle("print-geen-lokaal", !printOpts.lokaal);
b.toggle("print-paginadag", !!printOpts.paginaPerDag);
zetPaginaStijl(printOpts.papier, printOpts.orientatie);
setPrintKop(printOpts.kop || "");
setPrintLegenda(!!printOpts.legenda);
if (printOpts.bereik === "alles") setPrintAllesActief(true);
setPrintOpen(false);
setPrintPending(true);
}
useEffect(() => {
if (!printPending) return;
const id = setTimeout(() => {
window.print();
setPrintPending(false);
setPrintAllesActief(false);
}, 120);
return () => clearTimeout(id);
}, [printPending]);
// Direct vanuit een tijdvak in het rooster een vak toevoegen: tijdslot vast,
// en het doel alvast op de actieve scope (klas/leerling) zetten.
function openNieuwBlokVoorSlot(tijdslotId) {
setBewerkBlok(null);
const basis = { ...leeg, tijdslot: String(tijdslotId) };
if (scopeType === "klas" && scopeId) { basis.doel_type = "groep"; basis.doel = String(scopeId); }
else if (scopeType === "leerling" && scopeId) { basis.doel_type = "leerling"; basis.doel = String(scopeId); }
setForm(basis);
resetNieuweSplit();
setToonForm(true);
}
function sluitNieuwBlok() {
setToonForm(false);
resetNieuweSplit();
}
function toggleNieuwSplitDoel(id) {
setSplitNieuwDoelen((doelen) => doelen.includes(id) ? doelen.filter((x) => x !== id) : [...doelen, id]);
}
const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen;
const splitNieuweOpties = form.doel_type === "groep" && form.doel
? subgroepen.filter((s) => s.groep === Number(form.doel))
: [];
const splitNieuweGroep = groepen.find((g) => g.id === Number(form.doel));
function toggleBegeleider(id) {
setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] });
}
async function opslaan(e) {
e.preventDefault();
const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen, geldig_van: form.geldig_van || null, geldig_tot: form.geldig_tot || null };
try {
if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) {
for (const subgroep of splitNieuwDoelen) {
await createResource("roosterblokken", { ...basis, groep: null, subgroep, leerling: null });
}
} else {
await createResource("roosterblokken", {
...basis,
groep: form.doel_type === "groep" ? form.doel : null,
subgroep: form.doel_type === "subgroep" ? form.doel : null,
leerling: form.doel_type === "leerling" ? form.doel : null,
});
}
setForm({ ...leeg, doel_type: form.doel_type });
sluitNieuwBlok();
await herlaadNaBlokWijziging();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit roosterblok (alle weken) verwijderen?")) return;
try { await deleteResource("roosterblokken", id); await laadBlokken(); } catch (err) { setError(err.message); }
}
async function laatVervallen(blok, iso) {
try { await createResource("blok-uitzonderingen", { roosterblok: blok.id, datum: iso, vervalt: true }); await laadWeek(); }
catch (err) { setError(err.message); }
}
async function herstel(uitId) {
try { await deleteResource("blok-uitzonderingen", uitId); await laadWeek(); } catch (err) { setError(err.message); }
}
async function dagVervallen(iso) {
try { await createResource("kalenderdagen", { schooljaar, datum: iso, type: "vrije_dag", omschrijving: "vervallen" }); await laadBlokken(); }
catch (err) { setError(err.message); }
}
async function dagHerstel(kalId) {
try { await deleteResource("kalenderdagen", kalId); await laadBlokken(); }
catch (err) { setError(err.message); }
}
// Geldt dit blok op deze datum (periode / eenmalig)?
function blokGeldigOp(b, iso) {
if (b.geldig_van && iso < b.geldig_van) return false;
if (b.geldig_tot && iso > b.geldig_tot) return false;
return true;
}
// Maak één specifieke dag leeg: laat alle blokken die dag vervallen (alleen
// deze datum), zonder gevolgen voor andere dagen.
async function leegDag(iso) {
const ourWd = (new Date(iso).getDay() + 6) % 7;
const dagBlokken = blokken.filter((b) => {
const t = tijdsloten.find((x) => x.id === b.tijdslot);
if (!t || t.dag !== ourWd || !blokGeldigOp(b, iso)) return false;
const u = uitzOp(b.id, iso);
return !(u && u.vervalt);
});
if (dagBlokken.length === 0) { setError("Geen blokken om te laten vervallen op deze dag."); return; }
if (!confirm(`Alle ${dagBlokken.length} blok(ken) op ${iso} laten vervallen? Dit geldt alleen voor deze dag.`)) return;
try {
for (const b of dagBlokken) await createResource("blok-uitzonderingen", { roosterblok: b.id, datum: iso, vervalt: true });
await laadWeek();
} catch (err) { setError(err.message); }
}
// Voeg een eenmalig (alleen vandaag geldig) blok toe op deze dag.
function openEenmalig(iso) {
setBewerkBlok(null);
setForm({ ...leeg, geldig_van: iso, geldig_tot: iso });
resetNieuweSplit();
setToonForm(true);
}
// Hulpfuncties voor het effectieve rooster.
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
// Op id vergelijken, niet op naam: twee personen kunnen dezelfde naam hebben.
const persoonAfwezig = (id, iso) => afwezig.some((a) => a.persoon === id && a.start_datum <= iso && a.eind_datum >= iso);
const rolAfk = (id) => { const m = medewerkers.find((x) => x.id === id); return m ? (ROL_AFK[m.rol] || "") : ""; };
// Stage: welke leerlingen zijn op een gegeven tijdslot op stage (en dus niet in de klas)?
const tijdslotById = (id) => tijdsloten.find((t) => t.id === id);
const leerlingNaam = (id) => { const l = leerlingen.find((x) => x.id === id); return l ? l.volledige_naam : "?"; };
function stageDektTijdslot(stage, t) {
if (!t) return false;
if (stage.omvang === "blok") return stage.tijdslot === t.id;
if (stage.dag === null || stage.dag === undefined || stage.dag !== t.dag) return false;
if (stage.omvang === "hele_dag") return true;
const isOchtend = (t.start_tijd || "") < "12:00";
return (stage.dagdeel === "ochtend" && isOchtend) || (stage.dagdeel === "middag" && !isOchtend);
}
function leerlingenOpStageBij(t) {
const set = new Set();
for (const st of stages) { if (stageDektTijdslot(st, t)) set.add(st.leerling); }
return set;
}
function stageNoot(b) {
const t = tijdslotById(b.tijdslot);
const opStage = leerlingenOpStageBij(t);
if (opStage.size === 0) return null;
if (b.doel_type === "leerling") {
return opStage.has(b.leerling) ? <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));
// Eén uniform vak-kaartje, gebruikt in álle situaties (los vak of vak per
// subgroep) zodat de weergave overal hetzelfde oogt.
function renderVakKaart(b, iso, kal) {
const uit = uitzOp(b.id, iso);
const vervallen = !!kal || (uit && uit.vervalt);
return (
<div
className={"vak-kaart" + (vervallen ? " vervallen" : "") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "") + (conflictInfo.blokIds.has(b.id) ? " in-conflict" : "") + (vakkleurVlak ? " kleur-vlak" : "")}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur, "--vk-kleur": b.activiteit_kleur, "--vk-tekst": tekstOpKleur(b.activiteit_kleur) }}
onClick={edit ? () => toggleOpenBlok(b) : undefined}
title={edit ? "Klik om te bewerken" : undefined}
>
<div className="vk-kop">
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot"></span>}
{b.activiteit_pictogram && <img src={b.activiteit_pictogram} alt="" className="rb-picto" />}
<strong className="vk-naam">{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>
{(edit || detailsOpen) && (b.locatie_naam || b.lokaal_naam || b.begeleiders_namen.length > 0) && (
<div className="vk-meta">
{b.locatie_naam && <span className="vk-tag t-loc">📍 {b.locatie_naam}</span>}
{b.lokaal_naam && <span className="vk-tag t-lok">🚪 {b.lokaal_naam}</span>}
{b.begeleiders_namen.length > 0 && (
<span className="vk-tag t-beg">👤 {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>
))}</span>
)}
</div>
)}
{(edit || detailsOpen) && 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>
);
}
// Klas-/doelgroepkop boven de vakken: altijd duidelijk in beeld.
const doelIcoon = (t) => (t === "leerling" ? "🧒" : t === "leerplein" ? "🏫" : "👥");
const klasKop = (naam, sub, count, icoon) => (
<div className="split-header">
<span className="split-icon">{icoon}</span>
<div className="split-title"><strong>{naam}</strong><span>{sub}</span></div>
{count != null && <span className="split-count">{count}</span>}
</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 doelen (individuele leerling e.d.): per doel een klas-kop + vakken.
const losPerDoel = new Map();
losse.forEach((b) => {
const k = `${b.doel_type}:${b.doel_naam}`;
if (!losPerDoel.has(k)) losPerDoel.set(k, []);
losPerDoel.get(k).push(b);
});
losPerDoel.forEach((blks, k) => {
const eerste = blks[0];
out.push(
<div className="blok-splitsing enkel" key={`los-${k}`}>
{klasKop(eerste.doel_naam, eerste.doel_type, blks.length > 1 ? blks.length : null, doelIcoon(eerste.doel_type))}
<div className="split-vakken">{blks.map((b) => renderVakKaart(b, iso, kal))}</div>
</div>
);
});
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}`}>
{klasKop(leerplein ? leerplein.naam : BOVENGROEP_LABEL, `${BOVENGROEP_LABEL} → groepen`, kolommen.length, "🏫")}
<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) => renderVakKaart(b, iso, kal))}</div>
</div>
);
})}
</div>
</div>
);
return out;
}
perGroep.forEach((groepBlokken, gid) => {
const groep = groepen.find((g) => g.id === gid);
const groepNaam = groep ? groep.naam : "Groep";
const heeftSplitsing = groepBlokken.some((b) => b.subgroep);
if (!heeftSplitsing) {
out.push(
<div className="blok-splitsing enkel" key={`groep-${gid}`}>
{klasKop(groepNaam, "groep", groepBlokken.length > 1 ? groepBlokken.length : null, "👥")}
<div className="split-vakken">{groepBlokken.map((b) => renderVakKaart(b, iso, kal))}</div>
</div>
);
return;
}
const heleGroep = groepBlokken.filter((b) => b.groep === gid);
const subIds = [...new Set(groepBlokken.filter((b) => b.subgroep).map((b) => b.subgroep))];
const kolomAantal = subIds.length + (heleGroep.length > 0 ? 1 : 0);
out.push(
<div className="blok-splitsing" key={`split-${gid}`}>
{klasKop(groepNaam, "groep → subgroepen", kolomAantal, "👥")}
<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) => renderVakKaart(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) => renderVakKaart(b, iso, kal))}</div>
</div>
);
})}
</div>
</div>
);
});
return out;
}
return (
<section className={"rooster-scherm " + (edit ? "rooster-edit " : "rooster-view ") + (detailsOpen ? "details-open" : "details-compact")}>
<div className="print-only print-kop">
<RoosterLogo print />
<div className="print-kop-tekst">
<strong>{printKop || (edit ? "Roostermaker" : "Rooster")}</strong>
<span>{(schooljaren.find((j) => String(j.id) === String(schooljaar)) || {}).naam || ""}{schooljaar ? " · " : ""}{schooljaar ? weekTitel(maandag) : ""} · afgedrukt {fmtDM(new Date())}</span>
</div>
</div>
<div className="rooster-kop app-page-kop">
<div>
<h1>{edit ? "Roostermaker" : "Rooster"}</h1>
<div className="page-subtitle no-print">
{schooljaar ? `${weekTitel(maandag)} · ${zichtbareBlokken.length} blokken zichtbaar` : "Kies een schooljaar"}
{conflictInfo.meldingen.length > 0 ? ` · ${conflictInfo.meldingen.length} conflicten` : ""}
</div>
</div>
<RoosterLogo />
</div>
<Foutmelding msg={error} />
<div className="rooster-balk rooster-toolbar 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 icon" title="Vorige week" aria-label="Vorige week" onClick={() => setMaandag(plusDagen(maandag, -7))}></button>
<span className="week-label">{weekTitel(maandag)}</span>
<button className="btn icon" title="Volgende week" aria-label="Volgende week" onClick={() => setMaandag(plusDagen(maandag, 7))}></button>
<button className="link small" title="Naar deze week" onClick={() => setMaandag(maandagVan(new Date()))}>vandaag</button>
</div>
)}
{schooljaar && (edit ? tab === "week" : true) && (
<div className="weergave-opties no-print">
<button type="button" className={weergave === "lijst" ? "icon-toggle on" : "icon-toggle"} aria-pressed={weergave === "lijst"} title={weergave === "kolom" ? "Dagen onder elkaar tonen" : "Dagen als kolommen tonen"} onClick={() => setWeergave(weergave === "kolom" ? "lijst" : "kolom")}>{weergave === "kolom" ? "☰" : "▦"}</button>
<button type="button" className={vakkleurVlak ? "icon-toggle on" : "icon-toggle"} aria-pressed={vakkleurVlak} title={vakkleurVlak ? "Vakkleur als streepje tonen" : "Vak volledig in vakkleur tonen"} onClick={() => setVakkleur(!vakkleurVlak)}>🎨</button>
<button type="button" className={detailsOpen ? "icon-toggle on" : "icon-toggle"} aria-pressed={detailsOpen} title={detailsOpen ? "Minder details tonen" : "Meer details tonen"} onClick={() => setDetailsOpen(!detailsOpen)}></button>
{printAan && <button type="button" className="icon-toggle" title="Afdrukken (met opties)" onClick={() => setPrintOpen(true)}>🖨</button>}
</div>
)}
</div>
{schooljaar && (edit ? tab === "week" : true) && (
<div className="rooster-balk no-print scope-balk">
{(groepen.length > 0 || leerpleinen.length > 0) && (
<div className="scope-rij">
<span className="scope-label">Toon</span>
<div className="klas-strip">
<button type="button" className={scopeType === "alles" ? "chip on" : "chip"} onClick={() => { setScopeType("alles"); setScopeId(""); }}>Alle</button>
{groepen.map((g) => (
<button type="button" key={"g" + g.id} className={scopeType === "klas" && Number(scopeId) === g.id ? "chip on" : "chip"} onClick={() => { setScopeType("klas"); setScopeId(String(g.id)); }}>{g.naam}</button>
))}
{leerpleinen.map((lp) => (
<button type="button" key={"lp" + lp.id} className={scopeType === "leerplein" && Number(scopeId) === lp.id ? "chip on chip-plein" : "chip chip-plein"} onClick={() => { setScopeType("leerplein"); setScopeId(String(lp.id)); }}>{lp.naam}</button>
))}
</div>
</div>
)}
<div className="scope-rij">
<span className="scope-label">of één persoon</span>
<select
className={(scopeType === "leerling" || scopeType === "personeel") ? "scope-persoon on" : "scope-persoon"}
value={scopeType === "leerling" ? "l" + scopeId : scopeType === "personeel" ? "p" + scopeId : ""}
onChange={(e) => {
const v = e.target.value;
if (!v) { setScopeType("alles"); setScopeId(""); }
else if (v[0] === "l") { setScopeType("leerling"); setScopeId(v.slice(1)); }
else { setScopeType("personeel"); setScopeId(v.slice(1)); }
}}
>
<option value="">👤 kies een leerling of medewerker</option>
{leerlingen.length > 0 && (
<optgroup label="Leerlingen">
{leerlingen.map((l) => <option key={"l" + l.id} value={"l" + l.id}>{l.volledige_naam}</option>)}
</optgroup>
)}
{medewerkers.length > 0 && (
<optgroup label="Personeel">
{medewerkers.map((m) => <option key={"p" + m.id} value={"p" + m.id}>{m.volledige_naam}</option>)}
</optgroup>
)}
</select>
{scopeType !== "alles" && scopeId && <span className="muted small scope-aantal">{zichtbareBlokken.length} blok(ken)</span>}
</div>
</div>
)}
{edit && (
<div className="tabbar no-print">
<button className={tab === "week" ? "tab on" : "tab"} title="Weekrooster" onClick={() => setTab("week")}><span className="ico"></span><span className="tab-tekst">Week</span></button>
{(blokwizardAan || autoroosterAan) && (
<div className="tab-dropdown">
<button className={(tab === "wizard" || tab === "auto") ? "tab on" : "tab"} title="Rooster genereren" onClick={() => setGenOpen((v) => !v)}><span className="ico"></span><span className="tab-tekst">Genereren </span></button>
{genOpen && (
<div className="tab-menu" onMouseLeave={() => setGenOpen(false)}>
{blokwizardAan && <button type="button" className="tab-menu-item" onClick={() => { setTab("wizard"); setGenOpen(false); }}> Vak-wizard</button>}
{autoroosterAan && <button type="button" className="tab-menu-item" onClick={() => { setTab("auto"); setGenOpen(false); }}>🪄 Auto-rooster</button>}
</div>
)}
</div>
)}
<button className={tab === "instellingen" ? "tab on" : "tab"} title="Instellingen" onClick={() => setTab("instellingen")}><span className="ico"></span><span className="tab-tekst">Instellingen</span></button>
</div>
)}
{edit && tab === "week" && <div className="muted small no-print sneltoets-hint">N nieuw · pijltjes week · T vandaag · Esc sluiten</div>}
{edit && schooljaar && tab === "week" && toonForm && (
<div className="modal-overlay no-print" onClick={sluitNieuwBlok}>
<form className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Vak toevoegen" onSubmit={opslaan} onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>Vak toevoegen</h2>
<div className="muted small">Maak één blok of splits direct over subgroepen.</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={sluitNieuwBlok}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Basis</strong>
<span className="muted small">Tijd, vak en doel</span>
</div>
<div className="editor-grid">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
</select>
<select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}>
<option value=""> activiteit </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
<select value={form.locatie} onChange={(e) => setForm({ ...form, locatie: e.target.value })}>
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={form.lokaal} onChange={(e) => setForm({ ...form, lokaal: e.target.value })}>
<option value=""> lokaal (optioneel) </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
</div>
<div className="form-row periode-row">
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={form.geldig_van} onChange={(e) => setForm({ ...form, geldig_van: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={form.geldig_tot} onChange={(e) => setForm({ ...form, geldig_tot: e.target.value })} /></label>
{form.geldig_van && form.geldig_van === form.geldig_tot && <span className="muted small periode-hint">eenmalig op {form.geldig_van}</span>}
{(form.geldig_van || form.geldig_tot) && <button type="button" className="link small" onClick={() => setForm({ ...form, geldig_van: "", geldig_tot: "" })}>wis periode</button>}
</div>
<details className="editor-detail" open>
<summary>Doelgroep</summary>
<div className="editor-grid">
<select value={form.doel_type} onChange={(e) => { setForm({ ...form, doel_type: e.target.value, doel: "" }); resetNieuweSplit(); }}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={form.doel} required onChange={(e) => { setForm({ ...form, doel: e.target.value }); resetNieuweSplit(); }}>
<option value=""> kies {form.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
</details>
{form.doel_type === "groep" && form.doel && (
<details className="editor-detail split-editor" open>
<summary>Groep direct splitsen</summary>
<label className="veld editor-check">
<input type="checkbox" checked={splitNieuwBlok} onChange={(e) => { setSplitNieuwBlok(e.target.checked); if (!e.target.checked) setSplitNieuwDoelen([]); }} />
blok onder subgroepen plaatsen
</label>
{splitNieuwBlok && (
<>
<div className="chips compact">
{splitNieuweOpties.map((s) => <button type="button" key={s.id} className={splitNieuwDoelen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggleNieuwSplitDoel(s.id)}>{s.naam}</button>)}
{splitNieuweOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
</div>
{splitNieuwDoelen.length > 0 && (
<div className="split-preview">
<div className="split-preview-root">{splitNieuweGroep?.naam || "Groep"}</div>
<div className="split-preview-children">
{splitNieuwDoelen.map((sid) => <span key={sid}>{splitNieuweOpties.find((s) => s.id === sid)?.naam || "Subgroep"}</span>)}
</div>
</div>
)}
</>
)}
</details>
)}
<details className="editor-detail" open>
<summary>Begeleiders</summary>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div>
</details>
<div className="editor-actions">
<button className="btn primary icon-label" type="submit" disabled={splitNieuwBlok && splitNieuwDoelen.length === 0}> <span>{splitNieuwBlok ? `${splitNieuwDoelen.length} blok(ken)` : "Vak toevoegen"}</span></button>
<button type="button" className="btn" onClick={sluitNieuwBlok}>Annuleren</button>
</div>
</div>
</form>
</div>
)}
{schooljaar && tab === "week" && conflictInfo.meldingen.length > 0 && (
<div className="conflict-banner no-print">
<button type="button" className="conflict-kop" onClick={() => setConflictOpen((v) => !v)}>
<span className="ico"></span>
<span className="conflict-aantal">{conflictInfo.meldingen.length} conflict{conflictInfo.meldingen.length === 1 ? "" : "en"} in dit rooster</span>
<span className="muted small">{conflictOpen ? "▲ verberg" : "▼ toon details"}</span>
</button>
{conflictOpen && (
<ul className="conflict-lijst">
{conflictInfo.meldingen.map((m, i) => (
<li key={i} className={m.ernst === "fout" ? "fout" : "waarschuwing"}>{m.tekst}</li>
))}
</ul>
)}
</div>
)}
{schooljaar && tab === "week" && (
<div className={weergave === "lijst" ? "rooster-grid lijst" : "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 && (
<span className="dag-acties no-print">
{kal ? (
<button type="button" className="icon-btn" title="Weer lesdag maken" onClick={() => dagHerstel(kal.id)}></button>
) : (
<>
<button type="button" className="icon-btn danger" title="Hele dag laten vervallen" onClick={() => dagVervallen(iso)}></button>
<button type="button" className="icon-btn" title="Dag leegmaken" onClick={() => leegDag(iso)}></button>
<button type="button" className="icon-btn" title="Eenmalig blok toevoegen" onClick={() => openEenmalig(iso)}></button>
</>
)}
</span>
)}
</div>
{slots.length === 0 && <div className="muted small"></div>}
{slots.map((t) => {
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso));
return (
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id}
data-vrij={kal ? "1" : undefined}
key={t.id}
>
<div className="slot-tijd">{t.start_tijd?.slice(0, 5)} {t.naam}</div>
{renderSlotBlokken(slotBlokken, iso, kal)}
{edit && !kal && (
<button type="button" className="slot-add no-print" title="Vak toevoegen in dit tijdvak" onClick={() => openNieuwBlokVoorSlot(t.id)}> vak</button>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
{schooljaar && tab === "week" && printLegenda && legendaItems.length > 0 && (
<div className="print-only kleur-legenda">
<strong>Legenda</strong>
<div className="legenda-items">
{legendaItems.map((v) => (
<span className="legenda-item" key={v.naam}><span className="legenda-swatch" style={{ background: v.kleur }} />{v.icoon || vakIcoon(v.naam)} {v.naam}</span>
))}
</div>
</div>
)}
{edit && tab === "wizard" && blokwizardAan && <VakWizardPage />}
{edit && tab === "auto" && autoroosterAan && <AutoRoosterPage />}
{tab === "instellingen" && (
<div className="instellingen instellingen-secties">
<p className="muted">Beheer hier alle bouwstenen van het rooster. Klik een onderdeel open om het te bewerken.</p>
<details className="inst-sectie" open>
<summary><span className="ico">📅</span> Schooljaar &amp; kalender</summary>
<div className="inst-body"><p className="muted small">Maak een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).</p><SchooljaarPage /></div>
</details>
<details className="inst-sectie">
<summary><span className="ico">🕒</span> Tijdvakken</summary>
<div className="inst-body"><p className="muted small">De dag- en tijdindeling waarop vakken geplaatst kunnen worden.</p><TijdslotenPage /></div>
</details>
<details className="inst-sectie">
<summary><span className="ico">📚</span> Vakken &amp; activiteiten</summary>
<div className="inst-body"><p className="muted small">De vakken/activiteiten met kleur en icoon.</p><ActiviteitenPage /></div>
</details>
<details className="inst-sectie">
<summary><span className="ico">🚪</span> Lokalen</summary>
<div className="inst-body"><p className="muted small">Ruimtes met capaciteit; meetellen in de dubbele-boekingcheck.</p><LokalenPage /></div>
</details>
<details className="inst-sectie">
<summary><span className="ico">📍</span> Locaties</summary>
<div className="inst-body"><p className="muted small">Vrije locatie-aanduidingen (bv. gymzaal, zwembad).</p><LocatiesPage /></div>
</details>
<details className="inst-sectie">
<summary><span className="ico">🖼</span> Vormgeving</summary>
<div className="inst-body"><HuisstijlPage /></div>
</details>
</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>}
{printOpen && (
<div className="modal-overlay no-print" onClick={() => setPrintOpen(false)}>
<div className="modal" role="dialog" aria-modal="true" aria-label="Printopties" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div><h2>Afdrukken</h2><div className="muted small">Kies wat en hoe je afdrukt.</div></div>
<button type="button" className="icon-btn" title="Sluiten" onClick={() => setPrintOpen(false)}>×</button>
</div>
<div className="editor-grid">
<label className="veld-kol"><span className="veld-kop">Wat afdrukken</span>
<select value={printOpts.bereik} onChange={(e) => setPrintOpts({ ...printOpts, bereik: e.target.value })}>
<option value="huidig">Huidige selectie</option>
<option value="alles">Alle klassen</option>
</select>
</label>
<label className="veld-kol"><span className="veld-kop">Indeling</span>
<select value={printOpts.indeling} onChange={(e) => setPrintOpts({ ...printOpts, indeling: e.target.value })}>
<option value="kolom">Dagen als kolommen</option>
<option value="lijst">Dagen onder elkaar</option>
</select>
</label>
<label className="veld-kol"><span className="veld-kop">Papierformaat</span>
<select value={printOpts.papier} onChange={(e) => setPrintOpts({ ...printOpts, papier: e.target.value })}>
<option value="A4">A4</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
<option value="Letter">Letter</option>
</select>
</label>
<label className="veld-kol"><span className="veld-kop">Oriëntatie</span>
<select value={printOpts.orientatie} onChange={(e) => setPrintOpts({ ...printOpts, orientatie: e.target.value })}>
<option value="landscape">Liggend</option>
<option value="portrait">Staand</option>
</select>
</label>
</div>
<label className="veld-kol"><span className="veld-kop">Koptekst (optioneel)</span><input placeholder="bv. Weekrooster groep A1" value={printOpts.kop || ""} onChange={(e) => setPrintOpts({ ...printOpts, kop: e.target.value })} /></label>
<div className="check-grid">
<label className="check-item"><input type="checkbox" checked={printOpts.kleur} onChange={(e) => setPrintOpts({ ...printOpts, kleur: e.target.checked })} /><span>Vakken in kleur</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.begeleiding} onChange={(e) => setPrintOpts({ ...printOpts, begeleiding: e.target.checked })} /><span>Begeleiding tonen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.lokaal} onChange={(e) => setPrintOpts({ ...printOpts, lokaal: e.target.checked })} /><span>Lokaal/locatie tonen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.legenda} onChange={(e) => setPrintOpts({ ...printOpts, legenda: e.target.checked })} /><span>Kleurenlegenda toevoegen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.paginaPerDag} onChange={(e) => setPrintOpts({ ...printOpts, paginaPerDag: e.target.checked })} /><span>Elke dag op nieuwe pagina</span></label>
</div>
<div className="editor-actions">
<button type="button" className="btn primary" onClick={startPrint}>Afdrukken</button>
<button type="button" className="btn" onClick={() => setPrintOpen(false)}>Annuleren</button>
</div>
</div>
</div>
)}
</section>
);
}
// Twee schermen op basis van dezelfde component.
export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; }
export function RoostermakerPage() { return <RoosterScherm mode="edit" />; }
/* --------------- Perioderooster: alle klassen in één matrix --------------- */
// Bouwt een weekopzet voor alle klassen (nog zonder periode/kalender-koppeling):
// per klas vakken in elk tijdvak, met directe conflictsignalering over alle
// klassen heen. Bij voltooien wordt een periode gekozen en ingeladen.
export function PeriodeRoosterPage() {
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [blokken, setBlokken] = useState([]);
const [error, setError] = useState(null);
const [cel, setCel] = useState(null); // {tijdslot, groep}
const [form, setForm] = useState({ activiteit: "", begeleiders: [], lokaal: "" });
const [bezig, setBezig] = useState(false);
// Voltooien → periode kiezen/aanmaken + preview.
const [periodes, setPeriodes] = useState([]);
const [voltooiOpen, setVoltooiOpen] = useState(false);
const [periodeKeuze, setPeriodeKeuze] = useState("_new"); // id of "_new"
const [nieuwP, setNieuwP] = useState({ start_datum: "", eind_datum: "" });
const [preview, setPreview] = useState(null); // {toevoegen:[], verwijderen:[]}
const [applyBezig, setApplyBezig] = useState(false);
const [klaarMelding, setKlaarMelding] = useState(null);
const [wisInfo, setWisInfo] = useState(null); // {van, tot, blokken}
const [vakkleurVlak, setVakkleurState] = useState(() => { try { return localStorage.getItem("rw_vakkleur") === "vlak"; } catch { return false; } });
const setVakkleur = (v) => { setVakkleurState(v); try { localStorage.setItem("rw_vakkleur", v ? "vlak" : "streep"); } catch { /* niet beschikbaar */ } };
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
}, []);
useEffect(() => { laadBlokken(); laadPeriodes(); }, [schooljaar]); // eslint-disable-line react-hooks/exhaustive-deps
async function laadBlokken() {
if (!schooljaar) { setBlokken([]); return; }
try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}&sjabloon=1`));
setError(null);
} catch (e) { setError(e.message); }
}
async function laadPeriodes() {
if (!schooljaar) { setPeriodes([]); return; }
try { setPeriodes(await listResource("periodes", `?schooljaar=${schooljaar}`)); } catch { setPeriodes([]); }
}
const ctx = { leerlingen, subgroepen, leerpleinen };
const conflictInfo = berekenRoosterConflicten(blokken, ctx, tijdsloten);
const algemeneSloten = tijdsloten.filter((t) => !t.groep && t.actief !== false).sort((a, b) => a.dag - b.dag || a.volgorde - b.volgorde);
const perDag = WEEKDAGEN.map((d) => ({ dag: d, sloten: algemeneSloten.filter((t) => t.dag === d.value) })).filter((r) => r.sloten.length > 0);
const blokkenVan = (slotId, groepId) => blokken.filter((b) => b.tijdslot === slotId && bovenliggendeGroep(b, subgroepen) === groepId);
const groepNaam = (id) => { const g = groepen.find((x) => x.id === id); return g ? g.naam : "?"; };
function openCel(slotId, groepId) { setForm({ activiteit: "", begeleiders: [], lokaal: "" }); setCel({ tijdslot: slotId, groep: groepId }); }
function toggleBeg(id) { setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] }); }
async function voegToe(e) {
e.preventDefault();
if (!cel || !form.activiteit) return;
setBezig(true);
try {
await createResource("roosterblokken", { schooljaar, tijdslot: cel.tijdslot, activiteit: form.activiteit, groep: cel.groep, subgroep: null, leerling: null, begeleiders: form.begeleiders, lokaal: form.lokaal || null, locatie: null, opmerkingen: "", geldig_van: null, geldig_tot: null, sjabloon: true });
setCel(null);
setKlaarMelding(null);
await laadBlokken();
} catch (err) { setError(err.message); }
setBezig(false);
}
async function verwijder(id) {
try { await deleteResource("roosterblokken", id); setKlaarMelding(null); await laadBlokken(); } catch (e) { setError(e.message); }
}
// ---- Voltooien: periode kiezen/aanmaken, preview en toepassen ----
const doelKey = (b) => `${b.tijdslot}|${b.groep || ""}|${b.subgroep || ""}|${b.leerling || ""}`;
const overlaptPeriode = (b, van, tot) => {
const bv = b.geldig_van || null, bt = b.geldig_tot || null;
if (bt && bt < van) return false;
if (bv && bv > tot) return false;
return true;
};
function gekozenPeriodeDatums() {
if (periodeKeuze === "_new") return { van: nieuwP.start_datum, tot: nieuwP.eind_datum };
const p = periodes.find((x) => String(x.id) === String(periodeKeuze));
return p ? { van: p.start_datum, tot: p.eind_datum } : { van: "", tot: "" };
}
function openVoltooien() {
const volgend = (periodes.reduce((m, p) => Math.max(m, p.nummer), 0) || 0) + 1;
setPeriodeKeuze(periodes.length ? String(periodes[0].id) : "_new");
setNieuwP({ start_datum: "", eind_datum: "", nummer: volgend });
setPreview(null);
setWisInfo(null);
setKlaarMelding(null);
setVoltooiOpen(true);
}
async function maakPreview() {
const { van, tot } = gekozenPeriodeDatums();
if (!van || !tot) { setError("Kies of vul een periode met begin- en einddatum."); return; }
try {
const live = await listResource("roosterblokken", `?schooljaar=${schooljaar}`); // sjabloon=False
const keys = new Set(blokken.map(doelKey));
const overlappend = live.filter((b) => overlaptPeriode(b, van, tot));
const verwijderen = overlappend.filter((b) => keys.has(doelKey(b)));
const behouden = overlappend.filter((b) => !keys.has(doelKey(b)));
// Resultaat na toepassen = behouden bestaande blokken + nieuwe periodeblokken
// (met de periodedatums). Zelfde conflictcheck als het Rooster.
const nieuwe = blokken.map((b) => ({ ...b, geldig_van: van, geldig_tot: tot }));
const resultaat = [...behouden, ...nieuwe];
const resConflict = berekenRoosterConflicten(resultaat, ctx, tijdsloten);
setPreview({ toevoegen: blokken, verwijderen, conflicten: resConflict.meldingen, van, tot });
setError(null);
} catch (e) { setError(e.message); }
}
async function bevestigVoltooien() {
if (!preview) return;
setApplyBezig(true);
try {
let periodeId = periodeKeuze;
if (periodeKeuze === "_new") {
const p = await createResource("periodes", { schooljaar, nummer: nieuwP.nummer, start_datum: preview.van, eind_datum: preview.tot });
periodeId = p.id;
}
for (const b of preview.verwijderen) {
await deleteResource("roosterblokken", b.id);
}
for (const b of preview.toevoegen) {
await createResource("roosterblokken", {
schooljaar, tijdslot: b.tijdslot, activiteit: b.activiteit,
groep: b.groep, subgroep: b.subgroep, leerling: b.leerling,
begeleiders: (b.begeleiders_namen || []).map((x) => x.id),
lokaal: b.lokaal || null, locatie: b.locatie || null, opmerkingen: b.opmerkingen || "",
geldig_van: preview.van, geldig_tot: preview.tot, sjabloon: false,
});
}
await laadPeriodes();
setVoltooiOpen(false);
setPreview(null);
setKlaarMelding(`Perioderooster ingeladen (${preview.toevoegen.length} vak(ken) geplaatst, ${preview.verwijderen.length} overlappend verwijderd). De weekopzet blijft als sjabloon bewaard.`);
} catch (e) { setError(e.message); }
setApplyBezig(false);
}
// ---- Hele periode uit het echte rooster wissen ----
async function wisPeriodeControle() {
const { van, tot } = gekozenPeriodeDatums();
if (!van || !tot) { setError("Kies een periode om te wissen."); return; }
try {
const live = await listResource("roosterblokken", `?schooljaar=${schooljaar}`); // sjabloon=False
// Alleen periode-gebonden blokken die binnen deze periode vallen; vaste
// (altijd geldige) blokken zonder datum blijven staan.
const teWissen = live.filter((b) => b.geldig_van && b.geldig_tot && b.geldig_van >= van && b.geldig_tot <= tot);
setPreview(null);
setWisInfo({ van, tot, blokken: teWissen });
setError(null);
} catch (e) { setError(e.message); }
}
async function bevestigWis() {
if (!wisInfo) return;
setApplyBezig(true);
try {
for (const b of wisInfo.blokken) {
await deleteResource("roosterblokken", b.id);
}
const n = wisInfo.blokken.length;
setWisInfo(null);
setVoltooiOpen(false);
setKlaarMelding(`Periode geleegd: ${n} blok(ken) uit het rooster verwijderd. Het sjabloon blijft bewaard.`);
} catch (e) { setError(e.message); }
setApplyBezig(false);
}
return (
<section className="weekmaker">
<div className="rooster-kop"><h1>Perioderooster</h1><RoosterLogo /></div>
<p className="muted no-print">Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij <b>voltooien</b> kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.</p>
<Foutmelding msg={error} />
<div className="rooster-balk no-print">
<label className="veld">Schooljaar
<select value={schooljaar} onChange={(e) => { setSchooljaar(e.target.value); setKlaarMelding(null); }}>
<option value=""> kies </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
</label>
<span className={conflictInfo.meldingen.length ? "wm-status fout" : "wm-status ok"}>{conflictInfo.meldingen.length ? `${conflictInfo.meldingen.length} conflict(en)` : "✓ geen conflicten"}</span>
<div className="weergave-opties no-print">
<button type="button" className={vakkleurVlak ? "icon-toggle on" : "icon-toggle"} aria-pressed={vakkleurVlak} title={vakkleurVlak ? "Vakkleur als streepje tonen" : "Vak volledig in vakkleur tonen"} onClick={() => setVakkleur(!vakkleurVlak)}>🎨</button>
</div>
<button className="btn primary" disabled={!schooljaar || blokken.length === 0} onClick={openVoltooien}>Voltooien &amp; periode inladen</button>
</div>
{klaarMelding && <div className="banner ok">{klaarMelding}</div>}
{conflictInfo.meldingen.length > 0 && (
<ul className="conflict-lijst wm-conflicten no-print">
{conflictInfo.meldingen.map((m, i) => <li key={i} className={m.ernst === "fout" ? "fout" : "waarschuwing"}>{m.tekst}</li>)}
</ul>
)}
{!schooljaar && <p className="muted">Kies eerst een schooljaar.</p>}
{schooljaar && groepen.length === 0 && <p className="muted">Maak eerst groepen (klassen) aan onder Organisatie.</p>}
{schooljaar && groepen.length > 0 && perDag.length === 0 && <p className="muted">Maak eerst algemene tijdvakken aan (Instellingen Tijdvakken).</p>}
{schooljaar && groepen.length > 0 && perDag.length > 0 && (
<div className="wm-scroll">
<table className="wm-tabel">
<thead>
<tr>
<th className="wm-hoek">Tijdvak</th>
{groepen.map((g) => <th key={g.id} className="wm-klas">{g.naam}</th>)}
</tr>
</thead>
<tbody>
{perDag.map((rij) => (
<Fragment key={rij.dag.value}>
<tr className="wm-dagrij"><td colSpan={groepen.length + 1}>{rij.dag.label}</td></tr>
{rij.sloten.map((t) => (
<tr key={t.id}>
<th className="wm-tijd">{t.start_tijd?.slice(0, 5)}<span className="muted small"> {t.naam}</span></th>
{groepen.map((g) => {
const cellblk = blokkenVan(t.id, g.id);
return (
<td key={g.id} className="wm-cel">
{cellblk.map((b) => (
<div key={b.id} className={"wm-vak" + (conflictInfo.blokIds.has(b.id) ? " in-conflict" : "") + (vakkleurVlak ? " kleur-vlak" : "")} style={vakkleurVlak ? { borderLeftColor: b.activiteit_kleur, background: b.activiteit_kleur, color: tekstOpKleur(b.activiteit_kleur) } : { borderLeftColor: b.activiteit_kleur }}>
<div className="wm-vak-kop">
<span className="wm-vak-naam">{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</span>
<button type="button" className="icon-btn danger" title="Verwijderen" onClick={() => verwijder(b.id)}>×</button>
</div>
{b.subgroep && <div className="muted small">{b.doel_naam}</div>}
{(b.lokaal_naam || b.begeleiders_namen.length > 0) && (
<div className="wm-vak-meta">{[b.lokaal_naam, ...b.begeleiders_namen.map((x) => x.naam)].filter(Boolean).join(" · ")}</div>
)}
</div>
))}
<button type="button" className="wm-add no-print" title="Vak toevoegen" onClick={() => openCel(t.id, g.id)}></button>
</td>
);
})}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
)}
{voltooiOpen && (
<div className="modal-overlay no-print" onClick={() => setVoltooiOpen(false)}>
<div className="modal" role="dialog" aria-modal="true" aria-label="Perioderooster voltooien" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div><h2>Voltooien &amp; periode inladen</h2><div className="muted small">De weekopzet wordt als rooster geplaatst in de gekozen periode. De opzet blijft als sjabloon bewaard.</div></div>
<button type="button" className="icon-btn" title="Sluiten" onClick={() => setVoltooiOpen(false)}>×</button>
</div>
{conflictInfo.meldingen.length > 0 && <div className="banner waarschuwing">Let op: er zijn nog {conflictInfo.meldingen.length} conflict(en) in de opzet. Je kunt toch inladen, maar los ze bij voorkeur eerst op.</div>}
<div className="editor-grid">
<label className="veld-kol"><span className="veld-kop">Periode</span>
<select value={periodeKeuze} onChange={(e) => { setPeriodeKeuze(e.target.value); setPreview(null); setWisInfo(null); }}>
{periodes.map((p) => <option key={p.id} value={p.id}>{p.naam} ({p.start_datum} {p.eind_datum})</option>)}
<option value="_new">+ nieuwe periode aanmaken</option>
</select>
</label>
{periodeKeuze === "_new" && (
<>
<label className="veld-kol"><span className="veld-kop">Periodenummer</span><input type="number" min="1" value={nieuwP.nummer || ""} onChange={(e) => { setNieuwP({ ...nieuwP, nummer: Number(e.target.value) }); setPreview(null); setWisInfo(null); }} /></label>
<label className="veld-kol"><span className="veld-kop">Van</span><input type="date" value={nieuwP.start_datum} onChange={(e) => { setNieuwP({ ...nieuwP, start_datum: e.target.value }); setPreview(null); setWisInfo(null); }} /></label>
<label className="veld-kol"><span className="veld-kop">T/m</span><input type="date" value={nieuwP.eind_datum} onChange={(e) => { setNieuwP({ ...nieuwP, eind_datum: e.target.value }); setPreview(null); setWisInfo(null); }} /></label>
</>
)}
</div>
{!preview && !wisInfo && (
<div className="editor-actions">
<button type="button" className="btn primary" onClick={maakPreview}>Controleren &amp; inladen</button>
{periodeKeuze !== "_new" && <button type="button" className="btn danger" onClick={wisPeriodeControle}>Deze periode wissen</button>}
<button type="button" className="btn" onClick={() => setVoltooiOpen(false)}>Annuleren</button>
</div>
)}
{wisInfo && (
<>
<div className="wm-preview">
<p className="fout"><b>{wisInfo.blokken.length}</b> periode-blok(ken) ({wisInfo.van} {wisInfo.tot}) worden uit het rooster verwijderd. Vaste (altijd geldige) blokken blijven staan.</p>
{wisInfo.blokken.length > 0 && (
<ul className="conflict-lijst">
{wisInfo.blokken.slice(0, 30).map((b) => <li key={b.id} className="waarschuwing">{b.doel_naam} · {b.activiteit_naam} ({b.tijdslot_label})</li>)}
{wisInfo.blokken.length > 30 && <li className="muted">en nog {wisInfo.blokken.length - 30}</li>}
</ul>
)}
</div>
<div className="editor-actions">
<button type="button" className="btn danger" disabled={applyBezig || wisInfo.blokken.length === 0} onClick={bevestigWis}>{applyBezig ? "Bezig…" : `Wissen bevestigen (${wisInfo.blokken.length})`}</button>
<button type="button" className="btn" onClick={() => setWisInfo(null)} disabled={applyBezig}>Terug</button>
</div>
</>
)}
{preview && (
<>
<div className="wm-preview">
<p><b>{preview.toevoegen.length}</b> vak(ken) worden geplaatst voor periode {preview.van} {preview.tot}.</p>
{preview.verwijderen.length > 0 ? (
<>
<p className="fout"><b>{preview.verwijderen.length}</b> bestaande blok(ken) overlappen en worden verwijderd:</p>
<ul className="conflict-lijst">
{preview.verwijderen.slice(0, 30).map((b) => <li key={b.id} className="waarschuwing">{b.doel_naam} · {b.activiteit_naam} ({b.tijdslot_label})</li>)}
{preview.verwijderen.length > 30 && <li className="muted">en nog {preview.verwijderen.length - 30}</li>}
</ul>
</>
) : <p className="muted small">Geen overlappende bestaande blokken.</p>}
{preview.conflicten.length > 0 ? (
<>
<p className="fout"><b>{preview.conflicten.length}</b> conflict(en) in het rooster ná toepassen (nieuwe + behouden blokken):</p>
<ul className="conflict-lijst">
{preview.conflicten.map((m, i) => <li key={i} className={m.ernst === "fout" ? "fout" : "waarschuwing"}>{m.tekst}</li>)}
</ul>
</>
) : <p className="wm-status ok" style={{ display: "inline-block", marginTop: 4 }}> geen conflicten toepassen</p>}
</div>
<div className="editor-actions">
<button type="button" className="btn primary" disabled={applyBezig} onClick={bevestigVoltooien}>{applyBezig ? "Bezig…" : `Toepassen (${preview.verwijderen.length} verwijderen, ${preview.toevoegen.length} plaatsen)`}</button>
<button type="button" className="btn" onClick={() => setPreview(null)} disabled={applyBezig}>Terug</button>
</div>
</>
)}
</div>
</div>
)}
{cel && (
<div className="modal-overlay no-print" onClick={() => setCel(null)}>
<form className="modal" role="dialog" aria-modal="true" aria-label="Vak toevoegen" onSubmit={voegToe} onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div><h2>Vak toevoegen</h2><div className="muted small">{groepNaam(cel.groep)} · {(tijdsloten.find((t) => t.id === cel.tijdslot) || {}).naam}</div></div>
<button type="button" className="icon-btn" title="Sluiten" onClick={() => setCel(null)}>×</button>
</div>
<div className="editor-grid">
<select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}>
<option value=""> vak </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.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>
</div>
<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={() => toggleBeg(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" type="submit" disabled={!form.activiteit || bezig}>{bezig ? "Bezig…" : "Toevoegen"}</button>
<button type="button" className="btn" onClick={() => setCel(null)}>Annuleren</button>
</div>
</form>
</div>
)}
</section>
);
}
/* -------------------------------- Huisstijl / logo ------------------------ */
// Klein logo dat in elke roosterweergave en op afdrukken verschijnt.
export function RoosterLogo({ print = false }) {
const [logo, setLogo] = useState(null);
useEffect(() => { getHuisstijl().then((h) => setLogo(h && h.logo ? h.logo : null)).catch(() => {}); }, []);
if (!logo) return null;
return <img src={logo} alt="" className={print ? "rooster-logo print-logo" : "rooster-logo no-print"} />;
}
// Pas thema-kleuren direct toe (zelfde logica als App.pasThemaToe; hier
// gedupliceerd om een circulaire import te vermijden).
function _themaDirect(h) {
const root = document.documentElement;
const rgba = (hex, a) => {
if (!hex) return "";
const m = hex.replace("#", ""); const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
if (v.length < 6) return "";
const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16);
return [r, g, b].some(Number.isNaN) ? "" : `rgba(${r}, ${g}, ${b}, ${a})`;
};
if (h && h.primaire_kleur) { root.style.setProperty("--brand", h.primaire_kleur); const s = rgba(h.primaire_kleur, 0.14); if (s) root.style.setProperty("--brand-soft", s); }
if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur);
}
export function HuisstijlPage() {
const [huis, setHuis] = useState(null);
const [kwaliteit, setKwaliteit] = useState(null);
const [bezig, setBezig] = useState(false);
const [error, setError] = useState(null);
const [form, setForm] = useState({ schoolnaam: "", primaire_kleur: "", accent_kleur: "" });
const [opslaanOk, setOpslaanOk] = useState(false);
useEffect(() => { getHuisstijl().then((h) => { setHuis(h); setForm({ schoolnaam: h.schoolnaam || "", primaire_kleur: h.primaire_kleur || "", accent_kleur: h.accent_kleur || "" }); }).catch((e) => setError(e.message)); }, []);
async function opslaanThema() {
setBezig(true); setError(null); setOpslaanOk(false);
try {
const r = await opslaanVormgeving(form);
setHuis(r);
_themaDirect(r);
setOpslaanOk(true);
} catch (e) { setError(e.message); }
setBezig(false);
}
async function herstelKleuren() {
setForm((f) => ({ ...f, primaire_kleur: "", accent_kleur: "" }));
setBezig(true); setError(null);
try {
const r = await opslaanVormgeving({ ...form, primaire_kleur: "", accent_kleur: "" });
setHuis(r);
document.documentElement.style.removeProperty("--brand");
document.documentElement.style.removeProperty("--brand-soft");
document.documentElement.style.removeProperty("--accent");
} catch (e) { setError(e.message); }
setBezig(false);
}
async function kies(e) {
const f = e.target.files && e.target.files[0];
e.target.value = "";
if (!f) return;
setBezig(true); setError(null); setKwaliteit(null);
try {
const r = await uploadLogo(f);
setHuis(r);
setKwaliteit(r.kwaliteit || null);
} catch (err) {
setError(err.message);
if (err.data && err.data.kwaliteit) setKwaliteit(err.data.kwaliteit);
}
setBezig(false);
}
async function wis() {
setBezig(true); setError(null);
try { const r = await verwijderLogo(); setHuis(r); setKwaliteit(null); } catch (e) { setError(e.message); }
setBezig(false);
}
return (
<div className="huisstijl">
<Foutmelding msg={error} />
<h3 className="vorm-kop">Schoolnaam &amp; kleuren</h3>
<p className="muted small">De schoolnaam verschijnt in de balk en op afdrukken. De kleuren bepalen het thema van de hele app (knoppen, tabs, accenten).</p>
<div className="vorm-grid">
<label className="veld-kol"><span className="veld-kop">Schoolnaam</span><input placeholder="bv. De Regenboog" value={form.schoolnaam} onChange={(e) => setForm({ ...form, schoolnaam: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Primaire kleur</span><input type="color" value={form.primaire_kleur || "#2563eb"} onChange={(e) => setForm({ ...form, primaire_kleur: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Accentkleur</span><input type="color" value={form.accent_kleur || "#2563eb"} onChange={(e) => setForm({ ...form, accent_kleur: e.target.value })} /></label>
</div>
<div className="editor-actions">
<button type="button" className="btn primary" onClick={opslaanThema} disabled={bezig}>{bezig ? "Bezig…" : "Opslaan"}</button>
<button type="button" className="btn" onClick={herstelKleuren} disabled={bezig}>Standaardkleuren</button>
{opslaanOk && <span className="wm-status ok">Opgeslagen </span>}
</div>
<h3 className="vorm-kop">Logo</h3>
<p className="muted small">Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een <b>transparante PNG</b> of <b>SVG</b>; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.</p>
<div className="logo-vak">
<div className="logo-preview" title="Voorbeeld op een geruite achtergrond (zo zie je transparantie)">
{huis && huis.logo ? <img src={huis.logo} alt="Logo" /> : <span className="muted small">Nog geen logo</span>}
</div>
<div className="logo-acties">
<label className="btn primary">{bezig ? "Bezig…" : (huis && huis.logo ? "Ander logo kiezen" : "Logo kiezen")}
<input type="file" accept=".png,.svg,.jpg,.jpeg,.gif,.webp,image/*" style={{ display: "none" }} onChange={kies} disabled={bezig} />
</label>
{huis && huis.logo && <button type="button" className="btn danger" onClick={wis} disabled={bezig}>Verwijderen</button>}
<div className="muted small">Toegestaan: PNG, SVG, JPG, GIF, WebP.</div>
</div>
</div>
{kwaliteit && (
<div className={kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length ? "banner waarschuwing" : "banner ok"}>
<b>Kwaliteitscheck:</b>
{kwaliteit.info && (kwaliteit.info.breedte || kwaliteit.info.formaat) && (
<div className="small">Formaat {kwaliteit.info.formaat}{kwaliteit.info.breedte ? `, ${kwaliteit.info.breedte}×${kwaliteit.info.hoogte}px` : ""}{kwaliteit.info.transparant ? ", transparante achtergrond ✓" : ""}{kwaliteit.info.achtergrond ? `, achtergrond ${kwaliteit.info.achtergrond}` : ""}.</div>
)}
{kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length > 0
? <ul className="conflict-lijst">{kwaliteit.waarschuwingen.map((w, i) => <li key={i} className="waarschuwing">{w}</li>)}</ul>
: <div className="small">Geen problemen gevonden mooi en netjes. </div>}
</div>
)}
</div>
);
}
/* -------------------------------- Urenoverzicht --------------------------- */
export function UrenPage() {
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [groepFilter, setGroepFilter] = useState("");
const [vakFilter, setVakFilter] = useState("");
const [leerlingFilter, setLeerlingFilter] = useState("");
const [historie, setHistorie] = useState(null);
const [bewaarBusy, setBewaarBusy] = useState(false);
useEffect(() => {
listResource("schooljaren")
.then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); })
.catch((e) => setError(e.message));
}, []);
useEffect(() => {
if (!schooljaar) { setData(null); return; }
setLoading(true);
listResource("urenoverzicht", `?schooljaar=${schooljaar}`)
.then((d) => { setData(d); setError(null); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [schooljaar]);
function laadHistorie(sjid) {
if (!sjid) { setHistorie(null); return; }
listResource("uren-momentopnames", `?schooljaar=${sjid}`).then(setHistorie).catch(() => setHistorie(null));
}
useEffect(() => { laadHistorie(schooljaar); }, [schooljaar]);
async function bewaarMomentopname() {
if (!schooljaar) return;
setBewaarBusy(true);
try { await createResource("uren-momentopnames", { schooljaar: Number(schooljaar) }); laadHistorie(schooljaar); }
catch (e) { setError(e.message); }
finally { setBewaarBusy(false); }
}
const r1 = (n) => Math.round(n * 10) / 10;
const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : [];
const vakNamen = data ? [...new Set(data.per_groep.flatMap((g) => g.vakken.map((v) => v.vak)))].sort() : [];
const perGroep = (data ? data.per_groep : [])
.filter((g) => !groepFilter || g.groep === groepFilter)
.map((g) => { const vakken = vakFilter ? g.vakken.filter((v) => v.vak === vakFilter) : g.vakken; return { ...g, vakken, totaal: r1(vakken.reduce((a, v) => a + v.uren, 0)) }; })
.filter((g) => g.vakken.length > 0);
const perLeerling = (data ? data.per_leerling : [])
.filter((l) => !leerlingFilter || l.leerling.toLowerCase().includes(leerlingFilter.toLowerCase()))
.filter((l) => !groepFilter || l.groep === groepFilter)
.filter((l) => !vakFilter || l.vakken.some((v) => v.vak === vakFilter))
.map((l) => { const vakken = vakFilter ? l.vakken.filter((v) => v.vak === vakFilter) : l.vakken; return { ...l, vakkenWeergave: vakken }; });
const filterActief = groepFilter || vakFilter || leerlingFilter;
return (
<section>
<h1>Urenoverzicht</h1>
<p className="muted">Lesuren per groep en per leerling over het hele schooljaar. Vrije dagen, vakanties en studiedagen tellen niet mee; vervallen blokken zijn afgetrokken. Stage-uren tellen mee bij de leerling.</p>
<Foutmelding msg={error} />
<div className="form-row uren-filters">
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> schooljaar </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
<select value={groepFilter} onChange={(e) => setGroepFilter(e.target.value)}>
<option value="">alle groepen</option>
{groepNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<select value={vakFilter} onChange={(e) => setVakFilter(e.target.value)}>
<option value="">alle vakken</option>
{vakNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<input placeholder="zoek leerling…" value={leerlingFilter} onChange={(e) => setLeerlingFilter(e.target.value)} />
{filterActief && <button type="button" className="link small" onClick={() => { setGroepFilter(""); setVakFilter(""); setLeerlingFilter(""); }}>wis filters</button>}
</div>
{loading ? <p>Laden</p> : data && (
<>
<h2>Per groep</h2>
<table className="grid">
<thead><tr><th>Groep</th><th>Vak</th><th>Uren (schooljaar)</th></tr></thead>
<tbody>
{perGroep.length === 0 && <tr><td colSpan="3" className="muted">Geen resultaten.</td></tr>}
{perGroep.flatMap((g) => [
...g.vakken.map((v, i) => (
<tr key={g.groep + "-" + v.vak}>
<td>{i === 0 ? <strong>{g.groep}</strong> : ""}</td>
<td>{vakIcoon(v.vak)} {v.vak}</td>
<td>{v.uren} u</td>
</tr>
)),
<tr key={g.groep + "-tot"} className="uren-totaal">
<td></td><td><em>totaal</em></td><td><strong>{g.totaal} u</strong></td>
</tr>,
])}
</tbody>
</table>
<h2>Per leerling</h2>
<table className="grid">
<thead><tr><th>Leerling</th><th>Groep</th><th>Vakken</th><th>Stage</th><th>Totaal</th></tr></thead>
<tbody>
{perLeerling.length === 0 && <tr><td colSpan="5" className="muted">Geen resultaten.</td></tr>}
{perLeerling.map((l) => (
<tr key={l.leerling}>
<td>{l.leerling}</td>
<td className="muted">{l.groep || "—"}</td>
<td className="muted small">{l.vakkenWeergave.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}</td>
<td>{l.stage_uren ? `${l.stage_uren} u` : "—"}</td>
<td><strong>{l.totaal} u</strong></td>
</tr>
))}
</tbody>
</table>
<div className="historie-kop">
<h2>Geschiedenis</h2>
<button type="button" className="btn klein" disabled={bewaarBusy} onClick={bewaarMomentopname}>{bewaarBusy ? "Bezig…" : "Bewaar huidige stand"}</button>
</div>
{historie && historie.momentopnames.length > 0 ? (
<table className="grid">
<thead><tr><th>Leerling</th>{historie.momentopnames.map((m) => <th key={m.id}>{new Date(m.gemaakt_op).toLocaleDateString("nl-NL")}</th>)}</tr></thead>
<tbody>
{historie.leerlingen
.filter((r) => !leerlingFilter || r.leerling.toLowerCase().includes(leerlingFilter.toLowerCase()))
.map((r) => (
<tr key={r.leerling}>
<td>{r.leerling}</td>
{historie.momentopnames.map((m) => <td key={m.id}>{r.totalen[m.id] != null ? `${r.totalen[m.id]} u` : "—"}</td>)}
</tr>
))}
</tbody>
</table>
) : <p className="muted">Nog geen momentopnames. Klik op "Bewaar huidige stand" om het verloop in de tijd te volgen.</p>}
</>
)}
</section>
);
}
/* ---------------- Eenvoudige weergave (ZML-leerling / ouder) -------------- */
export function EenvoudigRooster({ mode }) {
const ouder = mode === "ouder";
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [blokken, setBlokken] = useState([]);
const [kalender, setKalender] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [scopeType, setScopeType] = useState("leerling");
const [scopeId, setScopeId] = useState("");
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [dagIdx, setDagIdx] = useState(() => Math.min((new Date().getDay() + 6) % 7, 4));
const [error, setError] = useState(null);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([]));
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
}, []);
useEffect(() => {
if (!schooljaar) return;
listResource("roosterblokken", `?schooljaar=${schooljaar}`).then(setBlokken).catch((e) => setError(e.message));
listResource("kalenderdagen", `?schooljaar=${schooljaar}`).then(setKalender).catch(() => {});
}, [schooljaar]);
const ctx = { leerlingen, subgroepen, leerpleinen };
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
const scopeOpties = scopeType === "klas" ? groepen : leerlingen;
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
const blokGeldigOp = (b, iso) => !((b.geldig_van && iso < b.geldig_van) || (b.geldig_tot && iso > b.geldig_tot));
function dagBlokken(datum) {
const iso = fmtISO(datum);
const vrij = kalenderOp(iso);
if (vrij) return { vrij, items: [] };
const wd = (datum.getDay() + 6) % 7;
const slots = tijdsloten.filter((t) => t.dag === wd).sort((a, b) => a.volgorde - b.volgorde);
const items = [];
slots.forEach((t) => {
blokken
.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso) && (scopeId ? blokInScope(b, scope, ctx) : false))
.forEach((b) => items.push({ t, b }));
});
return { vrij: null, items };
}
function blokKaart({ t, b }, groot) {
const kleur = b.activiteit_kleur || "#2563eb";
return (
<div className={groot ? "ez-blok groot" : "ez-blok"} key={b.id + "-" + t.id} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{t.start_tijd?.slice(0, 5)}</div>
{b.activiteit_pictogram
? <img className="ez-picto" src={b.activiteit_pictogram} alt="" />
: <div className="ez-emoji">{b.activiteit_icoon || vakIcoon(b.activiteit_naam)}</div>}
<div className="ez-vak" style={{ color: kleur }}>{b.activiteit_naam}</div>
</div>
);
}
function dagdeelSecties(items, groot) {
const ocht = items.filter(({ t }) => (t.start_tijd || "") < "12:00");
const midd = items.filter(({ t }) => (t.start_tijd || "") >= "12:00");
const secties = [["Ochtend", "\u2600\uFE0F", ocht], ["Middag", "\uD83C\uDF24\uFE0F", midd]].filter(([, , arr]) => arr.length > 0);
return secties.map(([label, icon, arr]) => (
<div className="ez-dagdeel" key={label}>
<div className="ez-dagdeel-kop"><span className="ez-dagdeel-icon">{icon}</span> {label}</div>
<div className="ez-dagdeel-blokken">{arr.map((x) => blokKaart(x, groot))}</div>
</div>
));
}
return (
<section className="ez">
<div className="rooster-kop"><h1>{ouder ? "Weekrooster" : "Mijn rooster"}</h1><RoosterLogo /></div>
<Foutmelding msg={error} />
<div className="form-row ez-bar no-print">
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
<option value="leerling">Per leerling</option>
<option value="klas">Per groep</option>
</select>
<select value={scopeId} onChange={(e) => setScopeId(e.target.value)}>
<option value=""> kies {scopeType === "klas" ? "groep" : "leerling"} </option>
{scopeOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, -7))}> vorige week</button>
<button type="button" className="btn" onClick={() => setMaandag(maandagVan(new Date()))}>deze week</button>
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, 7))}>volgende week </button>
</div>
{!scopeId && <p className="muted">Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.</p>}
{scopeId && ouder && (
<div className="ez-week">
{weekDatums.map((datum, i) => {
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-kolom" key={i}>
<div className="ez-dag-kop">{WEEKDAGEN[i].label}<span className="muted"> {fmtDM(datum)}</span></div>
{vrij && <div className="ez-vrij">{vrij.type_label}</div>}
{!vrij && items.length === 0 && <div className="muted small"></div>}
{!vrij && dagdeelSecties(items, false)}
</div>
);
})}
</div>
)}
{scopeId && !ouder && (() => {
const datum = plusDagen(maandag, dagIdx);
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-groot">
<div className="ez-dag-nav no-print">
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.max(0, d - 1))} disabled={dagIdx === 0}></button>
<div className="ez-dag-titel">{WEEKDAGEN[dagIdx].label} <span className="muted">{fmtDM(datum)}</span></div>
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.min(4, d + 1))} disabled={dagIdx === 4}></button>
</div>
{vrij && <div className="ez-vrij groot">{vrij.type_label}{vrij.omschrijving ? `: ${vrij.omschrijving}` : ""}</div>}
{!vrij && items.length === 0 && <p className="muted">Geen lessen op deze dag.</p>}
{!vrij && dagdeelSecties(items, true)}
</div>
);
})()}
</section>
);
}
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
export function OuderRoosterPage() { return <EenvoudigRooster mode="ouder" />; }
/* -------------------------------- Vak-wizard ------------------------------ */
export function VakWizardPage() {
const [stap, setStap] = useState(1);
const [schooljaren, setSchooljaren] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [tijdsloten, setTijdsloten] = useState([]);
const [locaties, setLocaties] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [error, setError] = useState(null);
const [klaar, setKlaar] = useState(false);
const leeg = { schooljaar: "", activiteit: "", doel_type: "groep", doel: "", tijdslot: "", locatie: "", lokaal: "", begeleiders: [], geldig_van: "", geldig_tot: "", opmerkingen: "" };
const [f, setF] = useState(leeg);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setF((x) => ({ ...x, schooljaar: String(j[0].id) })); }).catch((e) => setError(e.message));
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("locaties").then(setLocaties).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => setLokalen([]));
}, []);
const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepen : leerlingen;
const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] });
const naamVan = (lijst, id) => { const o = lijst.find((x) => x.id === Number(id)); return o ? (o.naam || o.volledige_naam) : "—"; };
const tslabel = (id) => { const t = tijdsloten.find((x) => x.id === Number(id)); return t ? `${t.dag_label} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; };
const magVerder =
(stap === 1 && f.activiteit) ||
(stap === 2 && f.doel) ||
(stap === 3 && f.tijdslot) ||
(stap === 4) ||
stap === 5;
async function opslaan() {
try {
await createResource("roosterblokken", {
schooljaar: f.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit,
locatie: f.locatie || null, lokaal: f.lokaal || null,
groep: f.doel_type === "groep" ? f.doel : null,
subgroep: f.doel_type === "subgroep" ? f.doel : null,
leerling: f.doel_type === "leerling" ? f.doel : null,
begeleiders: f.begeleiders, opmerkingen: f.opmerkingen,
geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null,
});
setKlaar(true);
} catch (e) { setError(e.message); }
}
function opnieuw() { setF({ ...leeg, schooljaar: f.schooljaar }); setStap(1); setKlaar(false); }
if (klaar) {
return (
<section className="wizard">
<h1>Vak-wizard</h1>
<div className="banner ok"> Het vak is ingepland.</div>
<div className="form-row"><button className="btn primary" onClick={opnieuw}>Nog een vak inplannen</button></div>
</section>
);
}
const STAPPEN = ["Vak", "Voor wie", "Wanneer & waar", "Begeleiders", "Controle"];
return (
<section className="wizard">
<h1>Vak-wizard</h1>
<Foutmelding msg={error} />
<ol className="wizard-stappen">
{STAPPEN.map((label, i) => <li key={i} className={stap === i + 1 ? "on" : stap > i + 1 ? "done" : ""}>{i + 1}. {label}</li>)}
</ol>
{stap === 1 && (
<div className="wizard-stap">
<h2>Welk vak?</h2>
{schooljaren.length > 1 && (
<select value={f.schooljaar} onChange={(e) => setF({ ...f, schooljaar: e.target.value })}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<div className="keuze-grid">
{activiteiten.map((a) => (
<button type="button" key={a.id} className={Number(f.activiteit) === a.id ? "keuze on" : "keuze"} style={{ borderColor: a.kleur }} onClick={() => setF({ ...f, activiteit: a.id })}>
{a.pictogram ? <img src={a.pictogram} alt="" className="keuze-picto" /> : <span className="keuze-emoji">{a.icoon || vakIcoon(a.naam)}</span>}
<span style={{ color: a.kleur }}>{a.naam}</span>
</button>
))}
</div>
</div>
)}
{stap === 2 && (
<div className="wizard-stap">
<h2>Voor wie?</h2>
<div className="form-row">
<select value={f.doel_type} onChange={(e) => setF({ ...f, doel_type: e.target.value, doel: "" })}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
<option value=""> kies {f.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
</div>
)}
{stap === 3 && (
<div className="wizard-stap">
<h2>Wanneer & waar?</h2>
<div className="form-row">
<select value={f.tijdslot} onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {(t.start_tijd || "").slice(0, 5)} {t.naam}</option>)}
</select>
<select value={f.lokaal} onChange={(e) => setF({ ...f, lokaal: e.target.value })}>
<option value=""> lokaal (optioneel) </option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
<select value={f.locatie} onChange={(e) => setF({ ...f, locatie: e.target.value })}>
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</div>
<div className="form-row">
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} /></label>
</div>
</div>
)}
{stap === 4 && (
<div className="wizard-stap">
<h2>Wie begeleidt? <span className="muted small">(optioneel)</span></h2>
<div className="chips compact">
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
</div>
<input placeholder="Opmerking (optioneel)" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
</div>
)}
{stap === 5 && (
<div className="wizard-stap">
<h2>Klopt het zo?</h2>
<ul className="wizard-overzicht">
<li><b>Vak:</b> {naamVan(activiteiten, f.activiteit)}</li>
<li><b>Voor:</b> {naamVan(doelOpties, f.doel)} ({f.doel_type})</li>
<li><b>Wanneer:</b> {tslabel(f.tijdslot)}</li>
<li><b>Waar:</b> {f.lokaal ? naamVan(lokalen, f.lokaal) : (f.locatie ? naamVan(locaties, f.locatie) : "")}</li>
<li><b>Begeleiding:</b> {f.begeleiders.length ? f.begeleiders.map((id) => naamVan(medewerkers, id)).join(", ") : ""}</li>
{(f.geldig_van || f.geldig_tot) && <li><b>Periode:</b> {f.geldig_van || "…"} t/m {f.geldig_tot || "…"}</li>}
</ul>
</div>
)}
<div className="wizard-nav">
{stap > 1 && <button type="button" className="btn" onClick={() => setStap(stap - 1)}> Vorige</button>}
{stap < 5 && <button type="button" className="btn primary" disabled={!magVerder} onClick={() => setStap(stap + 1)}>Volgende </button>}
{stap === 5 && <button type="button" className="btn primary" onClick={opslaan}> Vak inplannen</button>}
</div>
</section>
);
}
/* ----------------------------- Personen-wizard ---------------------------- */
const PWROLLEN = [
{ value: "leerling", label: "Leerling" },
{ value: "leerkracht", label: "Leerkracht" },
{ value: "praktijkondersteuner", label: "Praktijkondersteuner" },
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
];
function detecteerScheiding(regel) {
const tel = (c) => (regel.match(new RegExp("\\" + c, "g")) || []).length;
const t = (regel.match(/\t/g) || []).length;
const kand = [[";", tel(";")], [",", tel(",")], ["\t", t]];
kand.sort((a, b) => b[1] - a[1]);
return kand[0][1] > 0 ? kand[0][0] : ",";
}
function parsePersonen(tekst, rol) {
const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
if (regels.length === 0) return [];
const sep = detecteerScheiding(regels[0]);
const splits = (r) => r.split(sep).map((c) => c.trim());
let kolommen = null;
const eerste = splits(regels[0]).map((c) => c.toLowerCase());
const heeftHeader = eerste.some((c) => ["voornaam", "achternaam", "naam", "groep", "rol"].includes(c));
const rows = [];
const start = heeftHeader ? 1 : 0;
if (heeftHeader) kolommen = eerste;
for (let i = start; i < regels.length; i++) {
const cellen = splits(regels[i]);
let voornaam = "", achternaam = "", groep = "";
if (kolommen) {
const idx = (n) => kolommen.indexOf(n);
if (idx("voornaam") >= 0) voornaam = cellen[idx("voornaam")] || "";
if (idx("achternaam") >= 0) achternaam = cellen[idx("achternaam")] || "";
if (idx("groep") >= 0) groep = cellen[idx("groep")] || "";
if (!voornaam && !achternaam && idx("naam") >= 0) {
const delen = (cellen[idx("naam")] || "").split(" ");
voornaam = delen.shift() || ""; achternaam = delen.join(" ");
}
} else if (cellen.length === 1) {
const delen = cellen[0].split(" ");
voornaam = delen.shift() || ""; achternaam = delen.join(" ");
} else {
voornaam = cellen[0] || ""; achternaam = cellen[1] || ""; groep = cellen[2] || "";
}
if (voornaam || achternaam) rows.push(rol === "leerling" ? { voornaam, achternaam, groep } : { voornaam, achternaam });
}
return rows;
}
export function PersonenWizardPage() {
const [rol, setRol] = useState("leerling");
const [modus, setModus] = useState("enkel");
const [groepen, setGroepen] = useState([]);
const [error, setError] = useState(null);
const [melding, setMelding] = useState(null);
// enkel
const leeg = { voornaam: "", achternaam: "", groep: "" };
const [form, setForm] = useState(leeg);
// bulk
const [tekst, setTekst] = useState("");
const [groepAanmaken, setGroepAanmaken] = useState(false);
const [bezig, setBezig] = useState(false);
useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []);
const isLeerling = rol === "leerling";
const rijen = modus === "bulk" ? parsePersonen(tekst, rol) : [];
async function bewaarEnkel(e) {
e.preventDefault();
setError(null); setMelding(null);
try {
await createResource("personen", { voornaam: form.voornaam, achternaam: form.achternaam, rol, groep: isLeerling && form.groep ? Number(form.groep) : null });
setMelding(`${form.voornaam} ${form.achternaam} toegevoegd.`);
setForm(leeg);
} catch (err) { setError(err.message); }
}
function leesBestand(file) {
const reader = new FileReader();
reader.onload = () => setTekst(String(reader.result || ""));
reader.readAsText(file);
}
async function importeer() {
setError(null); setMelding(null); setBezig(true);
try {
const res = await createResource("personen-import", { rol, personen: rijen, groep_aanmaken: groepAanmaken });
let m = `${res.aangemaakt} ${isLeerling ? "leerling(en)" : "medewerker(s)"} geïmporteerd.`;
if (res.meldingen && res.meldingen.length) m += ` ${res.meldingen.length} aandachtspunt(en).`;
setMelding(m);
setTekst("");
} catch (err) { setError(err.message); }
finally { setBezig(false); }
}
return (
<section className="wizard">
<h1>Personen toevoegen</h1>
<p className="muted">Voeg leerlingen of medewerkers toe één voor één of in bulk (plakken of CSV-bestand).</p>
<Foutmelding msg={error} />
{melding && <div className="banner ok"> {melding}</div>}
<div className="form-row">
<select value={rol} onChange={(e) => setRol(e.target.value)}>
{PWROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<div className="segmented">
<button type="button" className={modus === "enkel" ? "seg on" : "seg"} onClick={() => setModus("enkel")}>Eén toevoegen</button>
<button type="button" className={modus === "bulk" ? "seg on" : "seg"} onClick={() => setModus("bulk")}>Bulk / import</button>
</div>
</div>
{modus === "enkel" && (
<form className="form-card" onSubmit={bewaarEnkel}>
<div className="form-row">
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
{isLeerling && (
<select value={form.groep} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> groep (optioneel) </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
)}
<button className="btn primary" type="submit">Toevoegen</button>
</div>
</form>
)}
{modus === "bulk" && (
<div className="form-card">
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam{isLeerling ? ", groep" : ""}</b> (komma, puntkomma of tab). Een koprij is optioneel.</p>
<div className="form-row">
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const fl = e.target.files[0]; if (fl) leesBestand(fl); e.target.value = ""; }} /></label>
{isLeerling && <label className="veld"><input type="checkbox" checked={groepAanmaken} onChange={(e) => setGroepAanmaken(e.target.checked)} /> onbekende groep automatisch aanmaken</label>}
</div>
<textarea className="bulk-invoer" rows={6} placeholder={isLeerling ? "Jan;Jansen;Groep 1\nPiet;Pietersen;Groep 2" : "Lea;Kracht\nMo;Hassan"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
{rijen.length > 0 && (
<>
<div className="muted small">Voorbeeld ({rijen.length} {isLeerling ? "leerlingen" : "medewerkers"}):</div>
<table className="grid">
<thead><tr><th>Voornaam</th><th>Achternaam</th>{isLeerling && <th>Groep</th>}</tr></thead>
<tbody>
{rijen.slice(0, 8).map((r, i) => <tr key={i}><td>{r.voornaam}</td><td>{r.achternaam}</td>{isLeerling && <td className="muted">{r.groep || ""}</td>}</tr>)}
{rijen.length > 8 && <tr><td colSpan={isLeerling ? 3 : 2} className="muted small"> en {rijen.length - 8} meer</td></tr>}
</tbody>
</table>
</>
)}
<div className="form-row">
<button className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} ${isLeerling ? "leerlingen" : "medewerkers"}`}</button>
</div>
</div>
)}
</section>
);
}
/* ------------------------------- Auto-rooster ----------------------------- */
export function AutoRoosterPage() {
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [groepen, setGroepen] = useState([]);
const [gekozenGroepen, setGekozenGroepen] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [lokalen, setLokalen] = useState([]);
const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, groepen: [], begeleider: "", lokaal: "", dagdeel: "" }]);
const [vrijeDagen, setVrijeDagen] = useState([]);
const [maxPerDag, setMaxPerDag] = useState("");
const [voorstel, setVoorstel] = useState(null);
const [niet, setNiet] = useState([]);
const [error, setError] = useState(null);
const [bezig, setBezig] = useState(false);
const [resultaat, setResultaat] = useState(null);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
listResource("groepen").then(setGroepen).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("lokalen").then(setLokalen).catch(() => setLokalen([]));
}, []);
const setWens = (i, veld, val) => setWensen((ws) => ws.map((w, idx) => idx === i ? { ...w, [veld]: val } : w));
const addWens = () => setWensen((ws) => [...ws, { activiteit: "", aantal: 1, groepen: [...gekozenGroepen], begeleider: "", lokaal: "", dagdeel: "" }]);
const delWens = (i) => setWensen((ws) => ws.filter((_, idx) => idx !== i));
function toggleGroep(gid) {
setGekozenGroepen((g) => {
const aan = g.includes(gid);
const nieuw = aan ? g.filter((x) => x !== gid) : [...g, gid];
if (aan) setWensen((ws) => ws.map((w) => ({ ...w, groepen: w.groepen.filter((x) => x !== gid) })));
return nieuw;
});
}
const effGroepen = (w) => (w.groepen && w.groepen.length ? w.groepen : gekozenGroepen);
const toggleWensGroep = (i, gid) => setWensen((ws) => ws.map((w, idx) => {
if (idx !== i) return w;
const eff = (w.groepen && w.groepen.length ? w.groepen : gekozenGroepen);
return { ...w, groepen: eff.includes(gid) ? eff.filter((x) => x !== gid) : [...eff, gid] };
}));
const groepNaam = (gid) => (groepen.find((g) => g.id === gid) || {}).naam || "?";
async function genereer() {
setError(null); setResultaat(null); setVoorstel(null); setNiet([]);
if (gekozenGroepen.length === 0) { setError("Kies eerst één of meer klassen."); return; }
const geldig = wensen.filter((w) => w.activiteit && Number(w.aantal) > 0);
if (geldig.length === 0) { setError("Voeg minstens één wens toe (vak + aantal)."); return; }
setBezig(true);
try {
const r = await createResource("autorooster/voorstel", {
schooljaar: Number(schooljaar),
groepen: gekozenGroepen,
wensen: geldig.map((w) => ({
activiteit: Number(w.activiteit), aantal: Number(w.aantal),
groepen: (w.groepen && w.groepen.length ? w.groepen : gekozenGroepen),
begeleider: w.begeleider || null, lokaal: w.lokaal || null, dagdeel: w.dagdeel || "",
})),
vrije_dagen: vrijeDagen,
max_per_dag: maxPerDag === "" ? null : Number(maxPerDag),
});
setVoorstel(r.voorstel || []); setNiet(r.niet_geplaatst || []);
} catch (e) { setError(e.message); }
finally { setBezig(false); }
}
async function toepassen() {
if (!voorstel || voorstel.length === 0) return;
setBezig(true); setError(null);
let ok = 0; const mislukt = [];
for (const v of voorstel) {
try {
await createResource("roosterblokken", {
schooljaar: Number(schooljaar), tijdslot: v.tijdslot, activiteit: v.activiteit,
groep: v.groep, subgroep: null, leerling: null,
begeleiders: v.begeleider ? [v.begeleider] : [], lokaal: v.lokaal || null,
});
ok += 1;
} catch (e) { mislukt.push(`${v.activiteit_naam} ${v.groep_naam} (${v.tijdslot_label}): ${e.message}`); }
}
setResultaat({ ok, mislukt });
setVoorstel(null);
setBezig(false);
}
const groepenInVoorstel = voorstel ? [...new Set(voorstel.map((v) => v.groep_naam))].sort() : [];
return (
<section className="autorooster">
<h1>Automatisch rooster</h1>
<p className="muted">Kies de klassen, geef je wensen op (een vak kan voor meerdere klassen gelden, bv. gym of atelier) en genereer een conflictvrij voorstel begeleiders en lokalen worden over alle klassen heen niet dubbel geboekt. Daarna pas je het aan in de Roostermaker.</p>
<Foutmelding msg={error} />
<div className="form-row" style={{ alignItems: "center", flexWrap: "wrap" }}>
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<span className="muted small">Klassen:</span>
{groepen.map((g) => (
<button type="button" key={g.id} className={gekozenGroepen.includes(g.id) ? "chip on" : "chip"} onClick={() => toggleGroep(g.id)}>{g.naam}</button>
))}
{groepen.length === 0 && <span className="muted small">Nog geen klassen.</span>}
</div>
<h2>Voorkeuren</h2>
<div className="form-row" style={{ alignItems: "center", flexWrap: "wrap" }}>
<span className="muted small">Dagen vrijhouden:</span>
{WEEKDAGEN.map((d) => (
<label key={d.value} className="veld">
<input type="checkbox" checked={vrijeDagen.includes(d.value)} onChange={(e) => setVrijeDagen((v) => e.target.checked ? [...v, d.value] : v.filter((x) => x !== d.value))} /> {d.label}
</label>
))}
<label className="veld-kol"><span className="veld-kop">Max. blokken per dag</span><input type="number" min="1" max="12" placeholder="geen limiet" value={maxPerDag} onChange={(e) => setMaxPerDag(e.target.value)} style={{ width: 110 }} /></label>
</div>
<h2>Wensen</h2>
<table className="grid">
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Voor welke klassen</th><th>Voorkeur dagdeel</th><th>Begeleider</th><th>Lokaal</th><th></th></tr></thead>
<tbody>
{wensen.map((w, i) => (
<tr key={i}>
<td>
<select value={w.activiteit} onChange={(e) => setWens(i, "activiteit", e.target.value)}>
<option value=""> vak </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
</td>
<td><input type="number" min="1" max="20" value={w.aantal} onChange={(e) => setWens(i, "aantal", e.target.value)} style={{ width: 64 }} /></td>
<td>
<div className="chips compact">
{gekozenGroepen.length === 0 && <span className="muted small">kies eerst klassen</span>}
{gekozenGroepen.map((gid) => (
<button type="button" key={gid} className={effGroepen(w).includes(gid) ? "chip on" : "chip"} onClick={() => toggleWensGroep(i, gid)}>{groepNaam(gid)}</button>
))}
</div>
</td>
<td>
<select value={w.dagdeel} onChange={(e) => setWens(i, "dagdeel", e.target.value)}>
<option value="">geen</option>
<option value="ochtend">Ochtend</option>
<option value="middag">Middag</option>
</select>
</td>
<td>
<select value={w.begeleider} onChange={(e) => setWens(i, "begeleider", e.target.value)}>
<option value=""></option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</td>
<td>
<select value={w.lokaal} onChange={(e) => setWens(i, "lokaal", e.target.value)}>
<option value=""></option>
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</td>
<td className="row-actions">{wensen.length > 1 && <button type="button" className="link danger" onClick={() => delWens(i)}>×</button>}</td>
</tr>
))}
</tbody>
</table>
<div className="form-row">
<button type="button" className="btn" onClick={addWens}>+ wens</button>
<button type="button" className="btn primary" disabled={bezig} onClick={genereer}>{bezig ? "Bezig…" : "Genereer voorstel"}</button>
</div>
{resultaat && (
<div className={resultaat.mislukt.length ? "banner error" : "banner ok"}>
{resultaat.ok} blok(ken) toegevoegd aan het rooster.{resultaat.mislukt.length > 0 && <> {resultaat.mislukt.length} mislukt: <span className="small">{resultaat.mislukt.join("; ")}</span></>}
{" "}Stel het verder af in de Roostermaker.
</div>
)}
{voorstel && (
<div className="voorstel">
<h2>Voorstel ({voorstel.length} blokken, {groepenInVoorstel.length} klas(sen))</h2>
{niet.length > 0 && (
<div className="banner error">Niet alles paste: {niet.map((n) => `${n.activiteit_naam} ${n.groep_naam || ""} (${n.tekort} te kort)`).join(", ")}.</div>
)}
{voorstel.length === 0 ? <p className="muted">Geen blokken voorgesteld.</p> : groepenInVoorstel.map((gn) => {
const items = voorstel.filter((v) => v.groep_naam === gn);
const perDag = {};
items.forEach((v) => { (perDag[v.dag] = perDag[v.dag] || []).push(v); });
return (
<div key={gn} className="voorstel-klas">
<h3>{gn}</h3>
<div className="ez-week">
{WEEKDAGEN.map((d) => (
<div className="ez-dag-kolom" key={d.value}>
<div className="ez-dag-kop">{d.label}</div>
{(perDag[d.value] || []).length === 0 && <div className="muted small"></div>}
{(perDag[d.value] || []).map((v, i) => {
const kleur = (activiteiten.find((a) => a.id === v.activiteit) || {}).kleur || "#2563eb";
return (
<div className="ez-blok" key={i} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{v.tijdslot_label.split(" ")[1]}</div>
<div className="ez-vak" style={{ color: kleur }}>{v.activiteit_naam}</div>
{v.begeleider_naam && <div className="muted small">{v.begeleider_naam}</div>}
{v.lokaal_naam && <div className="muted small">{v.lokaal_naam}</div>}
</div>
);
})}
</div>
))}
</div>
</div>
);
})}
<div className="form-row">
<button type="button" className="btn primary" disabled={bezig || voorstel.length === 0} onClick={toepassen}>{bezig ? "Bezig…" : "Voorstel toepassen op rooster"}</button>
<button type="button" className="btn" onClick={() => setVoorstel(null)}>Verwerpen</button>
</div>
</div>
)}
</section>
);
}
/* ------------------------- Audit, export en portaal ----------------------- */
export function AuditLogPage() {
const [items, setItems] = useState([]);
const [filter, setFilter] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function laad() {
setLoading(true);
try {
const q = filter.trim() ? `?actor=${encodeURIComponent(filter.trim())}&limit=200` : "?limit=200";
setItems(await getAuditLog(q));
setError(null);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}
useEffect(() => { laad(); }, []);
return (
<section>
<h1>Auditlog</h1>
<Foutmelding msg={error} />
<div className="form-row">
<input placeholder="Filter op gebruiker" value={filter} onChange={(e) => setFilter(e.target.value)} />
<button type="button" className="btn" onClick={laad}>Ververs</button>
</div>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Tijd</th><th>Gebruiker</th><th>Actie</th><th>Doel</th><th>IP</th><th>Extra</th></tr></thead>
<tbody>
{items.map((e) => (
<tr key={e.id}>
<td className="muted small">{new Date(e.created_at).toLocaleString("nl-NL")}</td>
<td>{e.actor || "—"}</td>
<td>{e.action}</td>
<td className="muted">{e.target || "—"}</td>
<td className="muted small">{e.ip_address || "—"}</td>
<td className="muted small"><code>{e.extra ? JSON.stringify(e.extra) : "{}"}</code></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="6" className="muted">Nog geen auditregels.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
export function ImportExportPage() {
const [csv, setCsv] = useState("voornaam,achternaam,rol,groep\n");
const [resultaat, setResultaat] = useState(null);
const [error, setError] = useState(null);
const exports = ["personen", "groepen", "roosterblokken", "afwezigheden"];
async function importeer(e) {
e.preventDefault();
setError(null); setResultaat(null);
try { setResultaat(await importPersonenCsv(csv)); }
catch (err) { setError(err.message); }
}
function leesBestand(file) {
const rd = new FileReader();
rd.onload = () => setCsv(String(rd.result || ""));
rd.readAsText(file);
}
return (
<section>
<h1>Import/export</h1>
<Foutmelding msg={error} />
<h2>Export</h2>
<div className="form-row">
{exports.map((r) => <a key={r} className="btn" href={exportUrl(r)}>{r}.csv</a>)}
</div>
<h2>Personen importeren</h2>
{resultaat && <div className="banner ok">{resultaat.aangemaakt} persoon/personen aangemaakt.{resultaat.meldingen?.length ? ` ${resultaat.meldingen.length} melding(en).` : ""}</div>}
<form className="form-card" onSubmit={importeer}>
<div className="form-row">
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) leesBestand(f); e.target.value = ""; }} /></label>
<button className="btn primary" type="submit">Importeer personen</button>
</div>
<textarea className="bulk-invoer" rows={8} value={csv} onChange={(e) => setCsv(e.target.value)} />
</form>
{resultaat?.meldingen?.length > 0 && (
<ul className="muted small">{resultaat.meldingen.map((m, i) => <li key={i}>{m}</li>)}</ul>
)}
</section>
);
}
export function VervangingPage() {
const { items: afwezigheden, error, loading } = useList("afwezigheden");
const [keuze, setKeuze] = useState("");
const [data, setData] = useState(null);
const [msg, setMsg] = useState(null);
const [busy, setBusy] = useState(false);
async function laad() {
if (!keuze) return;
setBusy(true); setMsg(null);
try { setData(await getVervangSuggesties(keuze)); }
catch (e) { setMsg(e.message); }
finally { setBusy(false); }
}
async function toepassen(s, datum, kandidaat) {
setMsg(null);
try {
await pasVervangingToe({ roosterblok: s.roosterblok, datum, vervanger: kandidaat.id });
setMsg(`${kandidaat.naam} vastgelegd op ${datum}.`);
} catch (e) { setMsg(e.message); }
}
return (
<section>
<h1>Vervanging</h1>
<Foutmelding msg={error || msg} />
<div className="form-row">
<select value={keuze} onChange={(e) => setKeuze(e.target.value)}>
<option value="">Kies afwezigheid</option>
{afwezigheden.map((a) => <option key={a.id} value={a.id}>{a.persoon_naam} {a.start_datum} t/m {a.eind_datum}</option>)}
</select>
<button type="button" className="btn primary" disabled={!keuze || busy || loading} onClick={laad}>{busy ? "Bezig…" : "Zoek vervangers"}</button>
</div>
{data && (
<div>
<h2>{data.persoon}</h2>
{data.suggesties.length === 0 && <p className="muted">Geen roosterblokken gevonden waarvoor vervanging nodig is.</p>}
{data.suggesties.map((s) => (
<div className="form-card" key={s.roosterblok}>
<div className="card-title">{s.activiteit}</div>
<p className="muted small">{s.tijdslot} · {s.doel}</p>
{s.datums.map((d) => (
<div className="form-row" key={d}>
<span className="tag">{d}</span>
{s.kandidaten.length === 0 && <span className="muted small">Geen vrije kandidaat gevonden.</span>}
{s.kandidaten.map((k) => <button type="button" className="chip" key={k.id} onClick={() => toepassen(s, d, k)}>{k.naam}</button>)}
</div>
))}
</div>
))}
</div>
)}
</section>
);
}
export function PortalPage() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => { getPortal().then(setData).catch((e) => setError(e.message)); }, []);
return (
<section>
<h1>Portaal</h1>
<Foutmelding msg={error} />
{!data ? <p>Laden</p> : (
<>
<div className="form-card">
<div className="card-title">{data.person_name || data.username}</div>
<p className="muted">{data.role_label || "Gebruiker"}</p>
<div className="form-row">
<a className="btn primary" href="#" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent("roosterwijs:navigate", { detail: "/leerlingrooster" })); }}>Mijn rooster</a>
<a className="btn" href="#" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent("roosterwijs:navigate", { detail: "/ouderrooster" })); }}>Ouderrooster</a>
</div>
</div>
<h2>Afwezigheden</h2>
{data.afwezigheden.length === 0 ? <p className="muted">Geen afwezigheden bekend.</p> : (
<table className="grid"><tbody>{data.afwezigheden.map((a) => <tr key={a.id}><td>{a.start_datum}</td><td>{a.eind_datum}</td><td className="muted">{a.reden || ""}</td></tr>)}</tbody></table>
)}
</>
)}
</section>
);
}
/* --------------------------- Wachtwoord opnieuw --------------------------- */
export function WachtwoordResetPage({ uid, token }) {
const [w1, setW1] = useState("");
const [w2, setW2] = useState("");
const [error, setError] = useState(null);
const [klaar, setKlaar] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
if (w1.length < 8) { setError("Het wachtwoord moet minstens 8 tekens zijn."); return; }
if (w1 !== w2) { setError("De twee wachtwoorden zijn niet gelijk."); return; }
setBusy(true); setError(null);
try { await wachtwoordReset(uid, token, w1); setKlaar(true); }
catch (err) { setError(err.message); }
finally { setBusy(false); }
}
return (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<div className="brand login-brand"><span className="brand-name">Roosterwijs</span></div>
{klaar ? (
<>
<p className="muted">Je wachtwoord is aangepast. Je kunt nu inloggen.</p>
<a className="btn primary" href="/">Naar inloggen</a>
</>
) : (
<>
<p className="muted">Kies een nieuw wachtwoord (minstens 8 tekens).</p>
{error && <div className="banner error"> {error}</div>}
<input type="password" placeholder="Nieuw wachtwoord" value={w1} required autoFocus onChange={(e) => setW1(e.target.value)} />
<input type="password" placeholder="Herhaal wachtwoord" value={w2} required onChange={(e) => setW2(e.target.value)} />
<button className="btn primary" type="submit" disabled={busy}>{busy ? "Bezig…" : "Wachtwoord opslaan"}</button>
</>
)}
</form>
</div>
);
}
/* -------------------- Gebruikers: bulk-import-wizard ---------------------- */
function parseGebruikers(tekst) {
const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
if (regels.length === 0) return [];
const sep = detecteerScheiding(regels[0]);
const splits = (r) => r.split(sep).map((c) => c.trim());
const eerste = splits(regels[0]).map((c) => c.toLowerCase());
const header = eerste.some((c) => ["voornaam", "achternaam", "naam", "email", "e-mail", "rol"].includes(c));
const kol = header ? eerste : null;
const rows = [];
for (let i = header ? 1 : 0; i < regels.length; i++) {
const c = splits(regels[i]);
let voornaam = "", achternaam = "", email = "";
if (kol) {
const idx = (n) => kol.indexOf(n);
if (idx("voornaam") >= 0) voornaam = c[idx("voornaam")] || "";
if (idx("achternaam") >= 0) achternaam = c[idx("achternaam")] || "";
const em = idx("email") >= 0 ? idx("email") : idx("e-mail");
if (em >= 0) email = c[em] || "";
if (!voornaam && !achternaam && idx("naam") >= 0) { const d = (c[idx("naam")] || "").split(" "); voornaam = d.shift() || ""; achternaam = d.join(" "); }
} else if (c.length === 1) {
const d = c[0].split(" "); voornaam = d.shift() || ""; achternaam = d.join(" ");
} else {
voornaam = c[0] || ""; achternaam = c[1] || ""; if (c[2] && c[2].includes("@")) email = c[2];
}
if (voornaam || achternaam) rows.push({ voornaam, achternaam, email });
}
return rows;
}
function naamScore(rij, persoonNaam) {
const a = `${rij.voornaam} ${rij.achternaam}`.trim().toLowerCase();
const b = (persoonNaam || "").toLowerCase();
if (!a || !b) return 0;
if (a === b) return 100;
const ta = a.split(/\s+/).filter(Boolean), tb = b.split(/\s+/).filter(Boolean);
let overlap = 0; ta.forEach((t) => { if (tb.includes(t)) overlap++; });
let score = overlap * 10;
if (b.includes(a) || a.includes(b)) score += 3;
if (ta.length && tb.length && ta[ta.length - 1] === tb[tb.length - 1]) score += 5;
return score;
}
export function GebruikersImportWizard({ personen, onKlaar }) {
const [tekst, setTekst] = useState("");
const [rol, setRol] = useState("medewerker");
const [koppel, setKoppel] = useState({});
const [resultaat, setResultaat] = useState(null);
const [bezig, setBezig] = useState(false);
const [error, setError] = useState(null);
const rijen = parseGebruikers(tekst);
const suggesties = (rij) => personen
.map((p) => ({ p, s: naamScore(rij, p.volledige_naam) }))
.filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 5);
const defaultKoppel = (rij) => { const top = suggesties(rij)[0]; return top && top.s >= 10 ? String(top.p.id) : "_new"; };
const koppelVan = (i, rij) => (koppel[i] !== undefined ? koppel[i] : defaultKoppel(rij));
async function importeer() {
setBezig(true); setError(null);
try {
const payload = rijen.map((rij, i) => {
const k = koppelVan(i, rij);
return { voornaam: rij.voornaam, achternaam: rij.achternaam, email: rij.email, rol,
persoon: k && k !== "_new" ? Number(k) : null, maak_persoon: k === "_new" };
});
const r = await gebruikersImport(payload);
setResultaat(r.gebruikers || []);
setTekst(""); setKoppel({});
if (onKlaar) onKlaar();
} catch (e) { setError(e.message); }
finally { setBezig(false); }
}
const kopieer = (t) => { try { navigator.clipboard.writeText(t); } catch (e) { /* niet beschikbaar */ } };
if (resultaat) {
return (
<div>
<div className="banner ok">{resultaat.length} gebruiker(s) aangemaakt. Noteer of kopieer de wachtwoorden nu ze worden hierna niet meer getoond.</div>
<table className="grid">
<thead><tr><th>Gebruikersnaam</th><th>Wachtwoord</th><th>Persoon</th><th></th></tr></thead>
<tbody>
{resultaat.map((g, i) => (
<tr key={i}>
<td>{g.gebruikersnaam}</td>
<td><code>{g.wachtwoord}</code></td>
<td className="muted">{g.persoon_naam || "—"}</td>
<td className="row-actions"><button type="button" className="link small" onClick={() => kopieer(`${g.gebruikersnaam}\t${g.wachtwoord}`)}>kopieer</button></td>
</tr>
))}
</tbody>
</table>
<div className="form-row">
<button type="button" className="btn" onClick={() => kopieer(resultaat.map((g) => `${g.gebruikersnaam}\t${g.wachtwoord}`).join("\n"))}>Alles kopiëren</button>
<button type="button" className="btn primary" onClick={() => setResultaat(null)}>Nog een import</button>
</div>
</div>
);
}
return (
<div className="form-card">
<Foutmelding msg={error} />
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam, e-mail</b> (komma, puntkomma of tab; koprij optioneel). Wachtwoorden worden automatisch gegenereerd; per persoon wordt een gelijkende bestaande persoon voorgesteld.</p>
<div className="form-row">
<label className="veld-kol"><span className="veld-kop">Rol voor iedereen</span>
<select value={rol} onChange={(e) => setRol(e.target.value)}>
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) { const rd = new FileReader(); rd.onload = () => setTekst(String(rd.result || "")); rd.readAsText(f); } e.target.value = ""; }} /></label>
</div>
<textarea className="bulk-invoer" rows={5} placeholder={"Jan;Jansen;jan@school.nl\nPiet;Pietersen;piet@school.nl"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
{rijen.length > 0 && (
<table className="grid">
<thead><tr><th>Naam</th><th>E-mail</th><th>Koppelen aan persoon</th></tr></thead>
<tbody>
{rijen.map((rij, i) => {
const sug = suggesties(rij);
return (
<tr key={i}>
<td>{rij.voornaam} {rij.achternaam}</td>
<td className="muted small">{rij.email || "—"}</td>
<td>
<select value={koppelVan(i, rij)} onChange={(e) => setKoppel((k) => ({ ...k, [i]: e.target.value }))}>
<option value="_new">+ nieuwe persoon aanmaken</option>
<option value=""> niet koppelen </option>
{sug.map(({ p }) => <option key={p.id} value={p.id}>{p.volledige_naam} lijkt hierop</option>)}
{personen.filter((p) => !sug.some((x) => x.p.id === p.id)).map((p) => <option key={p.id} value={p.id}>{p.volledige_naam}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<div className="form-row">
<button type="button" className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} gebruiker(s)`}</button>
</div>
</div>
);
}