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.