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] 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).

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 = [
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
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 (mavr), 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})

View file

@ -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 }) {
<details className="editor-detail split-editor" open>
<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">
{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>}
@ -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 }) {
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id}
data-vrij={kal ? "1" : undefined}
key={t.id}
>
<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 [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() {
))}
</tbody>
</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>

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; }
.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; }