From 3d29359d61f2cec7c86c19ce6b75ce63b154af79 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:39:04 +0200 Subject: [PATCH] Lokalen en Locaties als eigen items in het instellingen-menu (bovenbalk) --- ROADMAP.md | 9 + backend/core/admin.py | 15 +- backend/core/apps.py | 3 + ...aal_lokaalinstelling_roosterblok_lokaal.py | 46 ++++ backend/core/models.py | 48 ++++ backend/core/serializers.py | 45 +++- backend/core/urls.py | 2 + backend/core/views.py | 39 ++- backend/modules/stage/admin.py | 10 +- ...tage_stagelocatie_stage_lokaal_and_more.py | 27 +++ backend/modules/stage/models.py | 34 +-- backend/modules/stage/serializers.py | 78 ++++-- backend/modules/stage/urls.py | 1 - backend/modules/stage/views.py | 31 ++- frontend/src/App.jsx | 5 +- frontend/src/api.js | 4 + frontend/src/pages.jsx | 229 +++++++++++------- frontend/src/styles.css | 2 + 18 files changed, 469 insertions(+), 159 deletions(-) create mode 100644 backend/core/migrations/0006_lokaal_lokaalinstelling_roosterblok_lokaal.py create mode 100644 backend/modules/stage/migrations/0003_remove_stage_stagelocatie_stage_lokaal_and_more.py diff --git a/ROADMAP.md b/ROADMAP.md index 775f6f5..48b046b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,6 +53,15 @@ volgt. (in het weekrooster gemarkeerd "Op stage" bij de betreffende blokken). - [x] Stage instelbaar **per blok, per dagdeel of per dag**. +## Lokalen & dubbele boekingen + +- [x] **Lokalen** als apart roomsysteem (naast de vrije Locaties), met + capaciteit; gedeeld door lessen en interne stages. +- [x] **Dubbele-boekingcheck** per tijdslot: gemeld in het conflicten-overzicht; + per school instelbaar om opslaan **hard te blokkeren** (Lokalen-scherm). +- [x] Interne stages gebruiken nu een **lokaal** (de aparte stagelocatie-lijst is + vervallen) en tellen mee in de dubbele-boekingcheck. + ## Koppelingen & inzetregels - [x] **Personeel koppelen aan leerplein en subgroep** (personeelsformulier: diff --git a/backend/core/admin.py b/backend/core/admin.py index 045b67e..db3bc2b 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -2,7 +2,8 @@ from django.contrib import admin from .models import ( Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag, - Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot, + Locatie, Lokaal, Lokaalinstelling, Persoon, Roosterblok, Schooljaar, + Subgroep, Tijdslot, ) @@ -92,3 +93,15 @@ class BlokUitzonderingAdmin(admin.ModelAdmin): list_display = ("roosterblok", "datum", "vervalt", "vervanger") list_filter = ("vervalt", "datum") date_hierarchy = "datum" + + +@admin.register(Lokaal) +class LokaalAdmin(admin.ModelAdmin): + list_display = ("naam", "capaciteit", "omschrijving", "actief") + list_filter = ("actief",) + search_fields = ("naam",) + + +@admin.register(Lokaalinstelling) +class LokaalinstellingAdmin(admin.ModelAdmin): + list_display = ("dubbel_blokkeren",) diff --git a/backend/core/apps.py b/backend/core/apps.py index 831d81f..e1fe18e 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -34,6 +34,9 @@ class CoreConfig(AppConfig): MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"), MenuItem("Groepsindeling", "/groepsindeling", icon="layers", order=24, group="Organisatie"), MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Organisatie"), + # Submenu Instellingen (cog in de bovenbalk): bouwstenen. + MenuItem("Lokalen", "/lokalen", icon="door", order=70, group="Instellingen"), + MenuItem("Locaties", "/locaties", icon="pin", order=72, group="Instellingen"), ), ) ) diff --git a/backend/core/migrations/0006_lokaal_lokaalinstelling_roosterblok_lokaal.py b/backend/core/migrations/0006_lokaal_lokaalinstelling_roosterblok_lokaal.py new file mode 100644 index 0000000..664d6c2 --- /dev/null +++ b/backend/core/migrations/0006_lokaal_lokaalinstelling_roosterblok_lokaal.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.15 on 2026-06-16 06:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_subgroep_begeleiders_subgroep_groep'), + ] + + operations = [ + migrations.CreateModel( + name='Lokaal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')), + ('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')), + ('capaciteit', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='capaciteit')), + ('actief', models.BooleanField(default=True, verbose_name='actief')), + ('aangemaakt_op', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'lokaal', + 'verbose_name_plural': 'lokalen', + 'ordering': ['naam'], + }, + ), + migrations.CreateModel( + name='Lokaalinstelling', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dubbel_blokkeren', models.BooleanField(default=False, help_text='Aan = opslaan weigeren bij dubbele lokaalboeking; uit = alleen waarschuwen.', verbose_name='dubbele boeking hard blokkeren')), + ], + options={ + 'verbose_name': 'lokaalinstelling', + 'verbose_name_plural': 'lokaalinstelling', + }, + ), + migrations.AddField( + model_name='roosterblok', + name='lokaal', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roosterblokken', to='core.lokaal'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index ceba82d..d04dbab 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -317,6 +317,50 @@ class Locatie(models.Model): return self.naam +class Lokaal(models.Model): + """Een fysiek lokaal/ruimte met dubbele-boekingbewaking. + + Apart van de vrije 'Locatie'-lijst: lokalen worden gedeeld door lessen + (Roosterblok) en interne stages, en er wordt gecontroleerd of een lokaal + niet op hetzelfde tijdslot dubbel geboekt wordt. + """ + + naam = models.CharField("naam", max_length=80, unique=True) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + capaciteit = models.PositiveSmallIntegerField("capaciteit", null=True, blank=True) + actief = models.BooleanField("actief", default=True) + aangemaakt_op = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "lokaal" + verbose_name_plural = "lokalen" + ordering = ["naam"] + + def __str__(self) -> str: + return self.naam + + +class Lokaalinstelling(models.Model): + """Eén-rij-instelling: blokkeert dubbele lokaalboekingen hard (per school).""" + + dubbel_blokkeren = models.BooleanField( + "dubbele boeking hard blokkeren", default=False, + help_text="Aan = opslaan weigeren bij dubbele lokaalboeking; uit = alleen waarschuwen.", + ) + + class Meta: + verbose_name = "lokaalinstelling" + verbose_name_plural = "lokaalinstelling" + + @classmethod + def actueel(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + def __str__(self) -> str: + return "Lokaalinstelling" + + class Roosterblok(models.Model): """De spil: wie doet wat, wanneer, waar, met wie. @@ -337,6 +381,10 @@ class Roosterblok(models.Model): Locatie, on_delete=models.SET_NULL, null=True, blank=True, related_name="roosterblokken", ) + lokaal = models.ForeignKey( + Lokaal, on_delete=models.SET_NULL, null=True, blank=True, + related_name="roosterblokken", + ) # Doelgroep: precies één van de drie is gevuld. groep = models.ForeignKey( diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 342fff1..3c6fd53 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -3,7 +3,8 @@ from rest_framework import serializers from .models import ( Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag, - Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot, + Locatie, Lokaal, Lokaalinstelling, Persoon, Roosterblok, Schooljaar, + Subgroep, Tijdslot, ) @@ -191,11 +192,24 @@ class LocatieSerializer(serializers.ModelSerializer): fields = ["id", "naam", "omschrijving", "actief"] +class LokaalSerializer(serializers.ModelSerializer): + class Meta: + model = Lokaal + fields = ["id", "naam", "omschrijving", "capaciteit", "actief"] + + +class LokaalinstellingSerializer(serializers.ModelSerializer): + class Meta: + model = Lokaalinstelling + fields = ["dubbel_blokkeren"] + + class RoosterblokSerializer(serializers.ModelSerializer): activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True) activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True) tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True) locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None) + lokaal_naam = serializers.CharField(source="lokaal.naam", read_only=True, default=None) doel_type = serializers.CharField(read_only=True) doel_naam = serializers.SerializerMethodField() begeleiders_namen = serializers.SerializerMethodField() @@ -205,7 +219,7 @@ class RoosterblokSerializer(serializers.ModelSerializer): fields = [ "id", "schooljaar", "tijdslot", "tijdslot_label", "activiteit", "activiteit_naam", "activiteit_kleur", - "locatie", "locatie_naam", + "locatie", "locatie_naam", "lokaal", "lokaal_naam", "groep", "subgroep", "leerling", "doel_type", "doel_naam", "begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op", ] @@ -258,6 +272,33 @@ class RoosterblokSerializer(serializers.ModelSerializer): raise serializers.ValidationError( f"'{p.volledige_naam}' is niet gekoppeld aan het vak '{activiteit.naam}'." ) + + # Dubbele-lokaalboeking: bij harde modus weigeren we opslaan. + lokaal = data.get("lokaal", getattr(self.instance, "lokaal", None)) + tijdslot = data.get("tijdslot", getattr(self.instance, "tijdslot", None)) + schooljaar = data.get("schooljaar", getattr(self.instance, "schooljaar", None)) + if lokaal is not None and tijdslot is not None and Lokaalinstelling.actueel().dubbel_blokkeren: + botsing = Roosterblok.objects.filter( + lokaal=lokaal, tijdslot=tijdslot, schooljaar=schooljaar, + ) + if self.instance is not None: + botsing = botsing.exclude(pk=self.instance.pk) + if botsing.exists(): + raise serializers.ValidationError( + f"Lokaal '{lokaal.naam}' is op dit tijdslot al bezet." + ) + try: + from modules.stage.models import Stage as _Stage + stage_bezet = any( + st.dekt_tijdslot(tijdslot) + for st in _Stage.objects.filter(actief=True, lokaal=lokaal) + ) + except Exception: + stage_bezet = False + if stage_bezet: + raise serializers.ValidationError( + f"Lokaal '{lokaal.naam}' is op dit tijdslot al door een stage bezet." + ) return data diff --git a/backend/core/urls.py b/backend/core/urls.py index 6c33c31..7b0f782 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -13,6 +13,7 @@ router.register("kalenderdagen", views.KalenderDagViewSet, basename="kalenderdag router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot") router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit") router.register("locaties", views.LocatieViewSet, basename="locatie") +router.register("lokalen", views.LokaalViewSet, basename="lokaal") router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok") router.register("afwezigheden", views.AfwezigheidViewSet, basename="afwezigheid") router.register("blok-uitzonderingen", views.BlokUitzonderingViewSet, basename="blok-uitzondering") @@ -21,6 +22,7 @@ urlpatterns = [ path("health/", views.health, name="health"), path("info/", views.info, name="info"), path("conflicten/", views.conflicten, name="conflicten"), + path("lokaalinstelling/", views.lokaalinstelling, name="lokaalinstelling"), 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 8c616de..4459fa3 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -11,11 +11,13 @@ from plugins import services from .models import ( Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag, - Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot, + Locatie, Lokaal, Lokaalinstelling, Persoon, Roosterblok, Schooljaar, + Subgroep, Tijdslot, ) from .serializers import ( ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer, FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer, + LokaalSerializer, LokaalinstellingSerializer, PersoonSerializer, RoosterblokSerializer, SchooljaarSerializer, SubgroepSerializer, TijdslotSerializer, ) @@ -155,6 +157,23 @@ class LocatieViewSet(viewsets.ModelViewSet): serializer_class = LocatieSerializer +class LokaalViewSet(viewsets.ModelViewSet): + queryset = Lokaal.objects.all() + serializer_class = LokaalSerializer + + +@api_view(["GET", "PUT"]) +def lokaalinstelling(request): + """Lees of wijzig de (per-school) instelling voor dubbele lokaalboekingen.""" + obj = Lokaalinstelling.actueel() + if request.method == "PUT": + ser = LokaalinstellingSerializer(obj, data=request.data, partial=True) + ser.is_valid(raise_exception=True) + ser.save() + return Response(ser.data) + return Response(LokaalinstellingSerializer(obj).data) + + class RoosterblokViewSet(viewsets.ModelViewSet): serializer_class = RoosterblokSerializer @@ -234,7 +253,7 @@ def conflicten(request): schooljaar = request.query_params.get("schooljaar") qs = ( Roosterblok.objects - .select_related("tijdslot", "locatie", "groep", "subgroep", "leerling") + .select_related("tijdslot", "locatie", "lokaal", "groep", "subgroep", "leerling") .prefetch_related("begeleiders", "groep__leerlingen", "subgroep__leden") ) if schooljaar: @@ -303,6 +322,22 @@ def conflicten(request): "blokken": [b.activiteit.naam for b in info["blokken"]], }) + # Lokaalbotsing (dubbele boeking van een lokaal op hetzelfde tijdslot). + lokaal_map = {} + for b in blokken: + if b.lokaal_id: + lokaal_map.setdefault(b.lokaal_id, {"naam": b.lokaal.naam, "blokken": []}) + lokaal_map[b.lokaal_id]["blokken"].append(b) + for info in lokaal_map.values(): + if len(info["blokken"]) > 1: + resultaat.append({ + "type": "lokaal", + "ernst": "fout", + "tijdslot": slot_label, + "detail": f"Lokaal {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: diff --git a/backend/modules/stage/admin.py b/backend/modules/stage/admin.py index 873de9a..4982984 100644 --- a/backend/modules/stage/admin.py +++ b/backend/modules/stage/admin.py @@ -1,18 +1,12 @@ from django.contrib import admin -from .models import Stage, StageLocatie - - -@admin.register(StageLocatie) -class StageLocatieAdmin(admin.ModelAdmin): - list_display = ("naam", "omschrijving", "actief") - search_fields = ("naam",) +from .models import Stage @admin.register(Stage) class StageAdmin(admin.ModelAdmin): list_display = ( - "leerling", "type", "begeleider", "stagelocatie", "organisatie", + "leerling", "type", "begeleider", "lokaal", "organisatie", "omvang", "dag", "dagdeel", "actief", ) list_filter = ("type", "omvang", "actief", "dag") diff --git a/backend/modules/stage/migrations/0003_remove_stage_stagelocatie_stage_lokaal_and_more.py b/backend/modules/stage/migrations/0003_remove_stage_stagelocatie_stage_lokaal_and_more.py new file mode 100644 index 0000000..d2c8ea0 --- /dev/null +++ b/backend/modules/stage/migrations/0003_remove_stage_stagelocatie_stage_lokaal_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.15 on 2026-06-16 06:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_lokaal_lokaalinstelling_roosterblok_lokaal'), + ('module_stage', '0002_stagelocatie_omvang_dagdeel_tijdslot'), + ] + + operations = [ + migrations.RemoveField( + model_name='stage', + name='stagelocatie', + ), + migrations.AddField( + model_name='stage', + name='lokaal', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stages', to='core.lokaal'), + ), + migrations.DeleteModel( + name='StageLocatie', + ), + ] diff --git a/backend/modules/stage/models.py b/backend/modules/stage/models.py index 3528b57..653a7e4 100644 --- a/backend/modules/stage/models.py +++ b/backend/modules/stage/models.py @@ -2,8 +2,8 @@ Stage-module: interne en externe stages van leerlingen. - Beide stagevormen krijgen een begeleider (verplicht). -- Een interne stage krijgt daarnaast een stageruimte (eigen lijst, los van de - gewone lokalen). +- Een interne stage gebruikt een LOKAAL (gedeelde pool met de lessen, met + dubbele-boekingbewaking) — dus geen aparte stagelocatie-lijst meer. - Een externe stage legt de externe organisatie vast. - Een stage geldt voor een hele dag, een dagdeel of één specifiek roosterblok. """ @@ -40,26 +40,6 @@ class Weekdag(models.IntegerChoices): VRIJDAG = 4, "Vrijdag" -class StageLocatie(models.Model): - """Een stageruimte/-plek, los van de gewone lokalen (core.Locatie). - - Zo houd je de stagelocaties gescheiden van de lesruimtes. - """ - - naam = models.CharField("naam", max_length=120, unique=True) - omschrijving = models.CharField("omschrijving", max_length=255, blank=True) - actief = models.BooleanField("actief", default=True) - aangemaakt_op = models.DateTimeField(auto_now_add=True) - - class Meta: - verbose_name = "stagelocatie" - verbose_name_plural = "stagelocaties" - ordering = ["naam"] - - def __str__(self) -> str: - return self.naam - - class Stage(models.Model): leerling = models.ForeignKey( "core.Persoon", on_delete=models.CASCADE, related_name="stages", @@ -70,9 +50,9 @@ class Stage(models.Model): "core.Persoon", on_delete=models.PROTECT, related_name="stagebegeleidingen", help_text="Verplicht voor zowel interne als externe stage.", ) - # Interne stage: een eigen stageruimte. - stagelocatie = models.ForeignKey( - StageLocatie, on_delete=models.SET_NULL, null=True, blank=True, + # Interne stage: een lokaal uit de gedeelde lokalen-pool. + lokaal = models.ForeignKey( + "core.Lokaal", on_delete=models.SET_NULL, null=True, blank=True, related_name="stages", ) # Externe stage: de externe organisatie. @@ -103,8 +83,8 @@ class Stage(models.Model): ordering = ["leerling__achternaam", "leerling__voornaam"] def clean(self): - if self.type == StageType.INTERN and not self.stagelocatie_id: - raise ValidationError("Een interne stage vereist een stageruimte.") + if self.type == StageType.INTERN and not self.lokaal_id: + raise ValidationError("Een interne stage vereist een lokaal.") if self.type == StageType.EXTERN and not self.organisatie: raise ValidationError("Een externe stage vereist een externe organisatie.") if self.omvang == Omvang.BLOK and not self.tijdslot_id: diff --git a/backend/modules/stage/serializers.py b/backend/modules/stage/serializers.py index 8bbcd0c..c342e39 100644 --- a/backend/modules/stage/serializers.py +++ b/backend/modules/stage/serializers.py @@ -1,21 +1,12 @@ from rest_framework import serializers -from .models import Dagdeel, Omvang, Stage, StageLocatie, StageType - - -class StageLocatieSerializer(serializers.ModelSerializer): - aantal_stages = serializers.IntegerField(source="stages.count", read_only=True) - - class Meta: - model = StageLocatie - fields = ["id", "naam", "omschrijving", "actief", "aantal_stages", "aangemaakt_op"] - read_only_fields = ["aangemaakt_op"] +from .models import Dagdeel, Omvang, Stage, StageType class StageSerializer(serializers.ModelSerializer): leerling_naam = serializers.CharField(source="leerling.volledige_naam", read_only=True) begeleider_naam = serializers.CharField(source="begeleider.volledige_naam", read_only=True) - stagelocatie_naam = serializers.CharField(source="stagelocatie.naam", read_only=True, default=None) + lokaal_naam = serializers.CharField(source="lokaal.naam", read_only=True, default=None) type_label = serializers.CharField(source="get_type_display", read_only=True) omvang_label = serializers.CharField(source="get_omvang_display", read_only=True) dagdeel_label = serializers.CharField(source="get_dagdeel_display", read_only=True, default=None) @@ -28,7 +19,7 @@ class StageSerializer(serializers.ModelSerializer): fields = [ "id", "leerling", "leerling_naam", "type", "type_label", "begeleider", "begeleider_naam", - "stagelocatie", "stagelocatie_naam", "organisatie", + "lokaal", "lokaal_naam", "organisatie", "omvang", "omvang_label", "dag", "dag_label", "effectieve_dag", "dagdeel", "dagdeel_label", "tijdslot", "tijdslot_label", "omschrijving", "actief", "aangemaakt_op", @@ -40,7 +31,7 @@ class StageSerializer(serializers.ModelSerializer): def validate(self, data): type_ = self._val(data, "type") - stagelocatie = self._val(data, "stagelocatie") + lokaal = self._val(data, "lokaal") organisatie = self._val(data, "organisatie", "") begeleider = self._val(data, "begeleider") omvang = self._val(data, "omvang", Omvang.HELE_DAG) @@ -50,8 +41,8 @@ class StageSerializer(serializers.ModelSerializer): if begeleider is not None and begeleider.rol == "leerling": raise serializers.ValidationError("Een leerling kan geen begeleider zijn.") - if type_ == StageType.INTERN and not stagelocatie: - raise serializers.ValidationError("Een interne stage vereist een stageruimte.") + if type_ == StageType.INTERN and not lokaal: + raise serializers.ValidationError("Een interne stage vereist een lokaal.") if type_ == StageType.EXTERN and not organisatie: raise serializers.ValidationError("Een externe stage vereist een externe organisatie.") if omvang == Omvang.BLOK and not tijdslot: @@ -69,9 +60,66 @@ class StageSerializer(serializers.ModelSerializer): f"{begeleider.volledige_naam} begeleidt op dat moment al het " f"theorievak '{botsing}' en kan niet tegelijk stage begeleiden." ) + + # Dubbele-lokaalboeking (interne stage): bij harde modus weigeren. + if lokaal is not None: + from core.models import Lokaalinstelling + if Lokaalinstelling.actueel().dubbel_blokkeren: + bezet = _lokaal_bezet_door_ander( + lokaal, omvang, dag, dagdeel, tijdslot, + exclude_stage_id=getattr(self.instance, "id", None), + ) + if bezet: + raise serializers.ValidationError( + f"Lokaal '{lokaal.naam}' is op dat moment al bezet ({bezet})." + ) return data +def _gedekte_tijdsloten(omvang, dag, dagdeel, tijdslot): + """Geef de core.Tijdslot-objecten die door deze stage-omvang gedekt worden.""" + from core.models import Tijdslot + from .models import MIDDAG_GRENS, Omvang as _Omvang, Dagdeel as _Dagdeel + + if omvang == _Omvang.BLOK: + return [tijdslot] if tijdslot is not None else [] + if dag is None: + return [] + sloten = list(Tijdslot.objects.filter(dag=dag)) + if omvang == _Omvang.HELE_DAG: + return sloten + out = [] + for t in sloten: + is_ochtend = t.start_tijd < MIDDAG_GRENS + if (dagdeel == _Dagdeel.OCHTEND and is_ochtend) or (dagdeel == _Dagdeel.MIDDAG and not is_ochtend): + out.append(t) + return out + + +def _lokaal_bezet_door_ander(lokaal, omvang, dag, dagdeel, tijdslot, exclude_stage_id=None): + """Geef een omschrijving van wie het lokaal al bezet, of None.""" + from core.models import Roosterblok + sloten = _gedekte_tijdsloten(omvang, dag, dagdeel, tijdslot) + slot_ids = [t.id for t in sloten] + if not slot_ids: + return None + les = ( + Roosterblok.objects + .filter(lokaal=lokaal, tijdslot_id__in=slot_ids) + .select_related("activiteit").first() + ) + if les: + return f"les {les.activiteit.naam}" + # Andere stages die hetzelfde lokaal op een overlappend tijdslot gebruiken. + qs = Stage.objects.filter(actief=True, lokaal=lokaal).exclude(type=StageType.EXTERN) + if exclude_stage_id: + qs = qs.exclude(pk=exclude_stage_id) + for st in qs: + if any(st.dekt_tijdslot(t) for t in sloten): + return f"stage van {st.leerling.volledige_naam}" + return None + + def _theorievak_botsing(begeleider, omvang, dag, dagdeel, tijdslot): """Zoek een theorievak-roosterblok dat de begeleider op hetzelfde moment doet.""" from core.models import Roosterblok diff --git a/backend/modules/stage/urls.py b/backend/modules/stage/urls.py index 224d401..c61a815 100644 --- a/backend/modules/stage/urls.py +++ b/backend/modules/stage/urls.py @@ -5,7 +5,6 @@ from . import views router = DefaultRouter() router.register("stages", views.StageViewSet, basename="stage") -router.register("stagelocaties", views.StageLocatieViewSet, basename="stagelocatie") urlpatterns = [ path("stage-conflicten/", views.stage_conflicten, name="stage-conflicten"), diff --git a/backend/modules/stage/views.py b/backend/modules/stage/views.py index 88b501d..f374244 100644 --- a/backend/modules/stage/views.py +++ b/backend/modules/stage/views.py @@ -2,13 +2,8 @@ from rest_framework import viewsets from rest_framework.decorators import api_view from rest_framework.response import Response -from .models import Stage, StageLocatie -from .serializers import StageLocatieSerializer, StageSerializer, _theorievak_botsing - - -class StageLocatieViewSet(viewsets.ModelViewSet): - queryset = StageLocatie.objects.all() - serializer_class = StageLocatieSerializer +from .models import Stage +from .serializers import StageSerializer, _theorievak_botsing, _lokaal_bezet_door_ander class StageViewSet(viewsets.ModelViewSet): @@ -16,7 +11,7 @@ class StageViewSet(viewsets.ModelViewSet): def get_queryset(self): qs = Stage.objects.select_related( - "leerling", "begeleider", "stagelocatie", "tijdslot" + "leerling", "begeleider", "lokaal", "tijdslot" ).all() type_ = self.request.query_params.get("type") if type_: @@ -32,9 +27,9 @@ class StageViewSet(viewsets.ModelViewSet): @api_view(["GET"]) def stage_conflicten(request): - """Signaleer begeleiders die tegelijk stage én een theorievak doen.""" + """Signaleer stage-conflicten: begeleider op theorievak + dubbele lokaalboeking.""" resultaat = [] - for stage in Stage.objects.filter(actief=True).select_related("leerling", "begeleider"): + for stage in Stage.objects.filter(actief=True).select_related("leerling", "begeleider", "lokaal"): botsing = _theorievak_botsing( stage.begeleider, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot ) @@ -49,6 +44,22 @@ def stage_conflicten(request): ), "blokken": [botsing], }) + if stage.lokaal_id: + bezet = _lokaal_bezet_door_ander( + stage.lokaal, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot, + exclude_stage_id=stage.id, + ) + if bezet: + resultaat.append({ + "type": "lokaal", + "ernst": "fout", + "tijdslot": stage.get_omvang_display(), + "detail": ( + f"Lokaal '{stage.lokaal.naam}' van de stage van " + f"{stage.leerling.volledige_naam} botst met {bezet}." + ), + "blokken": [stage.lokaal.naam], + }) fouten = sum(1 for r in resultaat if r["ernst"] == "fout") return Response({ "aantal": len(resultaat), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 24cb734..e2d43ba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,7 +3,7 @@ import { getMe, login, logout, getModules, setModuleState } from "./api.js"; import Logo from "./Logo.jsx"; import { PersonenPage, FunctiesPage, - ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, + ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, RoosterWeergavePage, RoostermakerPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, } from "./pages.jsx"; @@ -11,7 +11,7 @@ import { const ICONS = { calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", - away: "🌴", gear: "⚙️", + away: "🌴", gear: "⚙️", door: "🚪", }; const GROEPSINDELING_PADEN = new Set(["/groepsindeling", "/structuur", "/leerplein", "/groepen", "/subgroepen"]); @@ -28,6 +28,7 @@ const PAGES = { "/functies": FunctiesPage, "/activiteiten": ActiviteitenPage, "/locaties": LocatiesPage, + "/lokalen": LokalenPage, "/tijdsloten": TijdslotenPage, "/schooljaar": SchooljaarPage, "/afwezigheid": AfwezigheidPage, diff --git a/frontend/src/api.js b/frontend/src/api.js index 70cca28..797f78f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -57,3 +57,7 @@ export const updateResource = (resource, id, body) => request("PATCH", `/${resource}/${id}/`, body); export const deleteResource = (resource, id) => request("DELETE", `/${resource}/${id}/`); + +// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------ +export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/"); +export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data); diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 048cf5b..458cf06 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -5,6 +5,8 @@ import { updateResource, deleteResource, getModules, + getLokaalinstelling, + setLokaalinstelling, } from "./api.js"; const ROLLEN = [ @@ -217,12 +219,85 @@ export function LocatiesPage() { return ( ); } +export function LokalenPage() { + const { items, error, loading, reload, setError } = useList("lokalen"); + const leeg = { naam: "", omschrijving: "", capaciteit: "" }; + const [form, setForm] = useState(leeg); + const [editId, setEditId] = useState(null); + const [blokkeren, setBlokkeren] = useState(false); + const [instError, setInstError] = useState(null); + + useEffect(() => { + getLokaalinstelling().then((d) => setBlokkeren(!!d.dubbel_blokkeren)).catch(() => {}); + }, []); + + async function toggleBlokkeren(val) { + setBlokkeren(val); + try { await setLokaalinstelling({ dubbel_blokkeren: val }); setInstError(null); } + catch (err) { setInstError(err.message); setBlokkeren(!val); } + } + async function opslaan(e) { + e.preventDefault(); + const payload = { naam: form.naam, omschrijving: form.omschrijving, capaciteit: form.capaciteit === "" ? null : Number(form.capaciteit) }; + try { + if (editId) await updateResource("lokalen", editId, payload); + else await createResource("lokalen", payload); + setForm(leeg); setEditId(null); await reload(); + } catch (err) { setError(err.message); } + } + async function verwijder(id) { + if (!confirm("Dit lokaal verwijderen?")) return; + try { await deleteResource("lokalen", id); await reload(); } catch (err) { setError(err.message); } + } + + return ( +
+

Lokalen

+

Lesruimtes die door lessen én interne stages gedeeld worden. Het systeem bewaakt dubbele boekingen per tijdslot.

+ + + +
+
+ setForm({ ...form, naam: e.target.value })} /> + setForm({ ...form, omschrijving: e.target.value })} /> + setForm({ ...form, capaciteit: e.target.value })} /> + + {editId && } +
+
+ {loading ?

Laden…

: ( + + + + {items.map((l) => ( + + + + + + + ))} + {items.length === 0 && } + +
NaamCapaciteitOmschrijving
{l.naam}{l.capaciteit ?? "—"}{l.omschrijving || "—"} + + +
Nog geen lokalen.
+ )} +
+ ); +} + /* --------------------------------- Groepen -------------------------------- */ export function GroepenPage() { return ; @@ -1075,7 +1150,7 @@ export function GroepsindelingPage() { } /* ---------------------------------- Stage --------------------------------- */ -// Module-scherm: interne en externe stages + stagelocaties. +// Module-scherm: interne en externe stages. const OMVANGEN = [ { value: "hele_dag", label: "Hele dag" }, { value: "dagdeel", label: "Dagdeel" }, @@ -1090,20 +1165,17 @@ export function StagePage() { const { items, error, loading, reload, setError } = useList("stages"); const [leerlingen, setLeerlingen] = useState([]); const [medewerkers, setMedewerkers] = useState([]); - const [stagelocaties, setStagelocaties] = useState([]); + const [lokalen, setLokalen] = useState([]); const [tijdsloten, setTijdsloten] = useState([]); - const leeg = { leerling: "", type: "intern", begeleider: "", stagelocatie: "", organisatie: "", omvang: "hele_dag", dag: "", dagdeel: "", tijdslot: "", omschrijving: "" }; + const leeg = { leerling: "", type: "intern", begeleider: "", lokaal: "", organisatie: "", omvang: "hele_dag", dag: "", dagdeel: "", tijdslot: "", omschrijving: "" }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); - async function laadStagelocaties() { - try { setStagelocaties(await listResource("stagelocaties")); } catch { setStagelocaties([]); } - } useEffect(() => { listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("tijdsloten").then(setTijdsloten).catch(() => {}); - laadStagelocaties(); + listResource("lokalen").then(setLokalen).catch(() => setLokalen([])); }, []); const isIntern = form.type === "intern"; @@ -1114,7 +1186,7 @@ export function StagePage() { e.preventDefault(); const payload = { leerling: form.leerling, type: form.type, begeleider: form.begeleider, - stagelocatie: isIntern ? (form.stagelocatie || null) : null, + lokaal: isIntern ? (form.lokaal || null) : null, organisatie: isIntern ? "" : form.organisatie, omvang: form.omvang, dag: isBlok ? null : (form.dag === "" ? null : Number(form.dag)), @@ -1148,7 +1220,7 @@ export function StagePage() { return (

Stage

-

Interne en externe stages. Beide krijgen een begeleider; een interne stage een stageruimte. Stel in per hele dag, dagdeel of los blok.

+

Interne en externe stages. Beide krijgen een begeleider; een interne stage gebruikt een lokaal (met dubbele-boekingcheck). Stel in per hele dag, dagdeel of los blok.

@@ -1167,32 +1239,46 @@ export function StagePage() {
{isIntern ? ( - setForm({ ...form, lokaal: e.target.value })}> + + {lokalen.map((l) => )} ) : ( setForm({ ...form, organisatie: e.target.value })} /> )} - +
+
+ {isBlok ? ( - + ) : ( - + )} {isDagdeel && ( - + )}
@@ -1212,10 +1298,10 @@ export function StagePage() { {s.leerling_naam} {s.type_label} {s.begeleider_naam} - {s.type === "intern" ? (s.stagelocatie_naam || "—") : (s.organisatie || "—")} + {s.type === "intern" ? (s.lokaal_naam || "—") : (s.organisatie || "—")} {wanneerTekst(s)} - + @@ -1224,63 +1310,10 @@ export function StagePage() { )} -
); } -function StagelocatieBeheer({ locaties, onChange }) { - const leeg = { naam: "", omschrijving: "" }; - const [form, setForm] = useState(leeg); - const [editId, setEditId] = useState(null); - const [error, setError] = useState(null); - - async function opslaan(e) { - e.preventDefault(); - try { - if (editId) await updateResource("stagelocaties", editId, form); - else await createResource("stagelocaties", form); - setForm(leeg); setEditId(null); await onChange(); - } catch (err) { setError(err.message); } - } - async function verwijder(id) { - if (!confirm("Deze stagelocatie verwijderen?")) return; - try { await deleteResource("stagelocaties", id); await onChange(); } catch (err) { setError(err.message); } - } - - return ( -
-

Stagelocaties

-

Aparte stageruimtes, los van de gewone lokalen.

- - -
- setForm({ ...form, naam: e.target.value })} /> - setForm({ ...form, omschrijving: e.target.value })} /> - - {editId && } -
- - - - - {locaties.map((l) => ( - - - - - - ))} - {locaties.length === 0 && } - -
NaamOmschrijving
{l.naam}{l.omschrijving || "—"} - - -
Nog geen stagelocaties.
-
- ); -} - /* -------------------------------- Gebruikers ------------------------------ */ const GEBRUIKERSROLLEN = [ { value: "beheerder", label: "Beheerder" }, @@ -1459,11 +1492,12 @@ function bovenliggendeGroep(blok, subgroepen) { /* ----------------------------- Blok bewerken/kopiëren --------------------- */ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { - const { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx; + const { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx; const [f, setF] = useState({ tijdslot: blok.tijdslot, activiteit: blok.activiteit, locatie: blok.locatie || "", + lokaal: blok.lokaal || "", doel_type: blok.doel_type, doel: blok.groep || blok.subgroep || blok.leerling || "", begeleiders: [...(blok.begeleiders || [])], @@ -1514,7 +1548,7 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { if (splitSubs.length === 0) return; try { for (const sid of splitSubs) { - await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); + await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); } if (verwijderOrig) await deleteResource("roosterblokken", blok.id); await onKlaar(); @@ -1528,13 +1562,13 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { leerling: f.doel_type === "leerling" ? f.doel : null, }); async function opslaan() { - try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } + try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } catch (e) { setError(e.message); } } async function kopieer() { try { for (const ts of kopieDoelen) { - await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); + await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); } await onKlaar(); } catch (e) { setError(e.message); } @@ -1570,6 +1604,10 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { {locaties.map((l) => )} + setF({ ...f, opmerkingen: e.target.value })} /> @@ -1779,6 +1817,7 @@ export function RoosterScherm({ mode }) { const [tijdsloten, setTijdsloten] = useState([]); const [activiteiten, setActiviteiten] = useState([]); const [locaties, setLocaties] = useState([]); + const [lokalen, setLokalen] = useState([]); const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); @@ -1799,7 +1838,7 @@ export function RoosterScherm({ mode }) { // Blok bewerken/kopiëren (alleen in de Roostermaker). const [bewerkBlok, setBewerkBlok] = useState(null); - const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; + const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; const [form, setForm] = useState(leeg); const [splitNieuwBlok, setSplitNieuwBlok] = useState(false); const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]); @@ -1815,6 +1854,7 @@ export function RoosterScherm({ mode }) { listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("activiteiten").then(setActiviteiten).catch(() => {}); listResource("locaties").then(setLocaties).catch(() => {}); + listResource("lokalen").then(setLokalen).catch(() => {}); listResource("groepen").then(setGroepen).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); @@ -1940,7 +1980,7 @@ export function RoosterScherm({ mode }) { } async function opslaan(e) { e.preventDefault(); - const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; + const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; try { if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) { for (const subgroep of splitNieuwDoelen) { @@ -2012,7 +2052,7 @@ export function RoosterScherm({ mode }) { if (namen.length === 0) return null; return
📍 Op stage: {namen.join(", ")}
; } - const editorCtx = { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }; + const editorCtx = { activiteiten, locaties, lokalen, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }; const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id; const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b)); function renderRoosterBlok(b, iso, kal) { @@ -2034,6 +2074,7 @@ export function RoosterScherm({ mode }) {
Doel: {b.doel_naam} ({b.doel_type})
{b.locatie_naam &&
Locatie: {b.locatie_naam}
} + {b.lokaal_naam &&
Lokaal: {b.lokaal_naam}
} {b.begeleiders_namen.length > 0 && (
Begeleiding: {b.begeleiders_namen.map((x, idx) => ( {idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""} @@ -2068,6 +2109,7 @@ export function RoosterScherm({ mode }) {
{b.locatie_naam && {b.locatie_naam}} + {b.lokaal_naam && {b.lokaal_naam}} {b.begeleiders_namen.length > 0 && {b.begeleiders_namen.map((x) => x.naam).join(", ")}} {vervallen && Vervalt}
@@ -2264,6 +2306,10 @@ export function RoosterScherm({ mode }) { {locaties.map((l) => )} + setForm({ ...form, opmerkingen: e.target.value })} /> @@ -2371,11 +2417,12 @@ export function RoosterScherm({ mode }) { )} - {schooljaar && tab === "instellingen" && ( + {tab === "instellingen" && (

Bouwstenen van het rooster — beheer ze hier zonder het Rooster te verlaten.

+
)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 23d810c..f81c35e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -355,3 +355,5 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .split-vak.vervallen .rb-stage { opacity: .7; } .subsectie { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--line); } .subsectie h2 { margin: 0 0 4px; font-size: 18px; } +.veld-kol { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; } +.veld-kop { font-size: 11px; font-weight: 700; color: var(--muted); }