Fase 1-4: functies, afwezigheid & uitzonderingen, weekrooster met drag-and-drop, conflictdetectie, Leerplein-module en menu-herstructurering

This commit is contained in:
bes-r 2026-06-12 22:14:09 +02:00
parent 0117c754fa
commit e8f31f6cca
21 changed files with 635 additions and 43 deletions

View file

@ -4,13 +4,21 @@ Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
Status: **Fase 03** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken) en
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster). Medewerkers kunnen
meerdere functies hebben uit een beheerbare functie-catalogus. Het Rooster-scherm
is een **weekweergave met drag-and-drop** (muis én touch) en een
**touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen worden
automatisch in het rooster verwerkt.
Status: **Fase 04** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken),
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster) en Fase 4
(**conflictdetectie**: dubbele inzet van begeleider/leerling en locatiebotsingen).
Het Rooster-scherm is een **weekweergave met drag-and-drop** (muis én touch) en
een **touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen
worden automatisch verwerkt. Het Rooster heeft tabs **Weekrooster / Conflicten /
Instellingen** (tijdsloten, activiteiten en locaties beheer je daar, zodat het
menu rustiger is). De zijbalk groepeert items onder inklapbare submenu's
(Personen, Groepen).
Optionele module **Leerplein**: koppelt subgroepen aan groepen en bundelt
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein. Aan/uit
te zetten via Modulebeheer.
## Wat er nu staat
@ -63,6 +71,9 @@ API-eindpunten:
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`),
`/api/afwezigheden/` (filters `?persoon=`, `?van=`, `?tot=`),
`/api/blok-uitzonderingen/` (filters `?roosterblok=`, `?schooljaar=`, `?van=`, `?tot=`).
- `GET /api/conflicten/?schooljaar=<id>` — roosterconflicten (dubbele inzet,
locatiebotsing, onderbezetting).
- Module Leerplein: `/api/leerpleinen/`, `/api/subgroep-groep-koppelingen/`.
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
@ -80,7 +91,8 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
aan/uit te zetten.
## Volgende stap (Fase 4)
## Volgende stap (Fase 5 & 6)
Conflictdetectie: automatisch signaleren van dubbele inzet van leerkrachten,
ondersteuners of leerlingen op hetzelfde moment, en locatiebotsingen.
De losse modules verder uitbouwen: **Printen/Exporteren** (roosters als PDF) en
**Multi-user & toegang** verfijnen (leerling-/ouder-/collega-rollen). Eventueel de
conflictdetectie datum-specifiek maken (rekening houdend met afwezigheid per dag).

View file

@ -72,6 +72,7 @@ CORE_APPS = [
MODULE_APPS = [
"modules.printing", # Printen/Exporteren
"modules.accounts", # Multi-user & toegang
"modules.leerplein", # Leerplein (groepsstructuur)
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
@ -177,6 +178,9 @@ REST_FRAMEWORK = {
"anon": "20/min",
"user": "300/min",
},
# Achter nginx zien we anders het proxy-IP voor iedereen: dan zou één
# throttle-emmer voor de hele school gelden. nginx zet X-Forwarded-For.
"NUM_PROXIES": 1,
}
# Frontend (React dev-server) mag de API benaderen.

View file

@ -8,4 +8,6 @@ urlpatterns = [
path("api/", include("plugins.urls")),
# Kern-API.
path("api/", include("core.urls")),
# Module-API's.
path("api/", include("modules.leerplein.urls")),
]

View file

@ -27,16 +27,16 @@ class CoreConfig(AppConfig):
category="Kern",
core=True,
menu_items=(
# Hoofdniveau
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Personen", "/personen", icon="users", order=20),
MenuItem("Functies", "/functies", icon="badge", order=25),
MenuItem("Groepen", "/groepen", icon="layers", order=30),
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
MenuItem("Activiteiten", "/activiteiten", icon="book", order=50),
MenuItem("Locaties", "/locaties", icon="pin", order=55),
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=80),
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=15),
# Submenu Personen
MenuItem("Personen", "/personen", icon="users", order=20, group="Personen"),
MenuItem("Functies", "/functies", icon="badge", order=25, group="Personen"),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Personen"),
# Submenu Groepen
MenuItem("Groepen", "/groepen", icon="layers", order=30, group="Groepen"),
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40, group="Groepen"),
),
)
)

View file

@ -85,6 +85,13 @@ class SchooljaarSerializer(serializers.ModelSerializer):
model = Schooljaar
fields = ["id", "naam", "start_datum", "eind_datum", "actief"]
def validate(self, data):
start = data.get("start_datum", getattr(self.instance, "start_datum", None))
eind = data.get("eind_datum", getattr(self.instance, "eind_datum", None))
if start and eind and eind <= start:
raise serializers.ValidationError("De einddatum moet na de startdatum liggen.")
return data
class KalenderDagSerializer(serializers.ModelSerializer):
type_label = serializers.CharField(source="get_type_display", read_only=True)
@ -101,8 +108,20 @@ class TijdslotSerializer(serializers.ModelSerializer):
model = Tijdslot
fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "actief"]
def validate(self, data):
start = data.get("start_tijd", getattr(self.instance, "start_tijd", None))
eind = data.get("eind_tijd", getattr(self.instance, "eind_tijd", None))
if start and eind and eind <= start:
raise serializers.ValidationError("De eindtijd moet na de starttijd liggen.")
return data
class ActiviteitSerializer(serializers.ModelSerializer):
kleur = serializers.RegexField(
regex=r"^#[0-9a-fA-F]{6}$",
error_messages={"invalid": "Kleur moet een hex-code zijn, bv. #2563eb."},
)
class Meta:
model = Activiteit
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
@ -156,6 +175,13 @@ class RoosterblokSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
"Kies precies één doelgroep: groep, subgroep of leerling."
)
# Begeleiders zijn medewerkers; een leerling kan geen begeleider zijn.
# (Het Roosterblok-model kent geen rolbeperking, dus hier afdwingen.)
for p in data.get("begeleiders", []):
if p.rol == "leerling":
raise serializers.ValidationError(
f"'{p.volledige_naam}' is een leerling en kan geen begeleider zijn."
)
return data
@ -190,3 +216,10 @@ class BlokUitzonderingSerializer(serializers.ModelSerializer):
"id", "roosterblok", "datum", "vervalt",
"vervanger", "vervanger_naam", "opmerking",
]
def validate_vervanger(self, value):
if value is not None and value.rol == "leerling":
raise serializers.ValidationError(
"Een leerling kan geen vervanger zijn."
)
return value

View file

@ -20,6 +20,7 @@ router.register("blok-uitzonderingen", views.BlokUitzonderingViewSet, basename="
urlpatterns = [
path("health/", views.health, name="health"),
path("info/", views.info, name="info"),
path("conflicten/", views.conflicten, name="conflicten"),
path("auth/me/", views.auth_me, name="auth-me"),
path("auth/login/", views.auth_login_view, name="auth-login"),
path("auth/logout/", views.auth_logout_view, name="auth-logout"),

View file

@ -208,3 +208,116 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet):
if schooljaar:
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
return qs
def _leerlingen_van_blok(blok):
"""De leerlingen die een blok raakt (via groep, subgroep of individueel)."""
if blok.groep_id:
return [p.id for p in blok.groep.leerlingen.all()]
if blok.subgroep_id:
return [p.id for p in blok.subgroep.leden.all()]
if blok.leerling_id:
return [blok.leerling_id]
return []
@api_view(["GET"])
def conflicten(request):
"""Signaleer roosterconflicten binnen een schooljaar.
Per tijdslot (zelfde dag + tijd) controleren we op:
- dubbele inzet van een begeleider;
- een leerling die in twee blokken tegelijk zit;
- twee blokken in dezelfde locatie;
- onderbezetting (blok zonder begeleider) als waarschuwing.
"""
schooljaar = request.query_params.get("schooljaar")
qs = (
Roosterblok.objects
.select_related("tijdslot", "locatie", "groep", "subgroep", "leerling")
.prefetch_related("begeleiders", "groep__leerlingen", "subgroep__leden")
)
if schooljaar:
qs = qs.filter(schooljaar_id=schooljaar)
# Groepeer blokken per tijdslot.
per_slot = {}
for blok in qs:
per_slot.setdefault(blok.tijdslot_id, []).append(blok)
resultaat = []
def naam_van(persoon):
return persoon.volledige_naam
for slot_id, blokken in per_slot.items():
slot_label = str(blokken[0].tijdslot)
# Begeleiders dubbel ingezet.
begeleider_map = {}
for b in blokken:
for p in b.begeleiders.all():
begeleider_map.setdefault(p.id, {"naam": naam_van(p), "blokken": []})
begeleider_map[p.id]["blokken"].append(b)
for info in begeleider_map.values():
if len(info["blokken"]) > 1:
resultaat.append({
"type": "begeleider",
"ernst": "fout",
"tijdslot": slot_label,
"detail": f"{info['naam']} is op {len(info['blokken'])} plekken tegelijk ingepland",
"blokken": [b.activiteit.naam for b in info["blokken"]],
})
# Leerlingen dubbel ingepland.
leerling_map = {}
for b in blokken:
for lid in _leerlingen_van_blok(b):
leerling_map.setdefault(lid, [])
leerling_map[lid].append(b)
if leerling_map:
namen = {p.id: p.volledige_naam for p in Persoon.objects.filter(id__in=leerling_map.keys())}
for lid, bl in leerling_map.items():
if len(bl) > 1:
resultaat.append({
"type": "leerling",
"ernst": "fout",
"tijdslot": slot_label,
"detail": f"{namen.get(lid, 'Leerling')} zit in {len(bl)} blokken tegelijk",
"blokken": [b.activiteit.naam for b in bl],
})
# Locatiebotsing.
locatie_map = {}
for b in blokken:
if b.locatie_id:
locatie_map.setdefault(b.locatie_id, {"naam": b.locatie.naam, "blokken": []})
locatie_map[b.locatie_id]["blokken"].append(b)
for info in locatie_map.values():
if len(info["blokken"]) > 1:
resultaat.append({
"type": "locatie",
"ernst": "fout",
"tijdslot": slot_label,
"detail": f"Locatie {info['naam']} is dubbel geboekt",
"blokken": [b.activiteit.naam for b in info["blokken"]],
})
# Onderbezetting (waarschuwing).
for b in blokken:
if b.begeleiders.count() == 0:
resultaat.append({
"type": "onderbezetting",
"ernst": "waarschuwing",
"tijdslot": slot_label,
"detail": f"{b.activiteit.naam} ({b.doel_type}) heeft geen begeleider",
"blokken": [b.activiteit.naam],
})
fouten = sum(1 for r in resultaat if r["ernst"] == "fout")
return Response({
"aantal": len(resultaat),
"fouten": fouten,
"waarschuwingen": len(resultaat) - fouten,
"conflicten": resultaat,
})

View file

View file

@ -0,0 +1,16 @@
from django.contrib import admin
from .models import Leerplein, SubgroepGroep
@admin.register(Leerplein)
class LeerpleinAdmin(admin.ModelAdmin):
list_display = ("naam", "omschrijving", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
filter_horizontal = ("groepen", "subgroepen", "leerlingen")
@admin.register(SubgroepGroep)
class SubgroepGroepAdmin(admin.ModelAdmin):
list_display = ("subgroep", "groep")

View file

@ -0,0 +1,29 @@
from django.apps import AppConfig
class LeerpleinConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.leerplein"
label = "module_leerplein"
verbose_name = "Module: Leerplein (groepsstructuur)"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="leerplein",
name="Leerplein (groepsstructuur)",
description=(
"Koppel subgroepen aan groepen en bundel groepen, subgroepen "
"en losse leerlingen onder een overkoepelend leerplein."
),
version="0.1.0",
category="Structuur",
core=False,
default_enabled=False,
menu_items=(
MenuItem("Leerplein", "/leerplein", icon="layers", order=50, group="Groepen"),
),
)
)

View file

@ -0,0 +1,48 @@
# Generated by Django 5.2.15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0003_afwezigheid_blokuitzondering'),
]
operations = [
migrations.CreateModel(
name='Leerplein',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=100, unique=True, verbose_name='naam')),
('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
('groepen', models.ManyToManyField(blank=True, related_name='leerpleinen', to='core.groep')),
('leerlingen', models.ManyToManyField(blank=True, limit_choices_to={'rol': 'leerling'}, related_name='leerpleinen', to='core.persoon')),
('subgroepen', models.ManyToManyField(blank=True, related_name='leerpleinen', to='core.subgroep')),
],
options={
'verbose_name': 'leerplein',
'verbose_name_plural': 'leerpleinen',
'ordering': ['naam'],
},
),
migrations.CreateModel(
name='SubgroepGroep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('groep', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subgroep_koppelingen', to='core.groep')),
('subgroep', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groep_koppelingen', to='core.subgroep')),
],
options={
'verbose_name': 'subgroep-groep-koppeling',
'verbose_name_plural': 'subgroep-groep-koppelingen',
'ordering': ['groep__naam', 'subgroep__naam'],
'constraints': [models.UniqueConstraint(fields=('groep', 'subgroep'), name='uniek_groep_subgroep')],
},
),
]

View file

@ -0,0 +1,61 @@
"""
Leerplein-module: een overkoepelende groepsstructuur.
- Een Leerplein bundelt meerdere groepen en/of subgroepen, en kan daarnaast
losse (individuele) leerlingen bevatten.
- Een SubgroepGroep-koppeling verbindt een subgroep met een groep.
De module raakt de kernmodellen niet aan; het zijn losse koppeltabellen die
alleen bestaan als de module geïnstalleerd is.
"""
from django.db import models
class Leerplein(models.Model):
naam = models.CharField("naam", max_length=100, unique=True)
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
actief = models.BooleanField("actief", default=True)
groepen = models.ManyToManyField(
"core.Groep", verbose_name="groepen", related_name="leerpleinen", blank=True
)
subgroepen = models.ManyToManyField(
"core.Subgroep", verbose_name="subgroepen", related_name="leerpleinen", blank=True
)
leerlingen = models.ManyToManyField(
"core.Persoon", verbose_name="losse leerlingen", related_name="leerpleinen",
blank=True, limit_choices_to={"rol": "leerling"},
)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "leerplein"
verbose_name_plural = "leerpleinen"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class SubgroepGroep(models.Model):
"""Koppelt een subgroep aan een groep (subgroep hoort bij groep)."""
groep = models.ForeignKey(
"core.Groep", on_delete=models.CASCADE, related_name="subgroep_koppelingen"
)
subgroep = models.ForeignKey(
"core.Subgroep", on_delete=models.CASCADE, related_name="groep_koppelingen"
)
class Meta:
verbose_name = "subgroep-groep-koppeling"
verbose_name_plural = "subgroep-groep-koppelingen"
ordering = ["groep__naam", "subgroep__naam"]
constraints = [
models.UniqueConstraint(
fields=["groep", "subgroep"], name="uniek_groep_subgroep"
)
]
def __str__(self) -> str:
return f"{self.subgroep}{self.groep}"

View file

@ -0,0 +1,48 @@
from rest_framework import serializers
from core.models import Groep, Persoon, Subgroep
from .models import Leerplein, SubgroepGroep
class LeerpleinSerializer(serializers.ModelSerializer):
groepen = serializers.PrimaryKeyRelatedField(
many=True, queryset=Groep.objects.all(), required=False
)
subgroepen = serializers.PrimaryKeyRelatedField(
many=True, queryset=Subgroep.objects.all(), required=False
)
leerlingen = serializers.PrimaryKeyRelatedField(
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
)
groepen_namen = serializers.SerializerMethodField()
subgroepen_namen = serializers.SerializerMethodField()
leerlingen_namen = serializers.SerializerMethodField()
class Meta:
model = Leerplein
fields = [
"id", "naam", "omschrijving", "actief",
"groepen", "subgroepen", "leerlingen",
"groepen_namen", "subgroepen_namen", "leerlingen_namen",
"aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
def get_groepen_namen(self, obj):
return [{"id": g.id, "naam": g.naam} for g in obj.groepen.all()]
def get_subgroepen_namen(self, obj):
return [{"id": s.id, "naam": s.naam} for s in obj.subgroepen.all()]
def get_leerlingen_namen(self, obj):
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leerlingen.all()]
class SubgroepGroepSerializer(serializers.ModelSerializer):
groep_naam = serializers.CharField(source="groep.naam", read_only=True)
subgroep_naam = serializers.CharField(source="subgroep.naam", read_only=True)
class Meta:
model = SubgroepGroep
fields = ["id", "groep", "groep_naam", "subgroep", "subgroep_naam"]

View file

@ -0,0 +1,10 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("leerpleinen", views.LeerpleinViewSet, basename="leerplein")
router.register("subgroep-groep-koppelingen", views.SubgroepGroepViewSet, basename="subgroep-groep")
urlpatterns = [path("", include(router.urls))]

View file

@ -0,0 +1,23 @@
from rest_framework import viewsets
from .models import Leerplein, SubgroepGroep
from .serializers import LeerpleinSerializer, SubgroepGroepSerializer
class LeerpleinViewSet(viewsets.ModelViewSet):
queryset = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all()
serializer_class = LeerpleinSerializer
class SubgroepGroepViewSet(viewsets.ModelViewSet):
serializer_class = SubgroepGroepSerializer
def get_queryset(self):
qs = SubgroepGroep.objects.select_related("groep", "subgroep").all()
groep = self.request.query_params.get("groep")
if groep:
qs = qs.filter(groep_id=groep)
subgroep = self.request.query_params.get("subgroep")
if subgroep:
qs = qs.filter(subgroep_id=subgroep)
return qs

View file

@ -17,12 +17,17 @@ from dataclasses import dataclass, field
@dataclass(frozen=True)
class MenuItem:
"""Een navigatie-item dat een module toevoegt aan de app."""
"""Een navigatie-item dat een module toevoegt aan de app.
`group` plaatst het item onder een (inklapbaar) submenu in de zijbalk.
Leeg = een item op het hoofdniveau.
"""
label: str
path: str
icon: str = "puzzle"
order: int = 100
group: str = ""
@dataclass(frozen=True)

View file

@ -64,6 +64,7 @@ def describe_all() -> list[dict]:
"path": m.path,
"icon": m.icon,
"order": m.order,
"group": m.group,
}
for m in spec.menu_items
]

View file

@ -4,7 +4,7 @@ import Logo from "./Logo.jsx";
import {
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
AfwezigheidPage,
AfwezigheidPage, LeerpleinPage,
} from "./pages.jsx";
const ICONS = {
@ -26,6 +26,7 @@ const PAGES = {
"/tijdsloten": TijdslotenPage,
"/schooljaar": SchooljaarPage,
"/afwezigheid": AfwezigheidPage,
"/leerplein": LeerpleinPage,
};
export default function App() {
@ -129,20 +130,40 @@ function Shell({ user, onLogout }) {
}
}
const [openGroups, setOpenGroups] = useState({});
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
const menu = useMemo(() => {
// Items met een `group` komen onder een inklapbaar submenu.
const { topItems, groups, alleItems } = useMemo(() => {
const items = [];
for (const m of modules) {
if (!m.enabled) continue;
for (const it of m.menu_items) items.push({ ...it, module: m.key });
}
// Modulebeheer is een beheerdersactie.
if (user.is_staff) {
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999, group: "" });
}
return items.sort((a, b) => a.order - b.order);
items.sort((a, b) => a.order - b.order);
const top = items.filter((i) => !i.group);
const grouped = {};
for (const i of items) {
if (i.group) (grouped[i.group] = grouped[i.group] || []).push(i);
}
return { topItems: top, groups: grouped, alleItems: items };
}, [modules, user.is_staff]);
function NavKnop({ it, indent }) {
return (
<button
className={(active === it.path ? "nav-item active" : "nav-item") + (indent ? " nav-sub" : "")}
onClick={() => setActive(it.path)}
>
<span className="ico">{ICONS[it.icon] || "•"}</span>
{it.label}
</button>
);
}
const Page = PAGES[active];
return (
@ -153,16 +174,19 @@ function Shell({ user, onLogout }) {
<span className="brand-name">Roosterwijs</span>
</div>
<nav>
{menu.map((it) => (
<button
key={it.path}
className={active === it.path ? "nav-item active" : "nav-item"}
onClick={() => setActive(it.path)}
>
<span className="ico">{ICONS[it.icon] || "•"}</span>
{it.label}
{topItems.map((it) => <NavKnop key={it.path} it={it} />)}
{Object.entries(groups).map(([naam, kinderen]) => {
const open = openGroups[naam] !== false;
return (
<div className="nav-group" key={naam}>
<button className="nav-group-head" onClick={() => setOpenGroups((o) => ({ ...o, [naam]: !open }))}>
<span>{naam}</span>
<span className="nav-chev">{open ? "▾" : "▸"}</span>
</button>
))}
{open && kinderen.map((it) => <NavKnop key={it.path} it={it} indent />)}
</div>
);
})}
</nav>
<div className="sidebar-user">
<span className="muted small">👤 {user.username}</span>
@ -180,7 +204,7 @@ function Shell({ user, onLogout }) {
) : Page ? (
<Page />
) : (
<Placeholder path={active} menu={menu} />
<Placeholder path={active} menu={alleItems} />
)}
</main>
</div>

View file

@ -744,6 +744,128 @@ export function AfwezigheidPage() {
);
}
/* -------------------------------- Leerplein ------------------------------- */
// Module-scherm: koppel subgroepen aan groepen en bundel groepen, subgroepen
// en losse leerlingen onder een overkoepelend leerplein.
export function LeerpleinPage() {
const { items, error, loading, reload, setError } = useList("leerpleinen");
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const leeg = { naam: "", omschrijving: "", groepen: [], subgroepen: [], leerlingen: [] };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => {
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
}, []);
function toggle(veld, id) {
setForm((f) => f[veld].includes(id) ? { ...f, [veld]: f[veld].filter((x) => x !== id) } : { ...f, [veld]: [...f[veld], id] });
}
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource("leerpleinen", editId, form);
else await createResource("leerpleinen", form);
setForm(leeg); setEditId(null); await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Dit leerplein verwijderen?")) return;
try { await deleteResource("leerpleinen", id); await reload(); } catch (err) { setError(err.message); }
}
return (
<section>
<h1>Leerplein</h1>
<p className="muted">Bundel groepen, subgroepen en losse leerlingen onder een overkoepelend leerplein.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
</div>
<div className="leden-keuze">
<div className="muted small">Groepen:</div>
<div className="chips">
{groepen.map((g) => <button type="button" key={g.id} className={form.groepen.includes(g.id) ? "chip on" : "chip"} onClick={() => toggle("groepen", g.id)}>{g.naam}</button>)}
{groepen.length === 0 && <span className="muted small">Geen groepen.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Subgroepen:</div>
<div className="chips">
{subgroepen.map((s) => <button type="button" key={s.id} className={form.subgroepen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggle("subgroepen", s.id)}>{s.naam}</button>)}
{subgroepen.length === 0 && <span className="muted small">Geen subgroepen.</span>}
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Losse leerlingen:</div>
<div className="chips">
{leerlingen.map((l) => <button type="button" key={l.id} className={form.leerlingen.includes(l.id) ? "chip on" : "chip"} onClick={() => toggle("leerlingen", l.id)}>{l.volledige_naam}</button>)}
{leerlingen.length === 0 && <span className="muted small">Geen leerlingen.</span>}
</div>
</div>
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Inhoud</th><th></th></tr></thead>
<tbody>
{items.map((lp) => (
<tr key={lp.id}>
<td>{lp.naam}{lp.omschrijving && <div className="muted small">{lp.omschrijving}</div>}</td>
<td className="small">
{lp.groepen_namen.length > 0 && <div>🗂 {lp.groepen_namen.map((x) => x.naam).join(", ")}</div>}
{lp.subgroepen_namen.length > 0 && <div>🔹 {lp.subgroepen_namen.map((x) => x.naam).join(", ")}</div>}
{lp.leerlingen_namen.length > 0 && <div>👤 {lp.leerlingen_namen.map((x) => x.naam).join(", ")}</div>}
{lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length === 0 && <span className="muted">leeg</span>}
</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(lp.id); setForm({ naam: lp.naam, omschrijving: lp.omschrijving, groepen: lp.groepen_namen.map((x) => x.id), subgroepen: lp.subgroepen_namen.map((x) => x.id), leerlingen: lp.leerlingen_namen.map((x) => x.id) }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(lp.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen leerpleinen.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- Conflicten ------------------------------ */
function Conflicten({ schooljaar }) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!schooljaar) return;
listResource("conflicten", `?schooljaar=${schooljaar}`).then(setData).catch((e) => setError(e.message));
}, [schooljaar]);
if (!schooljaar) return <p className="muted">Kies eerst een schooljaar.</p>;
if (error) return <Foutmelding msg={error} />;
if (!data) return <p>Laden</p>;
return (
<div>
<p className="muted">{data.aantal === 0 ? "Geen conflicten gevonden 🎉" : `${data.fouten} fout(en) en ${data.waarschuwingen} waarschuwing(en).`}</p>
{data.conflicten.map((c, i) => (
<div className={c.ernst === "fout" ? "conflict fout" : "conflict waarschuwing"} key={i}>
<div className="conflict-kop"><strong>{c.tijdslot}</strong> <span className="tag">{c.type}</span></div>
<div>{c.detail}</div>
<div className="muted small">Blokken: {c.blokken.join(", ")}</div>
</div>
))}
</div>
);
}
/* ------------------------------ Datumhulpjes ------------------------------ */
function pad2(n) { return String(n).padStart(2, "0"); }
function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
@ -770,6 +892,7 @@ export function RoosterPage() {
const [uitzonderingen, setUitzonderingen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false);
const [tab, setTab] = useState("week");
const [error, setError] = useState(null);
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
@ -874,7 +997,8 @@ export function RoosterPage() {
// 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);
const persoonAfwezig = (naam, iso) => afwezig.some((a) => a.persoon_naam === naam && a.start_datum <= iso && a.eind_datum >= iso);
// Op id vergelijken, niet op naam: twee personen kunnen dezelfde naam hebben.
const persoonAfwezig = (id, iso) => afwezig.some((a) => a.persoon === id && a.start_datum <= iso && a.eind_datum >= iso);
return (
<section>
@ -897,10 +1021,18 @@ export function RoosterPage() {
<span className="week-label">week van {fmtDM(maandag)}</span>
</div>
)}
{schooljaar && <button className="btn primary" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
{schooljaar && tab === "week" && <button className="btn primary" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
</div>
{schooljaar && toonForm && (
{schooljaar && (
<div className="tabbar">
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
<button className={tab === "conflicten" ? "tab on" : "tab"} onClick={() => setTab("conflicten")}>Conflicten</button>
<button className={tab === "instellingen" ? "tab on" : "tab"} onClick={() => setTab("instellingen")}>Instellingen</button>
</div>
)}
{schooljaar && tab === "week" && toonForm && (
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
@ -941,7 +1073,7 @@ export function RoosterPage() {
</form>
)}
{schooljaar && (
{schooljaar && tab === "week" && (
<div className="rooster-grid">
{WEEKDAGEN.map((d, i) => {
const datum = weekDatums[i];
@ -982,7 +1114,7 @@ export function RoosterPage() {
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
{b.begeleiders_namen.length > 0 && (
<div className="small muted">👤 {b.begeleiders_namen.map((x, idx) => (
<span key={x.id} className={persoonAfwezig(x.naam, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}</span>
<span key={x.id} className={persoonAfwezig(x.id, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}</span>
))}</div>
)}
{kal && <div className="small rb-status">Vervalt {kal.type_label}</div>}
@ -1003,6 +1135,17 @@ export function RoosterPage() {
</div>
)}
{schooljaar && tab === "conflicten" && <Conflicten schooljaar={schooljaar} />}
{schooljaar && tab === "instellingen" && (
<div className="instellingen">
<p className="muted">Bouwstenen van het rooster beheer ze hier zonder het Rooster te verlaten.</p>
<TijdslotenPage />
<ActiviteitenPage />
<LocatiesPage />
</div>
)}
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section>
);

View file

@ -147,6 +147,25 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
/* --- Submenu's in de zijbalk --- */
.nav-group { margin-top: 2px; }
.nav-group-head { display: flex; justify-content: space-between; align-items: center; width: 100%; background: transparent; border: 0; cursor: pointer; padding: 10px 10px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); font-weight: 700; }
.nav-chev { font-size: 10px; }
.nav-item.nav-sub { padding-left: 26px; font-size: 13.5px; }
/* --- Tabs (o.a. in het Rooster) --- */
.tabbar { display: flex; gap: 6px; margin: 8px 0 16px; border-bottom: 1px solid var(--line); }
.tab { background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 10px 14px; font: inherit; cursor: pointer; color: var(--muted); }
.tab:hover { color: var(--ink); }
.tab.on { color: var(--brand); border-bottom-color: var(--brand); font-weight: 600; }
.instellingen > section { margin-top: 24px; padding-top: 8px; border-top: 1px dashed var(--line); }
.instellingen > section:first-of-type { border-top: 0; }
/* --- Conflicten --- */
.conflict { border: 1px solid var(--line); border-left: 4px solid #b91c1c; border-radius: 8px; padding: 10px 14px; margin-bottom: 10px; background: #fff; }
.conflict.waarschuwing { border-left-color: #b45309; }
.conflict-kop { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
/* Touch-vriendelijk: ruimere tikdoelen op kleinere/aanraakschermen. */
@media (pointer: coarse) {
.btn, .chip, input, select { min-height: 44px; }