auto-rooster voor meerdere klassen + vak voor meerdere klassen (gedeelde begeleider/lokaal)

This commit is contained in:
bes-r 2026-06-17 15:44:33 +02:00
parent 54a9dd5dc2
commit 170a036c4e
4 changed files with 173 additions and 117 deletions

View file

@ -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

View file

@ -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})

View file

@ -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 (
<section className="autorooster">
<h1>Automatisch rooster</h1>
<p className="muted">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.</p>
<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">
<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>
)}
<select value={groep} onChange={(e) => setGroep(e.target.value)}>
<option value=""> kies groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.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>
@ -3317,7 +3337,7 @@ export function AutoRoosterPage() {
<h2>Wensen</h2>
<table className="grid">
<thead><tr><th>Vak</th><th>Aantal/week</th><th>Voorkeur dagdeel</th><th>Begeleider (optioneel)</th><th>Lokaal (optioneel)</th><th></th></tr></thead>
<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}>
@ -3327,7 +3347,15 @@ export function AutoRoosterPage() {
{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: 70 }} /></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>
@ -3366,31 +3394,39 @@ export function AutoRoosterPage() {
{voorstel && (
<div className="voorstel">
<h2>Voorstel ({voorstel.length} blokken)</h2>
<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.tekort} te kort)`).join(", ")}. Voeg tijdsloten toe of verlaag het aantal.</div>
<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> : (
<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>
);
})}
{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>
);
})}
<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>
@ -3401,6 +3437,7 @@ export function AutoRoosterPage() {
);
}
/* --------------------------- Wachtwoord opnieuw --------------------------- */
export function WachtwoordResetPage({ uid, token }) {
const [w1, setW1] = useState("");

View file

@ -489,3 +489,6 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.banner.ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; padding: 10px 14px; border-radius: 8px; }
.chip.chip-plein { border-style: dashed; }
.voorstel-klas { margin-bottom: 18px; }
.voorstel-klas h3 { margin: 8px 0 6px; }