From 52feabd744097855a63cc32f0288fe7aecd9c4f2 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:54:47 +0200 Subject: [PATCH] Fase 5: per-groep tijden, Stage-module (intern/extern), gesplitste personen-flow met functies/stamgroep/vakken en automatische theorievakken --- .gitignore | 60 ++- README.md | 14 +- backend/config/settings.py | 1 + backend/config/urls.py | 1 + backend/core/admin.py | 16 +- ...eorievak_persoon_geboortedatum_and_more.py | 39 ++ backend/core/models.py | 40 +- backend/core/serializers.py | 31 +- backend/core/views.py | 2 +- backend/db.sqlite3-journal | Bin 512 -> 0 bytes backend/modules/stage/__init__.py | 0 backend/modules/stage/admin.py | 10 + backend/modules/stage/apps.py | 29 ++ .../modules/stage/migrations/0001_initial.py | 36 ++ backend/modules/stage/migrations/__init__.py | 0 backend/modules/stage/models.py | 60 +++ backend/modules/stage/serializers.py | 35 ++ backend/modules/stage/urls.py | 9 + backend/modules/stage/views.py | 18 + frontend/src/App.jsx | 3 +- frontend/src/pages.jsx | 393 ++++++++++++------ 21 files changed, 646 insertions(+), 151 deletions(-) create mode 100644 backend/core/migrations/0004_activiteit_theorievak_persoon_geboortedatum_and_more.py delete mode 100644 backend/db.sqlite3-journal create mode 100644 backend/modules/stage/__init__.py create mode 100644 backend/modules/stage/admin.py create mode 100644 backend/modules/stage/apps.py create mode 100644 backend/modules/stage/migrations/0001_initial.py create mode 100644 backend/modules/stage/migrations/__init__.py create mode 100644 backend/modules/stage/models.py create mode 100644 backend/modules/stage/serializers.py create mode 100644 backend/modules/stage/urls.py create mode 100644 backend/modules/stage/views.py diff --git a/.gitignore b/.gitignore index 5222ccd..184a620 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,70 @@ +# ====================================================================== # Geheimen / omgeving +# ====================================================================== .env +.env.* +!.env.example -# Python +# ====================================================================== +# Python / Django +# ====================================================================== __pycache__/ -*.pyc -backend/db.sqlite3 -backend/staticfiles/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ .venv/ venv/ +env/ +ENV/ -# Node +# Databases (lokaal SQLite) +*.sqlite3 +*.sqlite3-journal +backend/db.sqlite3 +backend/db.sqlite3-journal + +# Door Django verzamelde/geüploade bestanden +backend/staticfiles/ +staticfiles/ +media/ + +# Test- en coverage-artefacten +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ + +# ====================================================================== +# Node / frontend +# ====================================================================== +node_modules/ frontend/node_modules/ frontend/dist/ +frontend/.vite/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +# ====================================================================== +# Logs +# ====================================================================== +*.log +logs/ + +# ====================================================================== # Editor / OS +# ====================================================================== .vscode/ .idea/ +*.swp +*.swo .DS_Store Thumbs.db +desktop.ini diff --git a/README.md b/README.md index 9bd916c..f878217 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,18 @@ 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 +groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein. +Optionele module **Stage**: interne en externe stages van leerlingen — beide met +een begeleider, een interne stage ook met een ruimte. Beide modules zijn aan/uit te zetten via Modulebeheer. +**Fase 5 (recent)**: vaste tijden zijn per groep aan te passen (een tijdslot +zonder groep = algemene tijd, met groep = afwijkende tijd). Personeel en +leerlingen hebben gescheiden invoerschermen. Bij personeel koppel je direct +functie(s), stamgroep/plein en de vakken die ze kunnen geven; een leerkracht +krijgt de theorievakken automatisch. Bij een leerling leg je groep en +geboortedatum vast. + ## Wat er nu staat - **Backend** (`backend/`) — Django + Django REST Framework. @@ -74,6 +83,9 @@ API-eindpunten: - `GET /api/conflicten/?schooljaar=` — roosterconflicten (dubbele inzet, locatiebotsing, onderbezetting). - Module Leerplein: `/api/leerpleinen/`, `/api/subgroep-groep-koppelingen/`. +- Module Stage: `/api/stages/` (filters `?type=intern|extern`, `?leerling=`). +- Tijdsloten kennen een optionele `groep` (per-groep tijden); personen hebben + `stamgroep`, `vakken` en `geboortedatum`; activiteiten een `theorievak`-vlag. - `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus). > Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan diff --git a/backend/config/settings.py b/backend/config/settings.py index 7d21061..8e25f7c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -73,6 +73,7 @@ MODULE_APPS = [ "modules.printing", # Printen/Exporteren "modules.accounts", # Multi-user & toegang "modules.leerplein", # Leerplein (groepsstructuur) + "modules.stage", # Stage (intern/extern) ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS diff --git a/backend/config/urls.py b/backend/config/urls.py index 5fb671f..6b8d9b0 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -10,4 +10,5 @@ urlpatterns = [ path("api/", include("core.urls")), # Module-API's. path("api/", include("modules.leerplein.urls")), + path("api/", include("modules.stage.urls")), ] diff --git a/backend/core/admin.py b/backend/core/admin.py index ab02fbc..7fdaca6 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -22,11 +22,11 @@ class GroepAdmin(admin.ModelAdmin): @admin.register(Persoon) class PersoonAdmin(admin.ModelAdmin): - list_display = ("achternaam", "voornaam", "rol", "groep", "actief") - list_filter = ("rol", "actief", "groep", "functies") + list_display = ("achternaam", "voornaam", "rol", "groep", "stamgroep", "actief") + list_filter = ("rol", "actief", "groep", "stamgroep", "functies") search_fields = ("voornaam", "achternaam") - autocomplete_fields = ("groep",) - filter_horizontal = ("functies",) + autocomplete_fields = ("groep", "stamgroep") + filter_horizontal = ("functies", "vakken") @admin.register(Subgroep) @@ -53,14 +53,14 @@ class KalenderDagAdmin(admin.ModelAdmin): @admin.register(Tijdslot) class TijdslotAdmin(admin.ModelAdmin): - list_display = ("naam", "dag", "start_tijd", "eind_tijd", "volgorde", "actief") - list_filter = ("dag", "actief") + list_display = ("naam", "dag", "start_tijd", "eind_tijd", "volgorde", "groep", "actief") + list_filter = ("dag", "actief", "groep") @admin.register(Activiteit) class ActiviteitAdmin(admin.ModelAdmin): - list_display = ("naam", "kleur", "actief") - list_filter = ("actief",) + list_display = ("naam", "kleur", "theorievak", "actief") + list_filter = ("theorievak", "actief") search_fields = ("naam",) diff --git a/backend/core/migrations/0004_activiteit_theorievak_persoon_geboortedatum_and_more.py b/backend/core/migrations/0004_activiteit_theorievak_persoon_geboortedatum_and_more.py new file mode 100644 index 0000000..de17b63 --- /dev/null +++ b/backend/core/migrations/0004_activiteit_theorievak_persoon_geboortedatum_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_afwezigheid_blokuitzondering'), + ] + + operations = [ + migrations.AddField( + model_name='activiteit', + name='theorievak', + field=models.BooleanField(default=False, help_text='Theorievakken worden standaard aan nieuwe leerkrachten gekoppeld.', verbose_name='theorievak'), + ), + migrations.AddField( + model_name='persoon', + name='geboortedatum', + field=models.DateField(blank=True, null=True, verbose_name='geboortedatum'), + ), + migrations.AddField( + model_name='persoon', + name='stamgroep', + field=models.ForeignKey(blank=True, help_text='Stamgroep/plein van een medewerker.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='medewerkers', to='core.groep', verbose_name='stamgroep'), + ), + migrations.AddField( + model_name='persoon', + name='vakken', + field=models.ManyToManyField(blank=True, help_text='Vakken die een medewerker kan geven.', related_name='docenten', to='core.activiteit', verbose_name='vakken'), + ), + migrations.AddField( + model_name='tijdslot', + name='groep', + field=models.ForeignKey(blank=True, help_text='Leeg = algemene (vaste) tijden; gevuld = afwijkende tijden voor die groep.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tijdsloten', to='core.groep', verbose_name='groep'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index d306665..3d3835e 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -86,6 +86,23 @@ class Persoon(models.Model): blank=True, help_text="Alleen van toepassing op medewerkers.", ) + stamgroep = models.ForeignKey( + Groep, + verbose_name="stamgroep", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="medewerkers", + help_text="Stamgroep/plein van een medewerker.", + ) + vakken = models.ManyToManyField( + "Activiteit", + verbose_name="vakken", + related_name="docenten", + blank=True, + help_text="Vakken die een medewerker kan geven.", + ) + geboortedatum = models.DateField("geboortedatum", null=True, blank=True) actief = models.BooleanField("actief", default=True) opmerkingen = models.TextField("opmerkingen", blank=True) aangemaakt_op = models.DateTimeField(auto_now_add=True) @@ -107,9 +124,11 @@ class Persoon(models.Model): return self.rol == Rol.LEERLING def save(self, *args, **kwargs): - # Een groepskoppeling hoort alleen bij leerlingen. + # 'groep' is leerling-only; 'stamgroep' is medewerker-only. if self.rol != Rol.LEERLING: self.groep = None + else: + self.stamgroep = None super().save(*args, **kwargs) if self.pk: # Wie geen leerling (meer) is, hoort niet in subgroepen thuis. @@ -117,8 +136,9 @@ class Persoon(models.Model): if self.rol != Rol.LEERLING: self.subgroepen.clear() else: - # Een leerling heeft geen medewerkersfuncties. + # Een leerling heeft geen medewerkersfuncties of -vakken. self.functies.clear() + self.vakken.clear() class Subgroep(models.Model): @@ -223,6 +243,15 @@ class Tijdslot(models.Model): start_tijd = models.TimeField("starttijd") eind_tijd = models.TimeField("eindtijd") volgorde = models.PositiveSmallIntegerField("volgorde", default=0) + groep = models.ForeignKey( + Groep, + verbose_name="groep", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="tijdsloten", + help_text="Leeg = algemene (vaste) tijden; gevuld = afwijkende tijden voor die groep.", + ) actief = models.BooleanField("actief", default=True) class Meta: @@ -231,7 +260,8 @@ class Tijdslot(models.Model): ordering = ["dag", "volgorde", "start_tijd"] def __str__(self) -> str: - return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}" + suffix = f" ({self.groep.naam})" if self.groep_id else "" + return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}{suffix}" class Activiteit(models.Model): @@ -240,6 +270,10 @@ class Activiteit(models.Model): naam = models.CharField("naam", max_length=80, unique=True) kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb") omschrijving = models.CharField("omschrijving", max_length=255, blank=True) + theorievak = models.BooleanField( + "theorievak", default=False, + help_text="Theorievakken worden standaard aan nieuwe leerkrachten gekoppeld.", + ) actief = models.BooleanField("actief", default=True) class Meta: diff --git a/backend/core/serializers.py b/backend/core/serializers.py index a2eece7..6d77707 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -29,32 +29,51 @@ class PersoonSerializer(serializers.ModelSerializer): volledige_naam = serializers.CharField(read_only=True) rol_label = serializers.CharField(source="get_rol_display", read_only=True) groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None) + stamgroep_naam = serializers.CharField(source="stamgroep.naam", read_only=True, default=None) functies = serializers.PrimaryKeyRelatedField( many=True, queryset=Functie.objects.all(), required=False ) functies_namen = serializers.SerializerMethodField() + vakken = serializers.PrimaryKeyRelatedField( + many=True, queryset=Activiteit.objects.all(), required=False + ) + vakken_namen = serializers.SerializerMethodField() class Meta: model = Persoon fields = [ "id", "voornaam", "achternaam", "volledige_naam", "rol", "rol_label", "groep", "groep_naam", - "functies", "functies_namen", - "actief", "opmerkingen", "aangemaakt_op", + "stamgroep", "stamgroep_naam", + "functies", "functies_namen", "vakken", "vakken_namen", + "geboortedatum", "actief", "opmerkingen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] def get_functies_namen(self, obj): return [{"id": f.id, "naam": f.naam} for f in obj.functies.all()] + def get_vakken_namen(self, obj): + return [{"id": a.id, "naam": a.naam} for a in obj.vakken.all()] + def validate(self, data): - # Functies horen alleen bij medewerkers; een leerling krijgt er geen. + # Functies/vakken horen alleen bij medewerkers; een leerling krijgt er geen. # (De M2M wordt door DRF ná save() gezet, dus hier afdwingen.) rol = data.get("rol", getattr(self.instance, "rol", None)) if rol == "leerling": data["functies"] = [] + data["vakken"] = [] return data + def create(self, validated_data): + # Een leerkracht krijgt de theorievakken standaard direct gekoppeld. + if validated_data.get("rol") == "leerkracht": + theorie = list(Activiteit.objects.filter(theorievak=True, actief=True)) + gekozen = validated_data.get("vakken", []) + ids = {v.id for v in gekozen} + validated_data["vakken"] = gekozen + [a for a in theorie if a.id not in ids] + return super().create(validated_data) + class SubgroepSerializer(serializers.ModelSerializer): leden = serializers.PrimaryKeyRelatedField( @@ -104,9 +123,11 @@ class KalenderDagSerializer(serializers.ModelSerializer): class TijdslotSerializer(serializers.ModelSerializer): dag_label = serializers.CharField(source="get_dag_display", read_only=True) + groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None) + class Meta: model = Tijdslot - fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "actief"] + fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "groep", "groep_naam", "actief"] def validate(self, data): start = data.get("start_tijd", getattr(self.instance, "start_tijd", None)) @@ -124,7 +145,7 @@ class ActiviteitSerializer(serializers.ModelSerializer): class Meta: model = Activiteit - fields = ["id", "naam", "kleur", "omschrijving", "actief"] + fields = ["id", "naam", "kleur", "omschrijving", "theorievak", "actief"] class LocatieSerializer(serializers.ModelSerializer): diff --git a/backend/core/views.py b/backend/core/views.py index 1950541..8c616de 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -105,7 +105,7 @@ class PersoonViewSet(viewsets.ModelViewSet): serializer_class = PersoonSerializer def get_queryset(self): - qs = Persoon.objects.select_related("groep").prefetch_related("functies").all() + qs = Persoon.objects.select_related("groep", "stamgroep").prefetch_related("functies", "vakken").all() rol = self.request.query_params.get("rol") if rol: qs = qs.filter(rol=rol) diff --git a/backend/db.sqlite3-journal b/backend/db.sqlite3-journal deleted file mode 100644 index 965fdf3e4c34b2933e079857b369a0687cdae38b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 512 kcmcb4mG!5>!sP1=K=3}|Ejx&2U|<4b0WfQn7~UZO0K7^D^#A|> diff --git a/backend/modules/stage/__init__.py b/backend/modules/stage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/stage/admin.py b/backend/modules/stage/admin.py new file mode 100644 index 0000000..7543e3e --- /dev/null +++ b/backend/modules/stage/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Stage + + +@admin.register(Stage) +class StageAdmin(admin.ModelAdmin): + list_display = ("leerling", "type", "begeleider", "locatie", "organisatie", "actief") + list_filter = ("type", "actief", "dag") + search_fields = ("leerling__voornaam", "leerling__achternaam", "organisatie") diff --git a/backend/modules/stage/apps.py b/backend/modules/stage/apps.py new file mode 100644 index 0000000..35b898b --- /dev/null +++ b/backend/modules/stage/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class StageConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.stage" + label = "module_stage" + verbose_name = "Module: Stage (intern/extern)" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="stage", + name="Stage (intern/extern)", + description=( + "Beheer interne en externe stages van leerlingen. Beide " + "krijgen een begeleider; een interne stage ook een ruimte." + ), + version="0.1.0", + category="Structuur", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Stage", "/stage", icon="pin", order=60, group="Groepen"), + ), + ) + ) diff --git a/backend/modules/stage/migrations/0001_initial.py b/backend/modules/stage/migrations/0001_initial.py new file mode 100644 index 0000000..63eb06a --- /dev/null +++ b/backend/modules/stage/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# 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', '0004_activiteit_theorievak_persoon_geboortedatum_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Stage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('intern', 'Interne stage'), ('extern', 'Externe stage')], max_length=10, verbose_name='type')), + ('organisatie', models.CharField(blank=True, max_length=160, verbose_name='externe organisatie')), + ('dag', models.IntegerField(blank=True, choices=[(0, 'Maandag'), (1, 'Dinsdag'), (2, 'Woensdag'), (3, 'Donderdag'), (4, 'Vrijdag')], null=True, verbose_name='dag')), + ('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)), + ('begeleider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stagebegeleidingen', to='core.persoon')), + ('leerling', models.ForeignKey(limit_choices_to={'rol': 'leerling'}, on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='core.persoon')), + ('locatie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stages', to='core.locatie')), + ], + options={ + 'verbose_name': 'stage', + 'verbose_name_plural': 'stages', + 'ordering': ['leerling__achternaam', 'leerling__voornaam'], + }, + ), + ] diff --git a/backend/modules/stage/migrations/__init__.py b/backend/modules/stage/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/stage/models.py b/backend/modules/stage/models.py new file mode 100644 index 0000000..0e88334 --- /dev/null +++ b/backend/modules/stage/models.py @@ -0,0 +1,60 @@ +""" +Stage-module: interne en externe stages van leerlingen. + +- Beide stagevormen krijgen een begeleider (verplicht). +- Een interne stage krijgt daarnaast een ruimte (locatie). +- Een externe stage legt de externe organisatie vast. +""" +from django.core.exceptions import ValidationError +from django.db import models + + +class StageType(models.TextChoices): + INTERN = "intern", "Interne stage" + EXTERN = "extern", "Externe stage" + + +class Weekdag(models.IntegerChoices): + MAANDAG = 0, "Maandag" + DINSDAG = 1, "Dinsdag" + WOENSDAG = 2, "Woensdag" + DONDERDAG = 3, "Donderdag" + VRIJDAG = 4, "Vrijdag" + + +class Stage(models.Model): + leerling = models.ForeignKey( + "core.Persoon", on_delete=models.CASCADE, related_name="stages", + limit_choices_to={"rol": "leerling"}, + ) + type = models.CharField("type", max_length=10, choices=StageType.choices) + begeleider = models.ForeignKey( + "core.Persoon", on_delete=models.PROTECT, related_name="stagebegeleidingen", + help_text="Verplicht voor zowel interne als externe stage.", + ) + # Interne stage: een ruimte binnen de school. + locatie = models.ForeignKey( + "core.Locatie", on_delete=models.SET_NULL, null=True, blank=True, + related_name="stages", + ) + # Externe stage: de externe organisatie. + organisatie = models.CharField("externe organisatie", max_length=160, blank=True) + + dag = models.IntegerField("dag", choices=Weekdag.choices, null=True, blank=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 = "stage" + verbose_name_plural = "stages" + ordering = ["leerling__achternaam", "leerling__voornaam"] + + def clean(self): + if self.type == StageType.INTERN and not self.locatie_id: + raise ValidationError("Een interne stage vereist een ruimte (locatie).") + if self.type == StageType.EXTERN and not self.organisatie: + raise ValidationError("Een externe stage vereist een externe organisatie.") + + def __str__(self) -> str: + return f"{self.leerling} – {self.get_type_display()}" diff --git a/backend/modules/stage/serializers.py b/backend/modules/stage/serializers.py new file mode 100644 index 0000000..6a01a7e --- /dev/null +++ b/backend/modules/stage/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers + +from .models import 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) + locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None) + type_label = serializers.CharField(source="get_type_display", read_only=True) + dag_label = serializers.CharField(source="get_dag_display", read_only=True, default=None) + + class Meta: + model = Stage + fields = [ + "id", "leerling", "leerling_naam", "type", "type_label", + "begeleider", "begeleider_naam", "locatie", "locatie_naam", + "organisatie", "dag", "dag_label", "omschrijving", "actief", + "aangemaakt_op", + ] + read_only_fields = ["aangemaakt_op"] + + def validate(self, data): + type_ = data.get("type", getattr(self.instance, "type", None)) + locatie = data.get("locatie", getattr(self.instance, "locatie", None)) + organisatie = data.get("organisatie", getattr(self.instance, "organisatie", "")) + begeleider = data.get("begeleider", getattr(self.instance, "begeleider", None)) + + if begeleider is not None and begeleider.rol == "leerling": + raise serializers.ValidationError("Een leerling kan geen begeleider zijn.") + if type_ == StageType.INTERN and not locatie: + raise serializers.ValidationError("Een interne stage vereist een ruimte (locatie).") + if type_ == StageType.EXTERN and not organisatie: + raise serializers.ValidationError("Een externe stage vereist een externe organisatie.") + return data diff --git a/backend/modules/stage/urls.py b/backend/modules/stage/urls.py new file mode 100644 index 0000000..7376524 --- /dev/null +++ b/backend/modules/stage/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("stages", views.StageViewSet, basename="stage") + +urlpatterns = [path("", include(router.urls))] diff --git a/backend/modules/stage/views.py b/backend/modules/stage/views.py new file mode 100644 index 0000000..4d42026 --- /dev/null +++ b/backend/modules/stage/views.py @@ -0,0 +1,18 @@ +from rest_framework import viewsets + +from .models import Stage +from .serializers import StageSerializer + + +class StageViewSet(viewsets.ModelViewSet): + serializer_class = StageSerializer + + def get_queryset(self): + qs = Stage.objects.select_related("leerling", "begeleider", "locatie").all() + type_ = self.request.query_params.get("type") + if type_: + qs = qs.filter(type=type_) + leerling = self.request.query_params.get("leerling") + if leerling: + qs = qs.filter(leerling_id=leerling) + return qs diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 49f59a7..9ca0204 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, LeerpleinPage, + AfwezigheidPage, LeerpleinPage, StagePage, } from "./pages.jsx"; const ICONS = { @@ -27,6 +27,7 @@ const PAGES = { "/schooljaar": SchooljaarPage, "/afwezigheid": AfwezigheidPage, "/leerplein": LeerpleinPage, + "/stage": StagePage, }; export default function App() { diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index fe2bce7..11348ef 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -55,13 +55,16 @@ function Foutmelding({ msg }) { /* ----------------------- Generieke eenvoudige catalogus ------------------- */ // Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties). -function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { +function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false }) { const { items, error, loading, reload, setError } = useList(resource); - const leeg = kleur - ? { naam: "", omschrijving: "", kleur: "#2563eb" } - : { naam: "", omschrijving: "" }; + const leeg = { + naam: "", omschrijving: "", + ...(kleur ? { kleur: "#2563eb" } : {}), + ...(metTheorievak ? { theorievak: false } : {}), + }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); + const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0); async function opslaan(e) { e.preventDefault(); @@ -84,6 +87,14 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { setError(err.message); } } + function bewerk(it) { + setEditId(it.id); + setForm({ + naam: it.naam, omschrijving: it.omschrijving, + ...(kleur ? { kleur: it.kleur } : {}), + ...(metTheorievak ? { theorievak: it.theorievak } : {}), + }); + } return (
@@ -91,32 +102,20 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { {uitleg &&

{uitleg}

}
- setForm({ ...form, naam: e.target.value })} - /> - setForm({ ...form, omschrijving: e.target.value })} - /> + setForm({ ...form, naam: e.target.value })} /> + setForm({ ...form, omschrijving: e.target.value })} /> {kleur && ( - setForm({ ...form, kleur: e.target.value })} - /> + setForm({ ...form, kleur: e.target.value })} /> )} - + {metTheorievak && ( + + )} + {editId && ( - + )}
{loading ? ( @@ -127,6 +126,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { Naam Omschrijving + {metTheorievak && Theorievak} {telKolom && {telKolom}} @@ -135,37 +135,20 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) { {items.map((it) => ( - {kleur && ( - - )} + {kleur && } {it.naam} {it.omschrijving || "—"} + {metTheorievak && {it.theorievak ? "✓" : "—"}} {telKolom && {it.aantal_personen ?? "—"}} - - + + ))} {items.length === 0 && ( - - Nog niets. - + Nog niets. )} @@ -188,10 +171,11 @@ export function FunctiesPage() { export function ActiviteitenPage() { return ( ); } @@ -270,43 +254,51 @@ export function GroepenPage() { } /* -------------------------------- Personen -------------------------------- */ +const STAFF_ROLLEN = ROLLEN.filter((r) => r.value !== "leerling"); + export function PersonenPage() { const { items, error, loading, reload, setError } = useList("personen"); const [groepen, setGroepen] = useState([]); const [functies, setFuncties] = useState([]); - const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "", functies: [] }; - const [form, setForm] = useState(leeg); + const [vakken, setVakken] = useState([]); + // Twee losse flows: medewerker en leerling. + const [modus, setModus] = useState("leerling"); + const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [] }; + const leegLeerling = { voornaam: "", achternaam: "", groep: "", geboortedatum: "" }; + const [form, setForm] = useState(leegLeerling); const [editId, setEditId] = useState(null); const [filter, setFilter] = useState(""); useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); listResource("functies").then(setFuncties).catch(() => {}); + listResource("activiteiten").then(setVakken).catch(() => {}); }, []); - const isLeerling = form.rol === "leerling"; - - function toggleFunctie(id) { - setForm((f) => - f.functies.includes(id) - ? { ...f, functies: f.functies.filter((x) => x !== id) } - : { ...f, functies: [...f.functies, id] } - ); + function kiesModus(m) { + setModus(m); + setEditId(null); + setForm(m === "medewerker" ? leegMedewerker : leegLeerling); + } + 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(); - const payload = { - voornaam: form.voornaam, - achternaam: form.achternaam, - rol: form.rol, - groep: isLeerling && form.groep ? form.groep : null, - functies: isLeerling ? [] : form.functies, - }; + const payload = modus === "medewerker" + ? { + voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol, + stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken, + } + : { + voornaam: form.voornaam, achternaam: form.achternaam, rol: "leerling", + groep: form.groep || null, geboortedatum: form.geboortedatum || null, + }; try { if (editId) await updateResource("personen", editId, payload); else await createResource("personen", payload); - setForm(leeg); + setForm(modus === "medewerker" ? leegMedewerker : leegLeerling); setEditId(null); await reload(); } catch (err) { @@ -315,92 +307,122 @@ export function PersonenPage() { } async function verwijder(id) { if (!confirm("Deze persoon verwijderen?")) return; - try { - await deleteResource("personen", id); - await reload(); - } catch (err) { - setError(err.message); + try { await deleteResource("personen", id); await reload(); } catch (err) { setError(err.message); } + } + function bewerk(p) { + setEditId(p.id); + if (p.rol === "leerling") { + setModus("leerling"); + setForm({ voornaam: p.voornaam, achternaam: p.achternaam, groep: p.groep || "", geboortedatum: p.geboortedatum || "" }); + } else { + setModus("medewerker"); + setForm({ + voornaam: p.voornaam, achternaam: p.achternaam, rol: p.rol, + stamgroep: p.stamgroep || "", + functies: p.functies_namen.map((x) => x.id), + vakken: p.vakken_namen.map((x) => x.id), + }); } } - const zichtbaar = filter ? items.filter((p) => p.rol === filter) : items; + const zichtbaar = filter === "leerling" ? items.filter((p) => p.rol === "leerling") + : filter === "medewerker" ? items.filter((p) => p.rol !== "leerling") + : items; return (

Personen

-

Leerlingen en medewerkers. Medewerkers kunnen meerdere functies hebben.

+

Personeel en leerlingen staan los van elkaar. Kies wat je wilt toevoegen.

+
+ + +
+
setForm({ ...form, voornaam: e.target.value })} /> setForm({ ...form, achternaam: e.target.value })} /> - - {isLeerling && ( - + {modus === "leerling" ? ( + <> + + + + ) : ( + <> + + + )}
- {!isLeerling && ( -
-
Functies (meerdere mogelijk):
-
- {functies.map((f) => ( - - ))} - {functies.length === 0 && Nog geen functies — voeg ze toe bij Functies.} + + {modus === "medewerker" && ( + <> +
+
Functies:
+
+ {functies.map((f) => )} + {functies.length === 0 && Nog geen functies.} +
-
+
+
Vakken die gegeven kunnen worden{form.rol === "leerkracht" ? " (theorievakken worden automatisch gekoppeld)" : ""}:
+
+ {vakken.map((a) => )} + {vakken.length === 0 && Nog geen vakken/activiteiten.} +
+
+ )} +
- {editId && } + {editId && }
- Filter: + Toon: - {ROLLEN.map((r) => ( - - ))} + +
{loading ?

Laden…

: ( - + {zichtbaar.map((p) => ( - @@ -504,15 +526,19 @@ export function SubgroepenPage() { /* ------------------------------- Tijdsloten ------------------------------- */ export function TijdslotenPage() { const { items, error, loading, reload, setError } = useList("tijdsloten"); - const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1 }; + const [groepen, setGroepen] = useState([]); + const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1, groep: "" }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); + useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []); + async function opslaan(e) { e.preventDefault(); try { - if (editId) await updateResource("tijdsloten", editId, form); - else await createResource("tijdsloten", form); + const payload = { ...form, groep: form.groep || null }; + if (editId) await updateResource("tijdsloten", editId, payload); + else await createResource("tijdsloten", payload); setForm(leeg); setEditId(null); await reload(); @@ -533,7 +559,7 @@ export function TijdslotenPage() { return (

Tijdsloten

-

Vaste tijdblokken per schooldag (bv. Blok 1, maandag 08:30–10:00).

+

Vaste tijden per schooldag. Laat de groep leeg voor de algemene tijden, of kies een groep voor afwijkende tijden.

setForm({ ...form, naam: e.target.value })} /> @@ -543,25 +569,30 @@ export function TijdslotenPage() { setForm({ ...form, start_tijd: e.target.value })} /> setForm({ ...form, eind_tijd: e.target.value })} /> setForm({ ...form, volgorde: Number(e.target.value) })} /> + {editId && } {loading ?

Laden…

: (
NaamRolGroep / Functies
NaamRolDetails
{p.volledige_naam} {p.rol_label} - {p.rol === "leerling" - ? (p.groep_naam || "—") - : (p.functies_namen.length ? p.functies_namen.map((x) => x.naam).join(", ") : "—")} + + {p.rol === "leerling" ? ( + <> + {p.groep_naam ? 🗂️ {p.groep_naam} : null} + {p.geboortedatum ? 🎂 {p.geboortedatum} : null} + {!p.groep_naam && !p.geboortedatum ? "—" : null} + + ) : ( + <> + {p.stamgroep_naam ?
🗂️ {p.stamgroep_naam}
: null} + {p.functies_namen.length ?
🪪 {p.functies_namen.map((x) => x.naam).join(", ")}
: null} + {p.vakken_namen.length ?
📚 {p.vakken_namen.map((x) => x.naam).join(", ")}
: null} + {!p.stamgroep_naam && !p.functies_namen.length && !p.vakken_namen.length ? "—" : null} + + )}
- +
- + {items.map((t) => ( + ))} - {items.length === 0 && } + {items.length === 0 && }
DagTijdNaam
DagTijdNaamGeldt voor
{t.dag_label} {t.start_tijd?.slice(0, 5)}–{t.eind_tijd?.slice(0, 5)} {t.naam}{t.groep_naam || "algemeen"} - +
Nog geen tijdsloten.
Nog geen tijdsloten.
)} @@ -841,6 +872,114 @@ export function LeerpleinPage() { ); } +/* ---------------------------------- Stage --------------------------------- */ +// Module-scherm: interne en externe stages. Beide met begeleider; intern + ruimte. +export function StagePage() { + const { items, error, loading, reload, setError } = useList("stages"); + const [leerlingen, setLeerlingen] = useState([]); + const [medewerkers, setMedewerkers] = useState([]); + const [locaties, setLocaties] = useState([]); + const leeg = { leerling: "", type: "intern", begeleider: "", locatie: "", organisatie: "", dag: "", omschrijving: "" }; + const [form, setForm] = useState(leeg); + const [editId, setEditId] = useState(null); + + useEffect(() => { + listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); + listResource("locaties").then(setLocaties).catch(() => {}); + }, []); + + const isIntern = form.type === "intern"; + + async function opslaan(e) { + e.preventDefault(); + const payload = { + leerling: form.leerling, type: form.type, begeleider: form.begeleider, + locatie: isIntern ? (form.locatie || null) : null, + organisatie: isIntern ? "" : form.organisatie, + dag: form.dag === "" ? null : Number(form.dag), + omschrijving: form.omschrijving, + }; + try { + if (editId) await updateResource("stages", editId, payload); + else await createResource("stages", payload); + setForm(leeg); + setEditId(null); + await reload(); + } catch (err) { + setError(err.message); + } + } + async function verwijder(id) { + if (!confirm("Deze stage verwijderen?")) return; + try { await deleteResource("stages", id); await reload(); } catch (err) { setError(err.message); } + } + + return ( +
+

Stage

+

Interne en externe stages. Beide krijgen een begeleider; een interne stage ook een ruimte.

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

Laden…

: ( + + + + {items.map((s) => ( + + + + + + + + + ))} + {items.length === 0 && } + +
LeerlingTypeBegeleiderWaarDag
{s.leerling_naam}{s.type_label}{s.begeleider_naam}{s.type === "intern" ? (s.locatie_naam || "—") : (s.organisatie || "—")}{s.dag_label || "—"} + + +
Nog geen stages.
+ )} +
+ ); +} + /* -------------------------------- Conflicten ------------------------------ */ function Conflicten({ schooljaar }) { const [data, setData] = useState(null);