Groepsindeling als één sleep-overzicht met kleurstructuur

This commit is contained in:
bes-r 2026-06-16 23:00:47 +02:00
parent e4a75c9794
commit 2c2c33c116
10 changed files with 256 additions and 5 deletions

View file

@ -87,11 +87,11 @@ volgt.
## Module: Urenoverzicht ## Module: Urenoverzicht
- [ ] **Module die berekent hoeveel uur een groep per vak krijgt.** - [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/)
- [ ] Optie om dit **per leerling** te tonen, **inclusief stage-uren**. - [x] Optie om dit **per leerling** te tonen, **inclusief stage-uren**.
- [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de - [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de
tijd). tijd). _(nog te doen: vereist opslag van momentopnames)_
- [ ] **Vrije dagen tellen niet mee** in deze urenberekening. - [x] **Vrije dagen tellen niet mee** in deze urenberekening.
## Roostermaker — vrije dagen & blok vervallen ## Roostermaker — vrije dagen & blok vervallen

View file

@ -74,6 +74,7 @@ MODULE_APPS = [
"modules.accounts", # Multi-user & toegang "modules.accounts", # Multi-user & toegang
"modules.leerplein", # Bovenliggende groepen "modules.leerplein", # Bovenliggende groepen
"modules.stage", # Stage (intern/extern) "modules.stage", # Stage (intern/extern)
"modules.uren", # Urenoverzicht
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS

View file

@ -11,5 +11,6 @@ urlpatterns = [
# Module-API's. # Module-API's.
path("api/", include("modules.leerplein.urls")), path("api/", include("modules.leerplein.urls")),
path("api/", include("modules.stage.urls")), path("api/", include("modules.stage.urls")),
path("api/", include("modules.uren.urls")),
path("api/", include("modules.accounts.urls")), path("api/", include("modules.accounts.urls")),
] ]

View file

View file

@ -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),
),
)
)

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"),
]

View file

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

View file

@ -5,7 +5,7 @@ import {
PersonenPage, FunctiesPage, PersonenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage, RoosterWeergavePage, RoostermakerPage,
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
} from "./pages.jsx"; } from "./pages.jsx";
const ICONS = { const ICONS = {
@ -34,6 +34,7 @@ const PAGES = {
"/afwezigheid": AfwezigheidPage, "/afwezigheid": AfwezigheidPage,
"/stage": StagePage, "/stage": StagePage,
"/gebruikers": GebruikersPage, "/gebruikers": GebruikersPage,
"/uren": UrenPage,
}; };
export default function App() { export default function App() {

View file

@ -2488,3 +2488,79 @@ export function RoosterScherm({ mode }) {
// Twee schermen op basis van dezelfde component. // Twee schermen op basis van dezelfde component.
export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; } export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; }
export function RoostermakerPage() { return <RoosterScherm mode="edit" />; } export function RoostermakerPage() { return <RoosterScherm mode="edit" />; }
/* -------------------------------- 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 (
<section>
<h1>Urenoverzicht</h1>
<p className="muted">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.</p>
<Foutmelding msg={error} />
<div className="form-row">
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> schooljaar </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
</div>
{loading ? <p>Laden</p> : data && (
<>
<h2>Per groep</h2>
<table className="grid">
<thead><tr><th>Groep</th><th>Vak</th><th>Uren (schooljaar)</th></tr></thead>
<tbody>
{data.per_groep.length === 0 && <tr><td colSpan="3" className="muted">Geen lesblokken gevonden.</td></tr>}
{data.per_groep.flatMap((g) => [
...g.vakken.map((v, i) => (
<tr key={g.groep + "-" + v.vak}>
<td>{i === 0 ? <strong>{g.groep}</strong> : ""}</td>
<td>{vakIcoon(v.vak)} {v.vak}</td>
<td>{v.uren} u</td>
</tr>
)),
<tr key={g.groep + "-tot"} className="uren-totaal">
<td></td><td><em>totaal</em></td><td><strong>{g.totaal} u</strong></td>
</tr>,
])}
</tbody>
</table>
<h2>Per leerling</h2>
<table className="grid">
<thead><tr><th>Leerling</th><th>Groep</th><th>Vakken</th><th>Stage</th><th>Totaal</th></tr></thead>
<tbody>
{data.per_leerling.length === 0 && <tr><td colSpan="5" className="muted">Geen leerlingen met uren.</td></tr>}
{data.per_leerling.map((l) => (
<tr key={l.leerling}>
<td>{l.leerling}</td>
<td className="muted">{l.groep || "—"}</td>
<td className="muted small">{l.vakken.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}</td>
<td>{l.stage_uren ? `${l.stage_uren} u` : "—"}</td>
<td><strong>{l.totaal} u</strong></td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
);
}

View file

@ -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-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; } .chip-persoon.geselecteerd { outline: 2px solid var(--brand); outline-offset: 1px; }
.icoon-input { width: 60px; text-align: center; } .icoon-input { width: 60px; text-align: center; }
.uren-totaal td { background: var(--bg); }