From 6afe007b228c7622d0c9a2dfdafbeb5ff7dbb393 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:26:12 +0200 Subject: [PATCH] Split-subgroep fix; vrije dagen niet bruikbaar in roostermaker; uren-geschiedenis (momentopnames, migratie module_uren 0001) --- ROADMAP.md | 9 +-- backend/modules/uren/admin.py | 9 +++ .../modules/uren/migrations/0001_initial.py | 28 ++++++++ backend/modules/uren/migrations/__init__.py | 0 backend/modules/uren/models.py | 19 +++++ backend/modules/uren/urls.py | 1 + backend/modules/uren/views.py | 72 +++++++++++++------ frontend/src/pages.jsx | 58 +++++++++++++-- frontend/src/styles.css | 4 ++ 9 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 backend/modules/uren/admin.py create mode 100644 backend/modules/uren/migrations/0001_initial.py create mode 100644 backend/modules/uren/migrations/__init__.py create mode 100644 backend/modules/uren/models.py diff --git a/ROADMAP.md b/ROADMAP.md index f92c11b..ac5fac2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,8 +90,8 @@ volgt. - [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] **Filters op de uren-tabellen** (per groep, per vak, zoek op leerling). -- [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de - tijd). _(nog te doen: vereist opslag van momentopnames)_ +- [x] **Geschiedenis** per leerling: momentopnames opslaan (model + UrenMomentopname) en een tabel leerling × datum met totaal-uren. - [x] **Vrije dagen tellen niet mee** in deze urenberekening. ## Roostermaker — vrije dagen & blok vervallen @@ -102,8 +102,9 @@ volgt. dag laten vervallen, blijft lesdag) en ➕ (eenmalig blok op die dag), zonder gevolgen voor andere dagen. -- [ ] **Vrije/vakantie-/studiedagen kunnen niet gebruikt worden** in de - Roostermaker (geen blokken plannen op die dagen). +- [x] **Vrije/vakantie-/studiedagen niet bruikbaar** in de Roostermaker: een + 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] 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). diff --git a/backend/modules/uren/admin.py b/backend/modules/uren/admin.py new file mode 100644 index 0000000..f0cbfea --- /dev/null +++ b/backend/modules/uren/admin.py @@ -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",) diff --git a/backend/modules/uren/migrations/0001_initial.py b/backend/modules/uren/migrations/0001_initial.py new file mode 100644 index 0000000..51b5e9b --- /dev/null +++ b/backend/modules/uren/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/backend/modules/uren/migrations/__init__.py b/backend/modules/uren/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/uren/models.py b/backend/modules/uren/models.py new file mode 100644 index 0000000..0198c0a --- /dev/null +++ b/backend/modules/uren/models.py @@ -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}" diff --git a/backend/modules/uren/urls.py b/backend/modules/uren/urls.py index 04cdddc..f063d89 100644 --- a/backend/modules/uren/urls.py +++ b/backend/modules/uren/urls.py @@ -4,4 +4,5 @@ from . import views urlpatterns = [ path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"), + path("uren-momentopnames/", views.momentopnames, name="uren-momentopnames"), ] diff --git a/backend/modules/uren/views.py b/backend/modules/uren/views.py index 4359736..42a3a27 100644 --- a/backend/modules/uren/views.py +++ b/backend/modules/uren/views.py @@ -2,7 +2,8 @@ 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), -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 @@ -11,23 +12,16 @@ from rest_framework.response import Response from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot +from .models import UrenMomentopname + def _duur_uren(ts): s, e = ts.start_tijd, ts.eind_tijd return ((e.hour * 60 + e.minute) - (s.hour * 60 + s.minute)) / 60.0 -@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) - - # Lesdagen per weekdag (ma–vr), vrije dagen uitgezonderd. +def bereken_uren(sj): + """Bereken het volledige urenoverzicht voor een schooljaar (dict).""" vrije = set(KalenderDag.objects.filter(schooljaar=sj).values_list("datum", flat=True)) lesdagen = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0} d = sj.start_datum @@ -48,16 +42,15 @@ def urenoverzicht(request): def _tel_weekdag(van, tot, wd): n = 0 - d = van - while d <= tot: - if d.weekday() == wd and d not in vrije: + d2 = van + while d2 <= tot: + if d2.weekday() == wd and d2 not in vrije: n += 1 - d += timedelta(days=1) + d2 += timedelta(days=1) return n def occurrences(blok): wd = blok.tijdslot.dag - # Geldigheidsperiode binnen het schooljaar clampen. van = max(blok.geldig_van or sj.start_datum, sj.start_datum) tot = min(blok.geldig_tot or sj.eind_datum, sj.eind_datum) if van > tot: @@ -102,7 +95,6 @@ def urenoverzicht(request): }) per_groep.sort(key=lambda x: x["groep"]) - # Stage-uren (module optioneel). stages = [] alle_ts = [] try: @@ -142,9 +134,49 @@ def urenoverzicht(request): }) per_leerling.sort(key=lambda x: x["leerling"]) - return Response({ + return { "schooljaar": sj.naam, "lesdagen": lesdagen, "per_groep": per_groep, "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}) diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index baaf8d1..5a953e4 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -1560,6 +1560,10 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { const [subgroepGroepFilter, setSubgroepGroepFilter] = useState(() => ( 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" ? (f.doel ? Number(f.doel) : null) : f.doel_type === "subgroep" @@ -1567,14 +1571,16 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { : bovenliggendeGroep(blok, 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 splitGroep = gekozenGroep || blok.groep || groepVanSubgroep(subs, blok.subgroep) || null; - const splitOpties = splitGroep ? subs.filter((s) => s.groep === splitGroep) : subs; - const splitGroepNaam = splitGroep ? (groepen.find((g) => g.id === splitGroep)?.naam || "groep") : "groep"; + const splitGroepNum = splitGroepId ? Number(splitGroepId) : null; + const splitOpties = splitGroepNum ? subs.filter((s) => s.groep === splitGroepNum) : []; + const splitGroepNaam = splitGroepNum ? (groepen.find((g) => g.id === splitGroepNum)?.naam || "groep") : "(kies groep)"; async function maakSubgroep() { if (!nieuweSub.trim()) return; + const g = subgroepGroepFilter || gekozenGroep; + if (!g) { setError("Kies eerst een groep voor de nieuwe subgroep."); return; } 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]); setSubgroepGroepFilter(s.groep || ""); 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]); async function maakSplitSub() { if (!splitNieuw.trim()) return; + if (!splitGroepNum) { setError("Kies eerst een groep om onder te splitsen."); return; } try { - const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroep }); + const s = await createResource("subgroepen", { naam: splitNieuw.trim(), groep: splitGroepNum }); setSubs([...subs, s]); setSplitSubs([...splitSubs, s.id]); setSplitNieuw(""); @@ -1698,6 +1705,12 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
Splitsen onder {splitGroepNaam} +
+ +
{splitOpties.map((s) => )} {splitOpties.length === 0 && Nog geen subgroepen onder deze groep.} @@ -1969,6 +1982,7 @@ export function RoosterScherm({ mode }) { dragRef.current = null; setGhost(null); setHoverSlot(null); + if (cell && cell.dataset.vrij) { setError("Op een vrije/vakantiedag kun je geen blok plaatsen."); return; } if (doel && doel !== blok.tijdslot) { try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); } catch (err) { setError(err.message); } @@ -2485,6 +2499,7 @@ export function RoosterScherm({ mode }) {
{t.start_tijd?.slice(0, 5)} {t.naam}
@@ -2544,6 +2559,8 @@ export function UrenPage() { const [groepFilter, setGroepFilter] = useState(""); const [vakFilter, setVakFilter] = useState(""); const [leerlingFilter, setLeerlingFilter] = useState(""); + const [historie, setHistorie] = useState(null); + const [bewaarBusy, setBewaarBusy] = useState(false); useEffect(() => { listResource("schooljaren") @@ -2558,6 +2575,18 @@ export function UrenPage() { .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, [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 groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : []; @@ -2634,6 +2663,25 @@ export function UrenPage() { ))} +
+

Geschiedenis

+ +
+ {historie && historie.momentopnames.length > 0 ? ( + + {historie.momentopnames.map((m) => )} + + {historie.leerlingen + .filter((r) => !leerlingFilter || r.leerling.toLowerCase().includes(leerlingFilter.toLowerCase())) + .map((r) => ( + + + {historie.momentopnames.map((m) => )} + + ))} + +
Leerling{new Date(m.gemaakt_op).toLocaleDateString("nl-NL")}
{r.leerling}{r.totalen[m.id] != null ? `${r.totalen[m.id]} u` : "—"}
+ ) :

Nog geen momentopnames. Klik op "Bewaar huidige stand" om het verloop in de tijd te volgen.

} )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2643678..3eb7049 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } .periode-row { align-items: flex-end; gap: 12px; flex-wrap: wrap; } .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; }