From 66590553fe87a98d90c56f40ac32e897339d28dc Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:47:42 +0200 Subject: [PATCH] weekroostermaker: matrix alle klassen, vast weekrooster met live conflicten --- ROADMAP.md | 9 ++ backend/core/apps.py | 1 + frontend/src/App.jsx | 3 +- frontend/src/pages.jsx | 186 +++++++++++++++++++++++++++++++++++++++- frontend/src/styles.css | 23 +++++ 5 files changed, 220 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fc8ee68..3729bc4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/core/apps.py b/backend/core/apps.py index 3238e81..704ba6c 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -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. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a911a8c..17370b5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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, diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index f73b4f0..da5fc55 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -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 ; } export function RoostermakerPage() { return ; } +/* --------------- 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 ( +
+

Weekroostermaker

+

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.

+ + +
+ + {conflictInfo.meldingen.length ? `⚠ ${conflictInfo.meldingen.length} conflict(en)` : "✓ geen conflicten"} + +
+ + {voltooid && ( +
+ {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.} +
+ )} + + {conflictInfo.meldingen.length > 0 && ( +
    + {conflictInfo.meldingen.map((m, i) =>
  • {m.tekst}
  • )} +
+ )} + + {!schooljaar &&

Kies eerst een schooljaar.

} + {schooljaar && groepen.length === 0 &&

Maak eerst groepen (klassen) aan onder Organisatie.

} + {schooljaar && groepen.length > 0 && perDag.length === 0 &&

Maak eerst algemene tijdvakken aan (Instellingen → Tijdvakken).

} + + {schooljaar && groepen.length > 0 && perDag.length > 0 && ( +
+ + + + + {groepen.map((g) => )} + + + + {perDag.map((rij) => ( + + + {rij.sloten.map((t) => ( + + + {groepen.map((g) => { + const cellblk = blokkenVan(t.id, g.id); + return ( + + ); + })} + + ))} + + ))} + +
Tijdvak{g.naam}
{rij.dag.label}
{t.start_tijd?.slice(0, 5)} {t.naam} + {cellblk.map((b) => ( +
+
+ {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} + +
+ {b.subgroep &&
{b.doel_naam}
} + {(b.lokaal_naam || b.begeleiders_namen.length > 0) && ( +
{[b.lokaal_naam, ...b.begeleiders_namen.map((x) => x.naam)].filter(Boolean).join(" · ")}
+ )} +
+ ))} + +
+
+ )} + + {cel && ( +
setCel(null)}> +
e.stopPropagation()}> +
+

Vak toevoegen

{groepNaam(cel.groep)} · {(tijdsloten.find((t) => t.id === cel.tijdslot) || {}).naam}
+ +
+
+ + +
+
+ Begeleiders +
+ {medewerkers.map((m) => )} + {medewerkers.length === 0 && Nog geen medewerkers.} +
+
+
+ + +
+
+
+ )} +
+ ); +} + /* -------------------------------- Urenoverzicht --------------------------- */ export function UrenPage() { const [schooljaren, setSchooljaren] = useState([]); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3cdf6d0..fa76fce 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }