From 2c2c33c1161295fee73507799a6907c50250af0c Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:00:47 +0200 Subject: [PATCH] =?UTF-8?q?Groepsindeling=20als=20=C3=A9=C3=A9n=20sleep-ov?= =?UTF-8?q?erzicht=20met=20kleurstructuur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 8 +- backend/config/settings.py | 1 + backend/config/urls.py | 1 + backend/modules/uren/__init__.py | 0 backend/modules/uren/apps.py | 30 +++++++ backend/modules/uren/urls.py | 7 ++ backend/modules/uren/views.py | 133 +++++++++++++++++++++++++++++++ frontend/src/App.jsx | 3 +- frontend/src/pages.jsx | 76 ++++++++++++++++++ frontend/src/styles.css | 2 + 10 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 backend/modules/uren/__init__.py create mode 100644 backend/modules/uren/apps.py create mode 100644 backend/modules/uren/urls.py create mode 100644 backend/modules/uren/views.py diff --git a/ROADMAP.md b/ROADMAP.md index a273576..a6d8e7b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,11 +87,11 @@ volgt. ## Module: Urenoverzicht -- [ ] **Module die berekent hoeveel uur een groep per vak krijgt.** -- [ ] Optie om dit **per leerling** te tonen, **inclusief stage-uren**. +- [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**. - [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de - tijd). -- [ ] **Vrije dagen tellen niet mee** in deze urenberekening. + tijd). _(nog te doen: vereist opslag van momentopnames)_ +- [x] **Vrije dagen tellen niet mee** in deze urenberekening. ## Roostermaker — vrije dagen & blok vervallen diff --git a/backend/config/settings.py b/backend/config/settings.py index 2a77179..a26e417 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -74,6 +74,7 @@ MODULE_APPS = [ "modules.accounts", # Multi-user & toegang "modules.leerplein", # Bovenliggende groepen "modules.stage", # Stage (intern/extern) + "modules.uren", # Urenoverzicht ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS diff --git a/backend/config/urls.py b/backend/config/urls.py index 06167ea..e680b99 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -11,5 +11,6 @@ urlpatterns = [ # Module-API's. path("api/", include("modules.leerplein.urls")), path("api/", include("modules.stage.urls")), + path("api/", include("modules.uren.urls")), path("api/", include("modules.accounts.urls")), ] diff --git a/backend/modules/uren/__init__.py b/backend/modules/uren/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/uren/apps.py b/backend/modules/uren/apps.py new file mode 100644 index 0000000..e88f051 --- /dev/null +++ b/backend/modules/uren/apps.py @@ -0,0 +1,30 @@ +from django.apps import AppConfig + + +class UrenConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.uren" + label = "module_uren" + verbose_name = "Module: Urenoverzicht" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="uren", + name="Urenoverzicht", + description=( + "Berekent hoeveel uur elke groep per vak krijgt en hoeveel uur " + "elke leerling krijgt (inclusief stage-uren). Vrije dagen, " + "vakanties en studiedagen tellen niet mee." + ), + version="0.1.0", + category="Overzicht", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Urenoverzicht", "/uren", icon="clock", order=30), + ), + ) + ) diff --git a/backend/modules/uren/urls.py b/backend/modules/uren/urls.py new file mode 100644 index 0000000..04cdddc --- /dev/null +++ b/backend/modules/uren/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"), +] diff --git a/backend/modules/uren/views.py b/backend/modules/uren/views.py new file mode 100644 index 0000000..53907dd --- /dev/null +++ b/backend/modules/uren/views.py @@ -0,0 +1,133 @@ +"""Urenoverzicht: berekent lesuren per groep/vak en per leerling. + +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. +""" +from datetime import timedelta + +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from core.models import KalenderDag, Persoon, Roosterblok, Rol, Schooljaar, Subgroep, Tijdslot + + +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. + 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 + while d <= sj.eind_datum: + wd = d.weekday() + if wd <= 4 and d not in vrije: + lesdagen[wd] += 1 + d += timedelta(days=1) + + blokken = list( + Roosterblok.objects.filter(schooljaar=sj) + .select_related("tijdslot", "activiteit", "groep", "subgroep", "leerling", "leerling__groep") + .prefetch_related("uitzonderingen") + ) + subgroepen = {s.id: s for s in Subgroep.objects.prefetch_related("leden").all()} + 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 occurrences(blok): + wd = blok.tijdslot.dag + basis = lesdagen.get(wd, 0) + 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: + weg += 1 + return max(basis - weg, 0) + + def groep_van_blok(b): + if b.groep_id: + return b.groep + if b.subgroep_id: + s = subgroepen.get(b.subgroep_id) + return s.groep if s else None + if b.leerling_id: + return b.leerling.groep + return None + + blok_uren = {} + groep_map = {} + for b in blokken: + u = _duur_uren(b.tijdslot) * occurrences(b) + blok_uren[b.id] = u + g = groep_van_blok(b) + if g: + gm = groep_map.setdefault(g.id, {"naam": g.naam, "vakken": {}}) + gm["vakken"][b.activiteit.naam] = gm["vakken"].get(b.activiteit.naam, 0) + u + + per_groep = [] + for gm in groep_map.values(): + vakken = [{"vak": k, "uren": round(v, 1)} for k, v in sorted(gm["vakken"].items())] + per_groep.append({ + "groep": gm["naam"], + "totaal": round(sum(x["uren"] for x in vakken), 1), + "vakken": vakken, + }) + per_groep.sort(key=lambda x: x["groep"]) + + # Stage-uren (module optioneel). + stages = [] + alle_ts = [] + try: + from modules.stage.models import Stage + alle_ts = list(Tijdslot.objects.all()) + stages = list(Stage.objects.filter(actief=True)) + except Exception: + pass + + per_leerling = [] + for l in leerlingen: + vakken = {} + for b in blokken: + hoort = ( + (b.groep_id and l.groep_id == b.groep_id) + or (b.subgroep_id and l.id in sub_leden.get(b.subgroep_id, set())) + or (b.leerling_id == l.id) + ) + if hoort: + vakken[b.activiteit.naam] = vakken.get(b.activiteit.naam, 0) + blok_uren[b.id] + stage_uren = 0 + for st in stages: + if st.leerling_id != l.id: + continue + for ts in alle_ts: + if st.dekt_tijdslot(ts): + stage_uren += _duur_uren(ts) * lesdagen.get(ts.dag, 0) + vak_list = [{"vak": k, "uren": round(v, 1)} for k, v in sorted(vakken.items())] + totaal = sum(x["uren"] for x in vak_list) + stage_uren + if vak_list or stage_uren: + per_leerling.append({ + "leerling": l.volledige_naam, + "groep": l.groep.naam if l.groep else None, + "vakken": vak_list, + "stage_uren": round(stage_uren, 1), + "totaal": round(totaal, 1), + }) + per_leerling.sort(key=lambda x: x["leerling"]) + + return Response({ + "schooljaar": sj.naam, + "lesdagen": lesdagen, + "per_groep": per_groep, + "per_leerling": per_leerling, + }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e2d43ba..aa2afbd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,7 +5,7 @@ import { PersonenPage, FunctiesPage, ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, RoosterWeergavePage, RoostermakerPage, - AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, + AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, } from "./pages.jsx"; const ICONS = { @@ -34,6 +34,7 @@ const PAGES = { "/afwezigheid": AfwezigheidPage, "/stage": StagePage, "/gebruikers": GebruikersPage, + "/uren": UrenPage, }; export default function App() { diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 4ad73d8..512e80a 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -2488,3 +2488,79 @@ export function RoosterScherm({ mode }) { // Twee schermen op basis van dezelfde component. export function RoosterWeergavePage() { return ; } export function RoostermakerPage() { return ; } + +/* -------------------------------- Urenoverzicht --------------------------- */ +export function UrenPage() { + const [schooljaren, setSchooljaren] = useState([]); + const [schooljaar, setSchooljaar] = useState(""); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + listResource("schooljaren") + .then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }) + .catch((e) => setError(e.message)); + }, []); + useEffect(() => { + if (!schooljaar) { setData(null); return; } + setLoading(true); + listResource("urenoverzicht", `?schooljaar=${schooljaar}`) + .then((d) => { setData(d); setError(null); }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [schooljaar]); + + 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.

+ +
+ +
+ {loading ?

Laden…

: data && ( + <> +

Per groep

+ + + + {data.per_groep.length === 0 && } + {data.per_groep.flatMap((g) => [ + ...g.vakken.map((v, i) => ( + + + + + + )), + + + , + ])} + +
GroepVakUren (schooljaar)
Geen lesblokken gevonden.
{i === 0 ? {g.groep} : ""}{vakIcoon(v.vak)} {v.vak}{v.uren} u
totaal{g.totaal} u
+

Per leerling

+ + + + {data.per_leerling.length === 0 && } + {data.per_leerling.map((l) => ( + + + + + + + + ))} + +
LeerlingGroepVakkenStageTotaal
Geen leerlingen met uren.
{l.leerling}{l.groep || "—"}{l.vakken.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}{l.stage_uren ? `${l.stage_uren} u` : "—"}{l.totaal} u
+ + )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3ebf537..90afec3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -420,3 +420,5 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .chip-sel { border: 0; background: transparent; cursor: pointer; font-size: 13px; padding: 0; line-height: 1; color: inherit; } .chip-persoon.geselecteerd { outline: 2px solid var(--brand); outline-offset: 1px; } .icoon-input { width: 60px; text-align: center; } + +.uren-totaal td { background: var(--bg); }