Split-subgroep fix; vrije dagen niet bruikbaar in roostermaker; uren-geschiedenis (momentopnames, migratie module_uren 0001)

This commit is contained in:
bes-r 2026-06-16 23:26:12 +02:00
parent fc651c2f9d
commit 6afe007b22
9 changed files with 171 additions and 29 deletions

View file

@ -90,8 +90,8 @@ volgt.
- [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/) - [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/)
- [x] Optie om dit **per leerling** te tonen, **inclusief stage-uren**. - [x] Optie om dit **per leerling** te tonen, **inclusief stage-uren**.
- [x] **Filters op de uren-tabellen** (per groep, per vak, zoek op leerling). - [x] **Filters op de uren-tabellen** (per groep, per vak, zoek op leerling).
- [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de - [x] **Geschiedenis** per leerling: momentopnames opslaan (model
tijd). _(nog te doen: vereist opslag van momentopnames)_ UrenMomentopname) en een tabel leerling × datum met totaal-uren.
- [x] **Vrije dagen tellen niet mee** in deze urenberekening. - [x] **Vrije dagen tellen niet mee** in deze urenberekening.
## Roostermaker — vrije dagen & blok vervallen ## Roostermaker — vrije dagen & blok vervallen
@ -102,8 +102,9 @@ volgt.
dag laten vervallen, blijft lesdag) en (eenmalig blok op die dag), zonder dag laten vervallen, blijft lesdag) en (eenmalig blok op die dag), zonder
gevolgen voor andere dagen. gevolgen voor andere dagen.
- [ ] **Vrije/vakantie-/studiedagen kunnen niet gebruikt worden** in de - [x] **Vrije/vakantie-/studiedagen niet bruikbaar** in de Roostermaker: een
Roostermaker (geen blokken plannen op die dagen). blok slepen naar een vrije dag wordt geweigerd; vrije dagkolommen zijn
gearceerd; dag-acties (leegmaken/eenmalig) verschijnen niet op vrije dagen.
- [x] De actie heet nu **"dit vak laten vervallen"** (per blok, juiste tekst). - [x] De actie heet nu **"dit vak laten vervallen"** (per blok, juiste tekst).
- [x] Een **hele dag laten vervallen** kan direct in het weekrooster (knop in de - [x] Een **hele dag laten vervallen** kan direct in het weekrooster (knop in de
dagkop; maakt een vrije dag aan, met "weer lesdag" om te herstellen). dagkop; maakt een vrije dag aan, met "weer lesdag" om te herstellen).

View file

@ -0,0 +1,9 @@
from django.contrib import admin
from .models import UrenMomentopname
@admin.register(UrenMomentopname)
class UrenMomentopnameAdmin(admin.ModelAdmin):
list_display = ("schooljaar", "gemaakt_op")
list_filter = ("schooljaar",)

View file

@ -0,0 +1,28 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="UrenMomentopname",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("gemaakt_op", models.DateTimeField(auto_now_add=True)),
("payload", models.JSONField(default=dict)),
("schooljaar", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="uren_momentopnames", to="core.schooljaar")),
],
options={
"verbose_name": "uren-momentopname",
"verbose_name_plural": "uren-momentopnames",
"ordering": ["-gemaakt_op"],
},
),
]

View file

@ -0,0 +1,19 @@
from django.db import models
class UrenMomentopname(models.Model):
"""Een opgeslagen momentopname van het urenoverzicht (voor geschiedenis)."""
schooljaar = models.ForeignKey(
"core.Schooljaar", on_delete=models.CASCADE, related_name="uren_momentopnames"
)
gemaakt_op = models.DateTimeField(auto_now_add=True)
payload = models.JSONField(default=dict)
class Meta:
verbose_name = "uren-momentopname"
verbose_name_plural = "uren-momentopnames"
ordering = ["-gemaakt_op"]
def __str__(self) -> str:
return f"Urenoverzicht {self.schooljaar} @ {self.gemaakt_op:%Y-%m-%d %H:%M}"

View file

@ -4,4 +4,5 @@ from . import views
urlpatterns = [ urlpatterns = [
path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"), path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"),
path("uren-momentopnames/", views.momentopnames, name="uren-momentopnames"),
] ]

View file

@ -2,7 +2,8 @@
Roosterblokken zijn wekelijks: per schooljaar telt een blok zo vaak mee als er Roosterblokken zijn wekelijks: per schooljaar telt een blok zo vaak mee als er
lesdagen op die weekdag zijn (vrije dagen/vakanties/studiedagen tellen niet mee), lesdagen op die weekdag zijn (vrije dagen/vakanties/studiedagen tellen niet mee),
minus de keren dat het blok via een uitzondering vervalt. minus de keren dat het blok via een uitzondering vervalt. Een blok met een
geldigheidsperiode (geldig_van/geldig_tot) telt alleen binnen dat bereik mee.
""" """
from datetime import timedelta from datetime import timedelta
@ -11,23 +12,16 @@ from rest_framework.response import Response
from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot
from .models import UrenMomentopname
def _duur_uren(ts): def _duur_uren(ts):
s, e = ts.start_tijd, ts.eind_tijd s, e = ts.start_tijd, ts.eind_tijd
return ((e.hour * 60 + e.minute) - (s.hour * 60 + s.minute)) / 60.0 return ((e.hour * 60 + e.minute) - (s.hour * 60 + s.minute)) / 60.0
@api_view(["GET"]) def bereken_uren(sj):
def urenoverzicht(request): """Bereken het volledige urenoverzicht voor een schooljaar (dict)."""
sjid = request.query_params.get("schooljaar")
if not sjid:
return Response({"detail": "Geef een schooljaar op."}, status=400)
try:
sj = Schooljaar.objects.get(pk=sjid)
except Schooljaar.DoesNotExist:
return Response({"detail": "Schooljaar niet gevonden."}, status=404)
# Lesdagen per weekdag (mavr), vrije dagen uitgezonderd.
vrije = set(KalenderDag.objects.filter(schooljaar=sj).values_list("datum", flat=True)) vrije = set(KalenderDag.objects.filter(schooljaar=sj).values_list("datum", flat=True))
lesdagen = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0} lesdagen = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0}
d = sj.start_datum d = sj.start_datum
@ -48,16 +42,15 @@ def urenoverzicht(request):
def _tel_weekdag(van, tot, wd): def _tel_weekdag(van, tot, wd):
n = 0 n = 0
d = van d2 = van
while d <= tot: while d2 <= tot:
if d.weekday() == wd and d not in vrije: if d2.weekday() == wd and d2 not in vrije:
n += 1 n += 1
d += timedelta(days=1) d2 += timedelta(days=1)
return n return n
def occurrences(blok): def occurrences(blok):
wd = blok.tijdslot.dag wd = blok.tijdslot.dag
# Geldigheidsperiode binnen het schooljaar clampen.
van = max(blok.geldig_van or sj.start_datum, sj.start_datum) van = max(blok.geldig_van or sj.start_datum, sj.start_datum)
tot = min(blok.geldig_tot or sj.eind_datum, sj.eind_datum) tot = min(blok.geldig_tot or sj.eind_datum, sj.eind_datum)
if van > tot: if van > tot:
@ -102,7 +95,6 @@ def urenoverzicht(request):
}) })
per_groep.sort(key=lambda x: x["groep"]) per_groep.sort(key=lambda x: x["groep"])
# Stage-uren (module optioneel).
stages = [] stages = []
alle_ts = [] alle_ts = []
try: try:
@ -142,9 +134,49 @@ def urenoverzicht(request):
}) })
per_leerling.sort(key=lambda x: x["leerling"]) per_leerling.sort(key=lambda x: x["leerling"])
return Response({ return {
"schooljaar": sj.naam, "schooljaar": sj.naam,
"lesdagen": lesdagen, "lesdagen": lesdagen,
"per_groep": per_groep, "per_groep": per_groep,
"per_leerling": per_leerling, "per_leerling": per_leerling,
}) }
@api_view(["GET"])
def urenoverzicht(request):
sjid = request.query_params.get("schooljaar")
if not sjid:
return Response({"detail": "Geef een schooljaar op."}, status=400)
try:
sj = Schooljaar.objects.get(pk=sjid)
except Schooljaar.DoesNotExist:
return Response({"detail": "Schooljaar niet gevonden."}, status=404)
return Response(bereken_uren(sj))
@api_view(["GET", "POST"])
def momentopnames(request):
"""GET: lijst van momentopnames (samenvatting). POST: bewaar huidige stand."""
sjid = request.query_params.get("schooljaar") or request.data.get("schooljaar")
if not sjid:
return Response({"detail": "Geef een schooljaar op."}, status=400)
try:
sj = Schooljaar.objects.get(pk=sjid)
except Schooljaar.DoesNotExist:
return Response({"detail": "Schooljaar niet gevonden."}, status=404)
if request.method == "POST":
payload = bereken_uren(sj)
opname = UrenMomentopname.objects.create(schooljaar=sj, payload=payload)
return Response({"id": opname.id, "gemaakt_op": opname.gemaakt_op}, status=201)
opnames = UrenMomentopname.objects.filter(schooljaar=sj)
# Bouw een leerling x momentopname-tabel met totaal-uren.
kolommen = []
leerling_rijen = {}
for o in opnames:
kolommen.append({"id": o.id, "gemaakt_op": o.gemaakt_op})
for l in (o.payload or {}).get("per_leerling", []):
leerling_rijen.setdefault(l["leerling"], {})[o.id] = l["totaal"]
rijen = [{"leerling": naam, "totalen": tot} for naam, tot in sorted(leerling_rijen.items())]
return Response({"schooljaar": sj.naam, "momentopnames": kolommen, "leerlingen": rijen})

View file

@ -1560,6 +1560,10 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => ( const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => (
blok.subgroep ? groepVanSubgroep(subgroepen, blok.subgroep) || "" : "" 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" const gekozenGroep = f.doel_type === "groep"
? (f.doel ? Number(f.doel) : null) ? (f.doel ? Number(f.doel) : null)
: f.doel_type === "subgroep" : f.doel_type === "subgroep"
@ -1567,14 +1571,16 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
: bovenliggendeGroep(blok, subs); : bovenliggendeGroep(blok, subs);
const subgroepOpties = gekozenGroep ? subs.filter((s) => s.groep === gekozenGroep) : 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 doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepOpties : leerlingen;
const splitGroep = gekozenGroep || blok.groep || groepVanSubgroep(subs, blok.subgroep) || null; const splitGroepNum = splitGroepId ? Number(splitGroepId) : null;
const splitOpties = splitGroep ? subs.filter((s) => s.groep === splitGroep) : subs; const splitOpties = splitGroepNum ? subs.filter((s) => s.groep === splitGroepNum) : [];
const splitGroepNaam = splitGroep ? (groepen.find((g) => g.id === splitGroep)?.naam || "groep") : "groep"; const splitGroepNaam = splitGroepNum ? (groepen.find((g) => g.id === splitGroepNum)?.naam || "groep") : "(kies groep)";
async function maakSubgroep() { async function maakSubgroep() {
if (!nieuweSub.trim()) return; if (!nieuweSub.trim()) return;
const g = subgroepGroepFilter || gekozenGroep;
if (!g) { setError("Kies eerst een groep voor de nieuwe subgroep."); return; }
try { try {
const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: gekozenGroep || null }); const s = await createResource("subgroepen", { naam: nieuweSub.trim(), groep: Number(g) });
setSubs([...subs, s]); setSubs([...subs, s]);
setSubgroepGroepFilter(s.groep || ""); setSubgroepGroepFilter(s.groep || "");
setF({ ...f, doel_type: "subgroep", doel: s.id }); setF({ ...f, doel_type: "subgroep", doel: s.id });
@ -1584,8 +1590,9 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
const toggleSplit = (id) => setSplitSubs((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]); const toggleSplit = (id) => setSplitSubs((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
async function maakSplitSub() { async function maakSplitSub() {
if (!splitNieuw.trim()) return; if (!splitNieuw.trim()) return;
if (!splitGroepNum) { setError("Kies eerst een groep om onder te splitsen."); return; }
try { try {
const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroep }); const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroepNum });
setSubs([...subs, s]); setSubs([...subs, s]);
setSplitSubs([...splitSubs, s.id]); setSplitSubs([...splitSubs, s.id]);
setSplitNieuw(""); setSplitNieuw("");
@ -1698,6 +1705,12 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
<details className="editor-detail split-editor" open> <details className="editor-detail split-editor" open>
<summary>Splitsen onder {splitGroepNaam}</summary> <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"> <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.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>} {splitOpties.length === 0 && <span className="muted small">Nog geen subgroepen onder deze groep.</span>}
@ -1969,6 +1982,7 @@ export function RoosterScherm({ mode }) {
dragRef.current = null; dragRef.current = null;
setGhost(null); setGhost(null);
setHoverSlot(null); setHoverSlot(null);
if (cell && cell.dataset.vrij) { setError("Op een vrije/vakantiedag kun je geen blok plaatsen."); return; }
if (doel && doel !== blok.tijdslot) { if (doel && doel !== blok.tijdslot) {
try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); } try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); }
catch (err) { setError(err.message); } catch (err) { setError(err.message); }
@ -2485,6 +2499,7 @@ export function RoosterScherm({ mode }) {
<div <div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"} className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id} data-tijdslot={t.id}
data-vrij={kal ? "1" : undefined}
key={t.id} key={t.id}
> >
<div className="slot-tijd">{t.start_tijd?.slice(0, 5)} {t.naam}</div> <div className="slot-tijd">{t.start_tijd?.slice(0, 5)} {t.naam}</div>
@ -2544,6 +2559,8 @@ export function UrenPage() {
const [groepFilter, setGroepFilter] = useState(""); const [groepFilter, setGroepFilter] = useState("");
const [vakFilter, setVakFilter] = useState(""); const [vakFilter, setVakFilter] = useState("");
const [leerlingFilter, setLeerlingFilter] = useState(""); const [leerlingFilter, setLeerlingFilter] = useState("");
const [historie, setHistorie] = useState(null);
const [bewaarBusy, setBewaarBusy] = useState(false);
useEffect(() => { useEffect(() => {
listResource("schooljaren") listResource("schooljaren")
@ -2558,6 +2575,18 @@ export function UrenPage() {
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [schooljaar]); }, [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 r1 = (n) => Math.round(n * 10) / 10;
const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : []; const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : [];
@ -2634,6 +2663,25 @@ export function UrenPage() {
))} ))}
</tbody> </tbody>
</table> </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> </section>

View file

@ -425,3 +425,7 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.uren-filters { flex-wrap: wrap; align-items: center; gap: 8px; } .uren-filters { flex-wrap: wrap; align-items: center; gap: 8px; }
.periode-row { align-items: flex-end; gap: 12px; flex-wrap: wrap; } .periode-row { align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.periode-hint { color: #b45309; font-weight: 600; } .periode-hint { color: #b45309; font-weight: 600; }
.rooster-kolom.vrij .slot-cel { opacity: .5; }
.slot-cel[data-vrij] { background: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,.03) 6px, rgba(0,0,0,.03) 12px); }
.historie-kop { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.historie-kop h2 { margin: 0; }