diff --git a/README.md b/README.md index 8f59da4..9bd916c 100644 --- a/README.md +++ b/README.md @@ -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 0–3** 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 0–4** 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=` — 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). diff --git a/backend/config/settings.py b/backend/config/settings.py index b61b793..7d21061 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -70,8 +70,9 @@ CORE_APPS = [ # Losse modules. Eén regel toevoegen = module beschikbaar maken. # Aan/uit zetten gebeurt daarna per school via het beheerscherm. MODULE_APPS = [ - "modules.printing", # Printen/Exporteren - "modules.accounts", # Multi-user & toegang + "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. diff --git a/backend/config/urls.py b/backend/config/urls.py index 8005185..5fb671f 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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")), ] diff --git a/backend/core/apps.py b/backend/core/apps.py index dd499ba..b441022 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -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"), ), ) ) diff --git a/backend/core/serializers.py b/backend/core/serializers.py index ea61588..a2eece7 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -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 diff --git a/backend/core/urls.py b/backend/core/urls.py index 027ca6d..6c33c31 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -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"), diff --git a/backend/core/views.py b/backend/core/views.py index 79aff06..1950541 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -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, + }) diff --git a/backend/modules/leerplein/__init__.py b/backend/modules/leerplein/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/leerplein/admin.py b/backend/modules/leerplein/admin.py new file mode 100644 index 0000000..79e0efa --- /dev/null +++ b/backend/modules/leerplein/admin.py @@ -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") diff --git a/backend/modules/leerplein/apps.py b/backend/modules/leerplein/apps.py new file mode 100644 index 0000000..19be4c5 --- /dev/null +++ b/backend/modules/leerplein/apps.py @@ -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"), + ), + ) + ) diff --git a/backend/modules/leerplein/migrations/0001_initial.py b/backend/modules/leerplein/migrations/0001_initial.py new file mode 100644 index 0000000..5d7fa81 --- /dev/null +++ b/backend/modules/leerplein/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/backend/modules/leerplein/migrations/__init__.py b/backend/modules/leerplein/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/leerplein/models.py b/backend/modules/leerplein/models.py new file mode 100644 index 0000000..973460e --- /dev/null +++ b/backend/modules/leerplein/models.py @@ -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}" diff --git a/backend/modules/leerplein/serializers.py b/backend/modules/leerplein/serializers.py new file mode 100644 index 0000000..d8e143d --- /dev/null +++ b/backend/modules/leerplein/serializers.py @@ -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"] diff --git a/backend/modules/leerplein/urls.py b/backend/modules/leerplein/urls.py new file mode 100644 index 0000000..4f68008 --- /dev/null +++ b/backend/modules/leerplein/urls.py @@ -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))] diff --git a/backend/modules/leerplein/views.py b/backend/modules/leerplein/views.py new file mode 100644 index 0000000..7fdb1bf --- /dev/null +++ b/backend/modules/leerplein/views.py @@ -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 diff --git a/backend/plugins/registry.py b/backend/plugins/registry.py index 49e0257..d69cceb 100644 --- a/backend/plugins/registry.py +++ b/backend/plugins/registry.py @@ -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) diff --git a/backend/plugins/services.py b/backend/plugins/services.py index 5c4bd45..8e43124 100644 --- a/backend/plugins/services.py +++ b/backend/plugins/services.py @@ -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 ] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 22b0112..49f59a7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( + + ); + } + const Page = PAGES[active]; return ( @@ -153,16 +174,19 @@ function Shell({ user, onLogout }) { Roosterwijs
👤 {user.username} @@ -180,7 +204,7 @@ function Shell({ user, onLogout }) { ) : Page ? ( ) : ( - + )}
diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 2255fe3..fe2bce7 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -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 ( +
+

Leerplein

+

Bundel groepen, subgroepen en losse leerlingen onder een overkoepelend leerplein.

+ +
+
+ setForm({ ...form, naam: e.target.value })} /> + setForm({ ...form, omschrijving: e.target.value })} /> +
+
+
Groepen:
+
+ {groepen.map((g) => )} + {groepen.length === 0 && Geen groepen.} +
+
+
+
Subgroepen:
+
+ {subgroepen.map((s) => )} + {subgroepen.length === 0 && Geen subgroepen.} +
+
+
+
Losse leerlingen:
+
+ {leerlingen.map((l) => )} + {leerlingen.length === 0 && Geen leerlingen.} +
+
+
+ + {editId && } +
+
+ {loading ?

Laden…

: ( + + + + {items.map((lp) => ( + + + + + + ))} + {items.length === 0 && } + +
NaamInhoud
{lp.naam}{lp.omschrijving &&
{lp.omschrijving}
}
+ {lp.groepen_namen.length > 0 &&
🗂️ {lp.groepen_namen.map((x) => x.naam).join(", ")}
} + {lp.subgroepen_namen.length > 0 &&
🔹 {lp.subgroepen_namen.map((x) => x.naam).join(", ")}
} + {lp.leerlingen_namen.length > 0 &&
👤 {lp.leerlingen_namen.map((x) => x.naam).join(", ")}
} + {lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length === 0 && leeg} +
+ + +
Nog geen leerpleinen.
+ )} +
+ ); +} + +/* -------------------------------- 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

Kies eerst een schooljaar.

; + if (error) return ; + if (!data) return

Laden…

; + return ( +
+

{data.aantal === 0 ? "Geen conflicten gevonden 🎉" : `${data.fouten} fout(en) en ${data.waarschuwingen} waarschuwing(en).`}

+ {data.conflicten.map((c, i) => ( +
+
{c.tijdslot} {c.type}
+
{c.detail}
+
Blokken: {c.blokken.join(", ")}
+
+ ))} +
+ ); +} + /* ------------------------------ 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 (
@@ -897,10 +1021,18 @@ export function RoosterPage() { week van {fmtDM(maandag)} )} - {schooljaar && } + {schooljaar && tab === "week" && } - {schooljaar && toonForm && ( + {schooljaar && ( +
+ + + +
+ )} + + {schooljaar && tab === "week" && toonForm && (