4588 lines
234 KiB
JavaScript
4588 lines
234 KiB
JavaScript
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 & 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 & 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 & 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 & 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 & 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 & 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 ná 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 & 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>
|
||
);
|
||
}
|