weekroostermaker: matrix alle klassen, vast weekrooster met live conflicten

This commit is contained in:
bes-r 2026-06-18 23:47:42 +02:00
parent 7ab1be9abe
commit 66590553fe
5 changed files with 220 additions and 2 deletions

View file

@ -25,6 +25,15 @@ volgt.
leerling/medewerker enkel toevoegen óf in bulk via plakken/CSV (komma,
puntkomma of tab; koprij optioneel) met preview; endpoint /api/personen-import/.
## Fase 6 — Weekroostermaker
- [x] **Weekroostermaker** (`/weekroostermaker`, menu onder Rooster): matrix met
alle klassen als kolommen en de algemene tijdvakken als rijen. Vakken per
klas plaatsen in het **vaste basisweekrooster** (geen periode-koppeling;
`geldig_van/tot` leeg). Live conflictsignalering over alle klassen heen
(begeleider/lokaal dubbel) + "Weekrooster voltooien" als eindcontrole.
Periode-afwijkingen blijven in de Roostermaker.
## Fase 6 — Overzicht & navigatie
- [~] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt — een

View file

@ -30,6 +30,7 @@ class CoreConfig(AppConfig):
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
MenuItem("Rooster", "/rooster", icon="calendar", order=10, group="Rooster"),
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12, group="Rooster"),
MenuItem("Weekroostermaker", "/weekroostermaker", icon="calendar", order=13, group="Rooster"),
MenuItem("Leerlingrooster", "/leerlingrooster", icon="book", order=14, group="Rooster"),
MenuItem("Ouderrooster", "/ouderrooster", icon="badge", order=16, group="Rooster"),
# Submenu Organisatie: wie hoort waar.

View file

@ -4,7 +4,7 @@ import Logo from "./Logo.jsx";
import {
PersonenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage,
RoosterWeergavePage, RoostermakerPage, WeekRoostermakerPage,
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage,
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
@ -28,6 +28,7 @@ const normaliseerPad = (path) => (GROEPSINDELING_PADEN.has(path) ? "/groepsindel
const PAGES = {
"/rooster": RoosterWeergavePage,
"/roostermaker": RoostermakerPage,
"/weekroostermaker": WeekRoostermakerPage,
"/groepsindeling": GroepsindelingPage,
"/personen": PersonenPage,
"/functies": FunctiesPage,

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
listResource,
createResource,
@ -2764,6 +2764,190 @@ export function RoosterScherm({ mode }) {
export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; }
export function RoostermakerPage() { return <RoosterScherm mode="edit" />; }
/* --------------- Weekroostermaker: alle klassen in één matrix ------------- */
// Bouwt het VASTE basisweekrooster (geen periode/kalender-koppeling): per klas
// vakken in elk tijdvak, met directe conflictsignalering over alle klassen heen.
// Afwijken per periode gebeurt daarna in de Roostermaker.
export function WeekRoostermakerPage() {
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);
const [voltooid, setVoltooid] = useState(false);
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(); }, [schooljaar]); // eslint-disable-line react-hooks/exhaustive-deps
async function laadBlokken() {
if (!schooljaar) { setBlokken([]); return; }
try {
const j = await listResource("roosterblokken", `?schooljaar=${schooljaar}`);
setBlokken(j.filter((b) => !b.geldig_van && !b.geldig_tot));
setError(null);
} catch (e) { setError(e.message); }
}
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 });
setCel(null);
setVoltooid(false);
await laadBlokken();
} catch (err) { setError(err.message); }
setBezig(false);
}
async function verwijder(id) {
try { await deleteResource("roosterblokken", id); setVoltooid(false); await laadBlokken(); } catch (e) { setError(e.message); }
}
return (
<section className="weekmaker">
<h1>Weekroostermaker</h1>
<p className="muted no-print">Geef alle klassen in één overzicht hun vaste weekvakken. Dit is het basisweekrooster niet aan een periode gekoppeld; per periode afwijken (laten vervallen, vervanger) doe je daarna in de Roostermaker. 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); setVoltooid(false); }}>
<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>
<button className="btn primary" disabled={!schooljaar || blokken.length === 0} onClick={() => setVoltooid(true)}>Weekrooster voltooien</button>
</div>
{voltooid && (
<div className={conflictInfo.meldingen.length ? "banner waarschuwing" : "banner ok"}>
{conflictInfo.meldingen.length
? <>Er zijn nog {conflictInfo.meldingen.length} conflict(en). Los de rood omlijnde vakken op voor je het weekrooster als definitief beschouwt.</>
: <>Het weekrooster is compleet en conflictvrij; het geldt nu als vast weekrooster voor elke week. Wil je in een bepaalde periode afwijken, ga dan naar de Roostermaker.</>}
</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" : "")} style={{ 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>
)}
{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>
);
}
/* -------------------------------- Urenoverzicht --------------------------- */
export function UrenPage() {
const [schooljaren, setSchooljaren] = useState([]);

View file

@ -530,6 +530,29 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.chip.chip-plein { border-style: dashed; }
/* Weekroostermaker (matrix: tijdvakken × klassen) */
.wm-status { font-weight: 600; padding: 4px 10px; border-radius: 999px; font-size: 13px; }
.wm-status.ok { background: #ecfdf5; color: #065f46; }
.wm-status.fout { background: #fef2f2; color: #991b1b; }
.wm-conflicten { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 10px; padding: 10px 14px; margin: 0 0 14px; }
.banner.waarschuwing { background: #fffbeb; color: #92400e; border: 1px solid #fcd34d; padding: 10px 14px; border-radius: 8px; }
.wm-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
.wm-tabel { border-collapse: collapse; width: 100%; min-width: 640px; }
.wm-tabel th, .wm-tabel td { border: 1px solid var(--line); vertical-align: top; }
.wm-hoek { background: var(--panel); position: sticky; left: 0; z-index: 2; padding: 8px 10px; text-align: left; font-size: 12px; }
.wm-klas { background: var(--panel); padding: 8px 10px; font-size: 13px; white-space: nowrap; }
.wm-dagrij td { background: var(--bg); font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); padding: 6px 10px; }
.wm-tijd { background: var(--panel); position: sticky; left: 0; z-index: 1; padding: 6px 10px; text-align: left; font-size: 13px; white-space: nowrap; }
.wm-cel { padding: 5px; min-width: 150px; }
.wm-vak { border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 7px; background: #fff; padding: 5px 6px; margin-bottom: 5px; }
.wm-vak-kop { display: flex; align-items: center; gap: 4px; }
.wm-vak-naam { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.wm-vak-meta { color: var(--muted); font-size: 11px; margin-top: 2px; }
.wm-vak.in-conflict { outline: 2px solid #ef4444; outline-offset: 1px; box-shadow: 0 0 0 4px rgba(239,68,68,.15); }
.wm-add { width: 100%; border: 1px dashed var(--line); background: transparent; color: var(--muted); border-radius: 7px; padding: 4px; cursor: pointer; font: inherit; font-size: 13px; opacity: .6; }
.wm-cel:hover .wm-add { opacity: 1; }
.wm-add:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-soft); }
.voorstel-klas { margin-bottom: 18px; }
.voorstel-klas h3 { margin: 8px 0 6px; }