Groepsindeling als één sleep-overzicht met kleurstructuur
This commit is contained in:
parent
e4a75c9794
commit
2c2c33c116
10 changed files with 256 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
0
backend/modules/uren/__init__.py
Normal file
0
backend/modules/uren/__init__.py
Normal file
30
backend/modules/uren/apps.py
Normal file
30
backend/modules/uren/apps.py
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
7
backend/modules/uren/urls.py
Normal file
7
backend/modules/uren/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("urenoverzicht/", views.urenoverzicht, name="urenoverzicht"),
|
||||||
|
]
|
||||||
133
backend/modules/uren/views.py
Normal file
133
backend/modules/uren/views.py
Normal 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 (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,
|
||||||
|
})
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue