From 170a036c4ef087dfb840420ca7e26a1b469ba122 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:44:33 +0200 Subject: [PATCH] auto-rooster voor meerdere klassen + vak voor meerdere klassen (gedeelde begeleider/lokaal) --- ROADMAP.md | 3 + backend/modules/autorooster/views.py | 169 ++++++++++++++------------- frontend/src/pages.jsx | 115 +++++++++++------- frontend/src/styles.css | 3 + 4 files changed, 173 insertions(+), 117 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index add6d86..8eb993a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,9 @@ volgt. - [x] **Auto-rooster slimmer**: voorkeuren — vak liever ochtend/middag (zachte voorkeur met fallback), bepaalde dagen vrijhouden, en max. blokken per dag per groep. +- [x] **Auto-rooster voor meerdere klassen tegelijk**: kies meerdere klassen; een + vak (bv. gym/atelier) kan voor meerdere klassen gelden, met gedeelde + bewaking van begeleider en lokaal over alle klassen heen. - [ ] **Algemene visuele opfris** (UX/uiterlijk). - [ ] **Module: automatisch rooster genereren.** De gebruiker ge \ No newline at end of file diff --git a/backend/modules/autorooster/views.py b/backend/modules/autorooster/views.py index 3a7122c..13d63b4 100644 --- a/backend/modules/autorooster/views.py +++ b/backend/modules/autorooster/views.py @@ -1,9 +1,9 @@ """Automatisch rooster: stelt een conflictvrij voorstel voor (greedy). -Houdt rekening met voorkeuren: een vak liever in de ochtend of middag, bepaalde -weekdagen vrijhouden, en een maximum aantal blokken per dag per groep. +Werkt voor meerdere klassen tegelijk. Een wens (vak) kan voor meerdere klassen +gelden; begeleiders en lokalen worden over álle klassen heen niet dubbel geboekt. +Voorkeuren: vak liever ochtend/middag, dagen vrijhouden, max. blokken per dag. """ -from django.db.models import Q from rest_framework.decorators import api_view from rest_framework.response import Response @@ -17,7 +17,14 @@ def _is_ochtend(slot): @api_view(["POST"]) def voorstel(request): sjid = request.data.get("schooljaar") - groep_id = request.data.get("groep") + # Meerdere klassen; val terug op het oude enkele 'groep'-veld. + groepen_ids = request.data.get("groepen") + if not groepen_ids and request.data.get("groep"): + groepen_ids = [request.data.get("groep")] + try: + groepen_ids = [int(g) for g in (groepen_ids or [])] + except (TypeError, ValueError): + groepen_ids = [] wensen = request.data.get("wensen") or [] respecteer = request.data.get("respecteer_bestaand", True) vrije_dagen = {int(d) for d in (request.data.get("vrije_dagen") or [])} @@ -26,47 +33,45 @@ def voorstel(request): max_per_dag = int(mpd) if mpd not in (None, "", 0, "0") else None except (TypeError, ValueError): max_per_dag = None - if not sjid or not groep_id: - return Response({"detail": "Geef een schooljaar en een groep op."}, status=400) + + if not sjid or not groepen_ids: + return Response({"detail": "Geef een schooljaar en minstens één klas op."}, status=400) try: sj = Schooljaar.objects.get(pk=sjid) - groep = Groep.objects.get(pk=groep_id) - except (Schooljaar.DoesNotExist, Groep.DoesNotExist): - return Response({"detail": "Schooljaar of groep niet gevonden."}, status=404) + except Schooljaar.DoesNotExist: + return Response({"detail": "Schooljaar niet gevonden."}, status=404) + groepen = {g.id: g for g in Groep.objects.filter(id__in=groepen_ids)} + ordered_gids = [gid for gid in groepen_ids if gid in groepen] + if not ordered_gids: + return Response({"detail": "Geen geldige klassen."}, status=404) - # Kandidaat-tijdsloten: groep-specifiek of algemeen, vrije dagen uitgesloten. - sloten = [ - s for s in Tijdslot.objects.filter(actief=True) - .filter(Q(groep=groep) | Q(groep__isnull=True)) - .order_by("dag", "volgorde", "start_tijd") - if s.dag not in vrije_dagen - ] - if not sloten: - return Response({"voorstel": [], "niet_geplaatst": [], "detail": "Geen bruikbare tijdsloten (let op vrije dagen)."}) + alle_sloten = list(Tijdslot.objects.filter(actief=True).order_by("dag", "volgorde", "start_tijd")) + slot_dag = {s.id: s.dag for s in alle_sloten} - alle_slot_dag = {t.id: t.dag for t in Tijdslot.objects.all()} + def sloten_voor(gid): + return [s for s in alle_sloten if s.groep_id in (None, gid) and s.dag not in vrije_dagen] - # Bezetting bijhouden (eventueel inclusief bestaande blokken). - groep_bezet = set() - beg_bezet = {} - lok_bezet = {} - per_dag = {} # weekdag -> aantal blokken voor deze groep + # Gedeelde bezetting over alle klassen heen. + groep_bezet = {gid: set() for gid in ordered_gids} + per_dag = {} # (gid, weekdag) -> aantal + beg_bezet = {} # tijdslot -> set(begeleider-id) (globaal) + lok_bezet = {} # tijdslot -> set(lokaal-id) (globaal) if respecteer: for b in Roosterblok.objects.filter(schooljaar=sj).prefetch_related("begeleiders"): - if b.groep_id == groep.id: - groep_bezet.add(b.tijdslot_id) - wd = alle_slot_dag.get(b.tijdslot_id) - if wd is not None: - per_dag[wd] = per_dag.get(wd, 0) + 1 for p in b.begeleiders.all(): beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id) if b.lokaal_id: lok_bezet.setdefault(b.tijdslot_id, set()).add(b.lokaal_id) + if b.groep_id in groep_bezet: + groep_bezet[b.groep_id].add(b.tijdslot_id) + wd = slot_dag.get(b.tijdslot_id) + if wd is not None: + per_dag[(b.groep_id, wd)] = per_dag.get((b.groep_id, wd), 0) + 1 act_naam = {a.id: a.naam for a in Activiteit.objects.all()} pers_naam = {p.id: p.volledige_naam for p in Persoon.objects.all()} lok_naam = {l.id: l.naam for l in Lokaal.objects.all()} - slot_info = {s.id: {"label": f"{s.get_dag_display()} {s.start_tijd:%H:%M} {s.naam}", "dag": s.dag, "dag_label": s.get_dag_display()} for s in sloten} + slot_info = {s.id: {"label": f"{s.get_dag_display()} {s.start_tijd:%H:%M} {s.naam}", "dag": s.dag, "dag_label": s.get_dag_display()} for s in alle_sloten} voorstel_lijst = [] niet = [] @@ -80,57 +85,65 @@ def voorstel(request): continue beg = int(beg) if beg else None lok = int(lok) if lok else None + # Doelklassen van deze wens (leeg = alle gekozen klassen). + try: + doel = [int(g) for g in (w.get("groepen") or [])] + except (TypeError, ValueError): + doel = [] + doel = [gid for gid in doel if gid in groepen] or list(ordered_gids) - # Voorkeur dagdeel: zet de voorkeurssloten vooraan (zachte voorkeur). - if dagdeel == "ochtend": - kandidaten = [s for s in sloten if _is_ochtend(s)] + [s for s in sloten if not _is_ochtend(s)] - elif dagdeel == "middag": - kandidaten = [s for s in sloten if not _is_ochtend(s)] + [s for s in sloten if _is_ochtend(s)] - else: - kandidaten = sloten + for gid in doel: + sloten = sloten_voor(gid) + if dagdeel == "ochtend": + kandidaten = [s for s in sloten if _is_ochtend(s)] + [s for s in sloten if not _is_ochtend(s)] + elif dagdeel == "middag": + kandidaten = [s for s in sloten if not _is_ochtend(s)] + [s for s in sloten if _is_ochtend(s)] + else: + kandidaten = sloten - geplaatst = 0 - vak_dagen = set() - for pas in (1, 2): # pas 1 = spreiden (max 1 per dag), pas 2 = opvullen - for s in kandidaten: + geplaatst = 0 + vak_dagen = set() + for pas in (1, 2): + for s in kandidaten: + if geplaatst >= aantal: + break + if s.id in groep_bezet[gid]: + continue + if pas == 1 and s.dag in vak_dagen: + continue + if max_per_dag is not None and per_dag.get((gid, s.dag), 0) >= max_per_dag: + continue + if beg and beg in beg_bezet.get(s.id, set()): + continue + if lok and lok in lok_bezet.get(s.id, set()): + continue + voorstel_lijst.append({ + "tijdslot": s.id, + "tijdslot_label": slot_info[s.id]["label"], + "dag": slot_info[s.id]["dag"], + "dag_label": slot_info[s.id]["dag_label"], + "dagdeel": "ochtend" if _is_ochtend(s) else "middag", + "activiteit": act_id, + "activiteit_naam": act_naam.get(act_id, "?"), + "groep": gid, + "groep_naam": groepen[gid].naam, + "begeleider": beg, + "begeleider_naam": pers_naam.get(beg) if beg else None, + "lokaal": lok, + "lokaal_naam": lok_naam.get(lok) if lok else None, + }) + groep_bezet[gid].add(s.id) + vak_dagen.add(s.dag) + per_dag[(gid, s.dag)] = per_dag.get((gid, s.dag), 0) + 1 + if beg: + beg_bezet.setdefault(s.id, set()).add(beg) + if lok: + lok_bezet.setdefault(s.id, set()).add(lok) + geplaatst += 1 if geplaatst >= aantal: break - if s.id in groep_bezet: - continue - if pas == 1 and s.dag in vak_dagen: - continue - if max_per_dag is not None and per_dag.get(s.dag, 0) >= max_per_dag: - continue - if beg and beg in beg_bezet.get(s.id, set()): - continue - if lok and lok in lok_bezet.get(s.id, set()): - continue - voorstel_lijst.append({ - "tijdslot": s.id, - "tijdslot_label": slot_info[s.id]["label"], - "dag": slot_info[s.id]["dag"], - "dag_label": slot_info[s.id]["dag_label"], - "dagdeel": "ochtend" if _is_ochtend(s) else "middag", - "activiteit": act_id, - "activiteit_naam": act_naam.get(act_id, "?"), - "groep": groep.id, - "begeleider": beg, - "begeleider_naam": pers_naam.get(beg) if beg else None, - "lokaal": lok, - "lokaal_naam": lok_naam.get(lok) if lok else None, - }) - groep_bezet.add(s.id) - vak_dagen.add(s.dag) - per_dag[s.dag] = per_dag.get(s.dag, 0) + 1 - if beg: - beg_bezet.setdefault(s.id, set()).add(beg) - if lok: - lok_bezet.setdefault(s.id, set()).add(lok) - geplaatst += 1 - if geplaatst >= aantal: - break - if geplaatst < aantal: - niet.append({"activiteit_naam": act_naam.get(act_id, "?"), "tekort": aantal - geplaatst}) + if geplaatst < aantal: + niet.append({"activiteit_naam": act_naam.get(act_id, "?"), "groep_naam": groepen[gid].naam, "tekort": aantal - geplaatst}) - voorstel_lijst.sort(key=lambda x: (x["dag"], x["tijdslot_label"])) + voorstel_lijst.sort(key=lambda x: (x["groep_naam"], x["dag"], x["tijdslot_label"])) return Response({"voorstel": voorstel_lijst, "niet_geplaatst": niet}) diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index ac77f78..cef9cef 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -3222,11 +3222,11 @@ export function AutoRoosterPage() { const [schooljaren, setSchooljaren] = useState([]); const [schooljaar, setSchooljaar] = useState(""); const [groepen, setGroepen] = useState([]); - const [groep, setGroep] = useState(""); + const [gekozenGroepen, setGekozenGroepen] = useState([]); const [activiteiten, setActiviteiten] = useState([]); const [medewerkers, setMedewerkers] = useState([]); const [lokalen, setLokalen] = useState([]); - const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, begeleider: "", lokaal: "", dagdeel: "" }]); + const [wensen, setWensen] = useState([{ activiteit: "", aantal: 1, groepen: [], begeleider: "", lokaal: "", dagdeel: "" }]); const [vrijeDagen, setVrijeDagen] = useState([]); const [maxPerDag, setMaxPerDag] = useState(""); const [voorstel, setVoorstel] = useState(null); @@ -3244,19 +3244,39 @@ export function AutoRoosterPage() { }, []); 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, begeleider: "", lokaal: "", dagdeel: "" }]); + 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 (!groep) { setError("Kies eerst een groep."); return; } + 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), groep: Number(groep), - wensen: geldig.map((w) => ({ activiteit: Number(w.activiteit), aantal: Number(w.aantal), begeleider: w.begeleider || null, lokaal: w.lokaal || null, dagdeel: w.dagdeel || "" })), + 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), }); @@ -3276,32 +3296,32 @@ export function AutoRoosterPage() { begeleiders: v.begeleider ? [v.begeleider] : [], lokaal: v.lokaal || null, }); ok += 1; - } catch (e) { mislukt.push(`${v.activiteit_naam} (${v.tijdslot_label}): ${e.message}`); } + } catch (e) { mislukt.push(`${v.activiteit_naam} ${v.groep_naam} (${v.tijdslot_label}): ${e.message}`); } } setResultaat({ ok, mislukt }); setVoorstel(null); setBezig(false); } - const perDag = {}; - (voorstel || []).forEach((v) => { (perDag[v.dag] = perDag[v.dag] || []).push(v); }); + const groepenInVoorstel = voorstel ? [...new Set(voorstel.map((v) => v.groep_naam))].sort() : []; return (

Automatisch rooster

-

Geef per groep je wensen op; het systeem stelt een conflictvrij voorstel voor (geen dubbele begeleider of lokaal, gespreid over de week). Daarna pas je het gewoon aan in de Roostermaker.

+

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.

-
+
{schooljaren.length > 1 && ( )} - + Klassen: + {groepen.map((g) => ( + + ))} + {groepen.length === 0 && Nog geen klassen.}

Voorkeuren

@@ -3317,7 +3337,7 @@ export function AutoRoosterPage() {

Wensen

- + {wensen.map((w, i) => ( @@ -3327,7 +3347,15 @@ export function AutoRoosterPage() { {activiteiten.map((a) => )} - + +
VakAantal/weekVoorkeur dagdeelBegeleider (optioneel)Lokaal (optioneel)
VakAantal/weekVoor welke klassenVoorkeur dagdeelBegeleiderLokaal
setWens(i, "aantal", e.target.value)} style={{ width: 70 }} /> setWens(i, "aantal", e.target.value)} style={{ width: 64 }} /> +
+ {gekozenGroepen.length === 0 && kies eerst klassen} + {gekozenGroepen.map((gid) => ( + + ))} +
+