From fc651c2f9d6cfb83be944a33bd9e161fa5e086fa Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:12:52 +0200 Subject: [PATCH] Uren-filters; roosterblok geldig van/tot (periode + eenmalig); dag leegmaken + eenmalig alternatief (migratie core 0008) --- ROADMAP.md | 7 ++ ...rblok_geldig_tot_roosterblok_geldig_van.py | 23 ++++ backend/core/models.py | 6 ++ backend/core/serializers.py | 1 + backend/modules/uren/views.py | 21 +++- frontend/src/pages.jsx | 101 +++++++++++++++--- frontend/src/styles.css | 3 + 7 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 backend/core/migrations/0008_roosterblok_geldig_tot_roosterblok_geldig_van.py diff --git a/ROADMAP.md b/ROADMAP.md index a6d8e7b..f92c11b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -89,12 +89,19 @@ 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] **Vrije dagen tellen niet mee** in deze urenberekening. ## Roostermaker — vrije dagen & blok vervallen +- [x] **Vak met geldigheidsperiode**: een blok kan gelden **van/tot** een datum + (leeg = altijd). `geldig_van == geldig_tot` = eenmalig op die dag. +- [x] **Dag leegmaken + eenmalig alternatief**: per dag knop 🧹 (alle blokken díe + 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] De actie heet nu **"dit vak laten vervallen"** (per blok, juiste tekst). diff --git a/backend/core/migrations/0008_roosterblok_geldig_tot_roosterblok_geldig_van.py b/backend/core/migrations/0008_roosterblok_geldig_tot_roosterblok_geldig_van.py new file mode 100644 index 0000000..4433baa --- /dev/null +++ b/backend/core/migrations/0008_roosterblok_geldig_tot_roosterblok_geldig_van.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-06-16 21:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_activiteit_icoon'), + ] + + operations = [ + migrations.AddField( + model_name='roosterblok', + name='geldig_tot', + field=models.DateField(blank=True, null=True, verbose_name='geldig tot en met'), + ), + migrations.AddField( + model_name='roosterblok', + name='geldig_van', + field=models.DateField(blank=True, null=True, verbose_name='geldig vanaf'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 4c55303..29550df 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -406,6 +406,10 @@ class Roosterblok(models.Model): Persoon, related_name="begeleider_roosterblokken", blank=True, ) opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True) + # Geldigheidsperiode: leeg = altijd (wekelijks). Gevuld = alleen binnen dit + # datumbereik. geldig_van == geldig_tot betekent een eenmalig blok op die dag. + geldig_van = models.DateField("geldig vanaf", null=True, blank=True) + geldig_tot = models.DateField("geldig tot en met", null=True, blank=True) aangemaakt_op = models.DateTimeField(auto_now_add=True) class Meta: @@ -420,6 +424,8 @@ class Roosterblok(models.Model): raise ValidationError( "Kies precies één doelgroep: groep, subgroep of leerling." ) + if self.geldig_van and self.geldig_tot and self.geldig_tot < self.geldig_van: + raise ValidationError("'geldig tot' ligt vóór 'geldig vanaf'.") @property def doel_type(self) -> str: diff --git a/backend/core/serializers.py b/backend/core/serializers.py index d7739a4..90e4772 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -222,6 +222,7 @@ class RoosterblokSerializer(serializers.ModelSerializer): "activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", "locatie", "locatie_naam", "lokaal", "lokaal_naam", "groep", "subgroep", "leerling", "doel_type", "doel_naam", + "geldig_van", "geldig_tot", "begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] diff --git a/backend/modules/uren/views.py b/backend/modules/uren/views.py index 53907dd..4359736 100644 --- a/backend/modules/uren/views.py +++ b/backend/modules/uren/views.py @@ -46,12 +46,29 @@ def urenoverzicht(request): sub_leden = {sid: set(s.leden.values_list("id", flat=True)) for sid, s in subgroepen.items()} leerlingen = list(Persoon.objects.filter(rol=Rol.LEERLING).select_related("groep")) + def _tel_weekdag(van, tot, wd): + n = 0 + d = van + while d <= tot: + if d.weekday() == wd and d not in vrije: + n += 1 + d += timedelta(days=1) + return n + def occurrences(blok): wd = blok.tijdslot.dag - basis = lesdagen.get(wd, 0) + # 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: + return 0 + if blok.geldig_van is None and blok.geldig_tot is None: + basis = lesdagen.get(wd, 0) + else: + basis = _tel_weekdag(van, tot, wd) weg = 0 for u in blok.uitzonderingen.all(): - if u.vervalt and sj.start_datum <= u.datum <= sj.eind_datum and u.datum.weekday() == wd and u.datum not in vrije: + if u.vervalt and van <= u.datum <= tot and u.datum.weekday() == wd and u.datum not in vrije: weg += 1 return max(basis - weg, 0) diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 512e80a..baaf8d1 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -1547,6 +1547,8 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { doel: blok.groep || blok.subgroep || blok.leerling || "", begeleiders: [...(blok.begeleiders || [])], opmerkingen: blok.opmerkingen || "", + geldig_van: blok.geldig_van || "", + geldig_tot: blok.geldig_tot || "", }); const [kopieDoelen, setKopieDoelen] = useState([]); const [error, setError] = useState(null); @@ -1593,7 +1595,7 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { if (splitSubs.length === 0) return; try { for (const sid of splitSubs) { - await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); + await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); } if (verwijderOrig) await deleteResource("roosterblokken", blok.id); await onKlaar(); @@ -1607,13 +1609,13 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { leerling: f.doel_type === "leerling" ? f.doel : null, }); async function opslaan() { - try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } + try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); await onKlaar(); } catch (e) { setError(e.message); } } async function kopieer() { try { for (const ts of kopieDoelen) { - await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); + await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); } await onKlaar(); } catch (e) { setError(e.message); } @@ -1654,6 +1656,8 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { {lokalen.map((l) => )} setF({ ...f, opmerkingen: e.target.value })} /> + setF({ ...f, geldig_van: e.target.value })} /> + setF({ ...f, geldig_tot: e.target.value })} />
@@ -1883,7 +1887,7 @@ export function RoosterScherm({ mode }) { // Blok bewerken/kopiëren (alleen in de Roostermaker). const [bewerkBlok, setBewerkBlok] = useState(null); - const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; + const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "", geldig_van: "", geldig_tot: "" }; const [form, setForm] = useState(leeg); const [splitNieuwBlok, setSplitNieuwBlok] = useState(false); const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]); @@ -2025,7 +2029,7 @@ export function RoosterScherm({ mode }) { } async function opslaan(e) { e.preventDefault(); - const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; + const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen, geldig_van: form.geldig_van || null, geldig_tot: form.geldig_tot || null }; try { if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) { for (const subgroep of splitNieuwDoelen) { @@ -2064,6 +2068,37 @@ export function RoosterScherm({ mode }) { catch (err) { setError(err.message); } } + // Geldt dit blok op deze datum (periode / eenmalig)? + function blokGeldigOp(b, iso) { + if (b.geldig_van && iso < b.geldig_van) return false; + if (b.geldig_tot && iso > b.geldig_tot) return false; + return true; + } + // Maak één specifieke dag leeg: laat alle blokken die dag vervallen (alleen + // deze datum), zonder gevolgen voor andere dagen. + async function leegDag(iso) { + const ourWd = (new Date(iso).getDay() + 6) % 7; + const dagBlokken = blokken.filter((b) => { + const t = tijdsloten.find((x) => x.id === b.tijdslot); + if (!t || t.dag !== ourWd || !blokGeldigOp(b, iso)) return false; + const u = uitzOp(b.id, iso); + return !(u && u.vervalt); + }); + if (dagBlokken.length === 0) { setError("Geen blokken om te laten vervallen op deze dag."); return; } + if (!confirm(`Alle ${dagBlokken.length} blok(ken) op ${iso} laten vervallen? Dit geldt alleen voor deze dag.`)) return; + try { + for (const b of dagBlokken) await createResource("blok-uitzonderingen", { roosterblok: b.id, datum: iso, vervalt: true }); + await laadWeek(); + } catch (err) { setError(err.message); } + } + // Voeg een eenmalig (alleen vandaag geldig) blok toe op deze dag. + function openEenmalig(iso) { + setBewerkBlok(null); + setForm({ ...leeg, geldig_van: iso, geldig_tot: iso }); + resetNieuweSplit(); + setToonForm(true); + } + // Hulpfuncties voor het effectieve rooster. const kalenderOp = (iso) => kalender.find((k) => k.datum === iso); const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso); @@ -2357,6 +2392,12 @@ export function RoosterScherm({ mode }) { setForm({ ...form, opmerkingen: e.target.value })} /> +
+ + + {form.geldig_van && form.geldig_van === form.geldig_tot && eenmalig op {form.geldig_van}} + {(form.geldig_van || form.geldig_tot) && } +
Doelgroep @@ -2430,12 +2471,16 @@ export function RoosterScherm({ mode }) { {kal &&
{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}
} {edit && (kal ? - : + : <> + + + + )} {slots.length === 0 &&
} {slots.map((t) => { - const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id); + const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso)); return (
{ listResource("schooljaren") @@ -2511,16 +2559,43 @@ export function UrenPage() { .finally(() => setLoading(false)); }, [schooljaar]); + const r1 = (n) => Math.round(n * 10) / 10; + const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : []; + const vakNamen = data ? [...new Set(data.per_groep.flatMap((g) => g.vakken.map((v) => v.vak)))].sort() : []; + + const perGroep = (data ? data.per_groep : []) + .filter((g) => !groepFilter || g.groep === groepFilter) + .map((g) => { const vakken = vakFilter ? g.vakken.filter((v) => v.vak === vakFilter) : g.vakken; return { ...g, vakken, totaal: r1(vakken.reduce((a, v) => a + v.uren, 0)) }; }) + .filter((g) => g.vakken.length > 0); + + const perLeerling = (data ? data.per_leerling : []) + .filter((l) => !leerlingFilter || l.leerling.toLowerCase().includes(leerlingFilter.toLowerCase())) + .filter((l) => !groepFilter || l.groep === groepFilter) + .filter((l) => !vakFilter || l.vakken.some((v) => v.vak === vakFilter)) + .map((l) => { const vakken = vakFilter ? l.vakken.filter((v) => v.vak === vakFilter) : l.vakken; return { ...l, vakkenWeergave: vakken }; }); + + const filterActief = groepFilter || vakFilter || leerlingFilter; + return (

Urenoverzicht

Lesuren per groep en per leerling over het hele schooljaar. Vrije dagen, vakanties en studiedagen tellen niet mee; vervallen blokken zijn afgetrokken. Stage-uren tellen mee bij de leerling.

-
+
+ + + setLeerlingFilter(e.target.value)} /> + {filterActief && }
{loading ?

Laden…

: data && ( <> @@ -2528,8 +2603,8 @@ export function UrenPage() { - {data.per_groep.length === 0 && } - {data.per_groep.flatMap((g) => [ + {perGroep.length === 0 && } + {perGroep.flatMap((g) => [ ...g.vakken.map((v, i) => ( @@ -2547,12 +2622,12 @@ export function UrenPage() {
GroepVakUren (schooljaar)
Geen lesblokken gevonden.
Geen resultaten.
{i === 0 ? {g.groep} : ""}
- {data.per_leerling.length === 0 && } - {data.per_leerling.map((l) => ( + {perLeerling.length === 0 && } + {perLeerling.map((l) => ( - + diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 90afec3..2643678 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -422,3 +422,6 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .icoon-input { width: 60px; text-align: center; } .uren-totaal td { background: var(--bg); } +.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; }
LeerlingGroepVakkenStageTotaal
Geen leerlingen met uren.
Geen resultaten.
{l.leerling} {l.groep || "—"}{l.vakken.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}{l.vakkenWeergave.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"} {l.stage_uren ? `${l.stage_uren} u` : "—"} {l.totaal} u