From a9dff26d9a08a9bbc690632dcbe556fd1d20c4a7 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:17:52 +0200 Subject: [PATCH] Lokalen-systeem met dubbele-boekingcheck (warn + per-school blokkeren); stage gebruikt lokaal; dagdeel-UI verduidelijkt --- ROADMAP.md | 18 +- backend/modules/stage/admin.py | 15 +- ...02_stagelocatie_omvang_dagdeel_tijdslot.py | 57 ++++++ backend/modules/stage/models.py | 95 +++++++++- backend/modules/stage/serializers.py | 87 ++++++++-- backend/modules/stage/urls.py | 6 +- backend/modules/stage/views.py | 46 ++++- frontend/src/pages.jsx | 164 ++++++++++++++++-- frontend/src/styles.css | 7 + 9 files changed, 444 insertions(+), 51 deletions(-) create mode 100644 backend/modules/stage/migrations/0002_stagelocatie_omvang_dagdeel_tijdslot.py diff --git a/ROADMAP.md b/ROADMAP.md index 906a1ac..775f6f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,12 +45,13 @@ volgt. ## Stage-module — uitbreidingen -- [ ] **Aparte (stage)locaties** kunnen toevoegen, los van de gewone lokalen. -- [ ] Een **begeleider aan meerdere stages** koppelen, maar **niet tegelijk aan - een theorievak** (conflict signaleren/voorkomen). -- [ ] Een leerling **verdwijnt op een stagedag(deel) uit de klas** in het rooster - (de betreffende blokken vervallen voor die leerling). -- [ ] Stage instelbaar **per blok, per dagdeel of per dag**. +- [x] **Aparte (stage)locaties** kunnen toevoegen, los van de gewone lokalen. +- [x] Een **begeleider aan meerdere stages** koppelen, maar **niet tegelijk aan + een theorievak** (conflict wordt voorkomen bij opslaan + gesignaleerd via + `/api/stage-conflicten/`). +- [x] Een leerling **verdwijnt op een stagedag(deel) uit de klas** in het rooster + (in het weekrooster gemarkeerd "Op stage" bij de betreffende blokken). +- [x] Stage instelbaar **per blok, per dagdeel of per dag**. ## Koppelingen & inzetregels @@ -83,7 +84,4 @@ volgt. ## Later -- [ ] **Module: automatisch rooster genereren.** De gebruiker geeft vereisten/ - input op, het systeem stelt een rooster voor, en de gebruiker past het - daarna zelf aan. -- [ ] **Uiterlijk / UX-verbeteringen** (algemene visuele opfris). +- [ ] **Module: automatisch rooster genereren.** De gebruiker ge \ No newline at end of file diff --git a/backend/modules/stage/admin.py b/backend/modules/stage/admin.py index 7543e3e..873de9a 100644 --- a/backend/modules/stage/admin.py +++ b/backend/modules/stage/admin.py @@ -1,10 +1,19 @@ from django.contrib import admin -from .models import Stage +from .models import Stage, StageLocatie + + +@admin.register(StageLocatie) +class StageLocatieAdmin(admin.ModelAdmin): + list_display = ("naam", "omschrijving", "actief") + search_fields = ("naam",) @admin.register(Stage) class StageAdmin(admin.ModelAdmin): - list_display = ("leerling", "type", "begeleider", "locatie", "organisatie", "actief") - list_filter = ("type", "actief", "dag") + list_display = ( + "leerling", "type", "begeleider", "stagelocatie", "organisatie", + "omvang", "dag", "dagdeel", "actief", + ) + list_filter = ("type", "omvang", "actief", "dag") search_fields = ("leerling__voornaam", "leerling__achternaam", "organisatie") diff --git a/backend/modules/stage/migrations/0002_stagelocatie_omvang_dagdeel_tijdslot.py b/backend/modules/stage/migrations/0002_stagelocatie_omvang_dagdeel_tijdslot.py new file mode 100644 index 0000000..82e69be --- /dev/null +++ b/backend/modules/stage/migrations/0002_stagelocatie_omvang_dagdeel_tijdslot.py @@ -0,0 +1,57 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0005_subgroep_begeleiders_subgroep_groep"), + ("module_stage", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="StageLocatie", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("naam", models.CharField(max_length=120, 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)), + ], + options={ + "verbose_name": "stagelocatie", + "verbose_name_plural": "stagelocaties", + "ordering": ["naam"], + }, + ), + migrations.RemoveField( + model_name="stage", + name="locatie", + ), + migrations.AddField( + model_name="stage", + name="stagelocatie", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="stages", to="module_stage.stagelocatie"), + ), + migrations.AddField( + model_name="stage", + name="omvang", + field=models.CharField(choices=[("hele_dag", "Hele dag"), ("dagdeel", "Dagdeel"), ("blok", "Eén blok")], default="hele_dag", max_length=10, verbose_name="omvang"), + ), + migrations.AddField( + model_name="stage", + name="dagdeel", + field=models.CharField(blank=True, choices=[("ochtend", "Ochtend"), ("middag", "Middag")], help_text="Alleen bij omvang 'dagdeel'.", max_length=8, verbose_name="dagdeel"), + ), + migrations.AddField( + model_name="stage", + name="tijdslot", + field=models.ForeignKey(blank=True, help_text="Alleen bij omvang 'eén blok'; bepaalt zelf de dag.", null=True, on_delete=django.db.models.deletion.CASCADE, related_name="stages", to="core.tijdslot"), + ), + migrations.AlterField( + model_name="stage", + name="begeleider", + field=models.ForeignKey(help_text="Verplicht voor zowel interne als externe stage.", on_delete=django.db.models.deletion.PROTECT, related_name="stagebegeleidingen", to="core.persoon"), + ), + ] diff --git a/backend/modules/stage/models.py b/backend/modules/stage/models.py index 0e88334..3528b57 100644 --- a/backend/modules/stage/models.py +++ b/backend/modules/stage/models.py @@ -2,18 +2,36 @@ Stage-module: interne en externe stages van leerlingen. - Beide stagevormen krijgen een begeleider (verplicht). -- Een interne stage krijgt daarnaast een ruimte (locatie). +- Een interne stage krijgt daarnaast een stageruimte (eigen lijst, los van de + gewone lokalen). - Een externe stage legt de externe organisatie vast. +- Een stage geldt voor een hele dag, een dagdeel of één specifiek roosterblok. """ +from datetime import time + from django.core.exceptions import ValidationError from django.db import models +# De grens tussen ochtend en middag (voor dagdeel-stages). +MIDDAG_GRENS = time(12, 0) + class StageType(models.TextChoices): INTERN = "intern", "Interne stage" EXTERN = "extern", "Externe stage" +class Omvang(models.TextChoices): + HELE_DAG = "hele_dag", "Hele dag" + DAGDEEL = "dagdeel", "Dagdeel" + BLOK = "blok", "Eén blok" + + +class Dagdeel(models.TextChoices): + OCHTEND = "ochtend", "Ochtend" + MIDDAG = "middag", "Middag" + + class Weekdag(models.IntegerChoices): MAANDAG = 0, "Maandag" DINSDAG = 1, "Dinsdag" @@ -22,6 +40,26 @@ 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", @@ -32,15 +70,29 @@ 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 ruimte binnen de school. - locatie = models.ForeignKey( - "core.Locatie", on_delete=models.SET_NULL, null=True, blank=True, + # Interne stage: een eigen stageruimte. + stagelocatie = models.ForeignKey( + StageLocatie, 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) + # Wanneer geldt de stage? + omvang = models.CharField( + "omvang", max_length=10, choices=Omvang.choices, default=Omvang.HELE_DAG, + ) dag = models.IntegerField("dag", choices=Weekdag.choices, null=True, blank=True) + dagdeel = models.CharField( + "dagdeel", max_length=8, choices=Dagdeel.choices, blank=True, + help_text="Alleen bij omvang 'dagdeel'.", + ) + tijdslot = models.ForeignKey( + "core.Tijdslot", on_delete=models.CASCADE, null=True, blank=True, + related_name="stages", + help_text="Alleen bij omvang 'eén blok'; bepaalt zelf de dag.", + ) + omschrijving = models.CharField("omschrijving", max_length=255, blank=True) actief = models.BooleanField("actief", default=True) aangemaakt_op = models.DateTimeField(auto_now_add=True) @@ -51,10 +103,41 @@ class Stage(models.Model): 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.INTERN and not self.stagelocatie_id: + raise ValidationError("Een interne stage vereist een stageruimte.") 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: + raise ValidationError("Kies een roosterblok (tijdslot) voor een blok-stage.") + if self.omvang in (Omvang.HELE_DAG, Omvang.DAGDEEL) and self.dag is None: + raise ValidationError("Kies een dag voor een dag- of dagdeel-stage.") + if self.omvang == Omvang.DAGDEEL and not self.dagdeel: + raise ValidationError("Kies een dagdeel (ochtend of middag).") + + @property + def effectieve_dag(self): + """De weekdag waarop de stage valt (afgeleid van het tijdslot bij blok).""" + if self.omvang == Omvang.BLOK and self.tijdslot_id: + return self.tijdslot.dag + return self.dag + + def dekt_tijdslot(self, tijdslot) -> bool: + """True als deze stage het gegeven tijdslot (dag + tijd) afdekt.""" + if not self.actief: + return False + if self.omvang == Omvang.BLOK: + return self.tijdslot_id == tijdslot.id + if self.dag is None or self.dag != tijdslot.dag: + return False + if self.omvang == Omvang.HELE_DAG: + return True + # Dagdeel: ochtend = start vóór de middaggrens, middag = vanaf de grens. + is_ochtend = tijdslot.start_tijd < MIDDAG_GRENS + if self.dagdeel == Dagdeel.OCHTEND: + return is_ochtend + if self.dagdeel == Dagdeel.MIDDAG: + return not is_ochtend + return False 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 index 6a01a7e..8bbcd0c 100644 --- a/backend/modules/stage/serializers.py +++ b/backend/modules/stage/serializers.py @@ -1,35 +1,100 @@ from rest_framework import serializers -from .models import Stage, StageType +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"] 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) + stagelocatie_naam = serializers.CharField(source="stagelocatie.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) dag_label = serializers.CharField(source="get_dag_display", read_only=True, default=None) + tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True, default=None) + effectieve_dag = serializers.IntegerField(read_only=True) 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", + "begeleider", "begeleider_naam", + "stagelocatie", "stagelocatie_naam", "organisatie", + "omvang", "omvang_label", "dag", "dag_label", "effectieve_dag", + "dagdeel", "dagdeel_label", "tijdslot", "tijdslot_label", + "omschrijving", "actief", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] + def _val(self, data, naam, default=None): + return data.get(naam, getattr(self.instance, naam, default)) + 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)) + type_ = self._val(data, "type") + stagelocatie = self._val(data, "stagelocatie") + organisatie = self._val(data, "organisatie", "") + begeleider = self._val(data, "begeleider") + omvang = self._val(data, "omvang", Omvang.HELE_DAG) + dag = self._val(data, "dag") + dagdeel = self._val(data, "dagdeel", "") + tijdslot = self._val(data, "tijdslot") 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.INTERN and not stagelocatie: + raise serializers.ValidationError("Een interne stage vereist een stageruimte.") if type_ == StageType.EXTERN and not organisatie: raise serializers.ValidationError("Een externe stage vereist een externe organisatie.") + if omvang == Omvang.BLOK and not tijdslot: + raise serializers.ValidationError("Kies een roosterblok (tijdslot) voor een blok-stage.") + if omvang in (Omvang.HELE_DAG, Omvang.DAGDEEL) and dag is None: + raise serializers.ValidationError("Kies een dag voor een dag- of dagdeel-stage.") + if omvang == Omvang.DAGDEEL and not dagdeel: + raise serializers.ValidationError("Kies een dagdeel (ochtend of middag).") + + # Inzetregel: de begeleider mag niet tegelijk een THEORIEVAK begeleiden. + if begeleider is not None: + botsing = _theorievak_botsing(begeleider, omvang, dag, dagdeel, tijdslot) + if botsing: + raise serializers.ValidationError( + f"{begeleider.volledige_naam} begeleidt op dat moment al het " + f"theorievak '{botsing}' en kan niet tegelijk stage begeleiden." + ) return data + + +def _theorievak_botsing(begeleider, omvang, dag, dagdeel, tijdslot): + """Zoek een theorievak-roosterblok dat de begeleider op hetzelfde moment doet.""" + from core.models import Roosterblok + from .models import MIDDAG_GRENS, Omvang as _Omvang, Dagdeel as _Dagdeel + + qs = ( + Roosterblok.objects + .filter(begeleiders=begeleider, activiteit__theorievak=True) + .select_related("tijdslot", "activiteit") + ) + for blok in qs: + t = blok.tijdslot + if omvang == _Omvang.BLOK: + if tijdslot is not None and t.id == tijdslot.id: + return blok.activiteit.naam + continue + if dag is None or t.dag != dag: + continue + if omvang == _Omvang.HELE_DAG: + return blok.activiteit.naam + is_ochtend = t.start_tijd < MIDDAG_GRENS + if dagdeel == _Dagdeel.OCHTEND and is_ochtend: + return blok.activiteit.naam + if dagdeel == _Dagdeel.MIDDAG and not is_ochtend: + return blok.activiteit.naam + return None diff --git a/backend/modules/stage/urls.py b/backend/modules/stage/urls.py index 7376524..224d401 100644 --- a/backend/modules/stage/urls.py +++ b/backend/modules/stage/urls.py @@ -5,5 +5,9 @@ from . import views router = DefaultRouter() router.register("stages", views.StageViewSet, basename="stage") +router.register("stagelocaties", views.StageLocatieViewSet, basename="stagelocatie") -urlpatterns = [path("", include(router.urls))] +urlpatterns = [ + path("stage-conflicten/", views.stage_conflicten, name="stage-conflicten"), + path("", include(router.urls)), +] diff --git a/backend/modules/stage/views.py b/backend/modules/stage/views.py index 4d42026..88b501d 100644 --- a/backend/modules/stage/views.py +++ b/backend/modules/stage/views.py @@ -1,18 +1,58 @@ from rest_framework import viewsets +from rest_framework.decorators import api_view +from rest_framework.response import Response -from .models import Stage -from .serializers import StageSerializer +from .models import Stage, StageLocatie +from .serializers import StageLocatieSerializer, StageSerializer, _theorievak_botsing + + +class StageLocatieViewSet(viewsets.ModelViewSet): + queryset = StageLocatie.objects.all() + serializer_class = StageLocatieSerializer class StageViewSet(viewsets.ModelViewSet): serializer_class = StageSerializer def get_queryset(self): - qs = Stage.objects.select_related("leerling", "begeleider", "locatie").all() + qs = Stage.objects.select_related( + "leerling", "begeleider", "stagelocatie", "tijdslot" + ).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) + actief = self.request.query_params.get("actief") + if actief in ("1", "true", "True"): + qs = qs.filter(actief=True) return qs + + +@api_view(["GET"]) +def stage_conflicten(request): + """Signaleer begeleiders die tegelijk stage én een theorievak doen.""" + resultaat = [] + for stage in Stage.objects.filter(actief=True).select_related("leerling", "begeleider"): + botsing = _theorievak_botsing( + stage.begeleider, stage.omvang, stage.dag, stage.dagdeel, stage.tijdslot + ) + if botsing: + resultaat.append({ + "type": "stage", + "ernst": "fout", + "tijdslot": stage.get_omvang_display(), + "detail": ( + f"{stage.begeleider.volledige_naam} begeleidt {stage.leerling.volledige_naam} " + f"op stage én tegelijk het theorievak '{botsing}'." + ), + "blokken": [botsing], + }) + 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/frontend/src/pages.jsx b/frontend/src/pages.jsx index 1504e78..048cf5b 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -1075,31 +1075,51 @@ export function GroepsindelingPage() { } /* ---------------------------------- Stage --------------------------------- */ -// Module-scherm: interne en externe stages. Beide met begeleider; intern + ruimte. +// Module-scherm: interne en externe stages + stagelocaties. +const OMVANGEN = [ + { value: "hele_dag", label: "Hele dag" }, + { value: "dagdeel", label: "Dagdeel" }, + { value: "blok", label: "Eén blok" }, +]; +const DAGDELEN = [ + { value: "ochtend", label: "Ochtend" }, + { value: "middag", label: "Middag" }, +]; + 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 [stagelocaties, setStagelocaties] = useState([]); + const [tijdsloten, setTijdsloten] = useState([]); + const leeg = { leerling: "", type: "intern", begeleider: "", stagelocatie: "", 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("locaties").then(setLocaties).catch(() => {}); + listResource("tijdsloten").then(setTijdsloten).catch(() => {}); + laadStagelocaties(); }, []); const isIntern = form.type === "intern"; + const isBlok = form.omvang === "blok"; + const isDagdeel = form.omvang === "dagdeel"; async function opslaan(e) { e.preventDefault(); const payload = { leerling: form.leerling, type: form.type, begeleider: form.begeleider, - locatie: isIntern ? (form.locatie || null) : null, + stagelocatie: isIntern ? (form.stagelocatie || null) : null, organisatie: isIntern ? "" : form.organisatie, - dag: form.dag === "" ? null : Number(form.dag), + omvang: form.omvang, + dag: isBlok ? null : (form.dag === "" ? null : Number(form.dag)), + dagdeel: isDagdeel ? form.dagdeel : "", + tijdslot: isBlok ? (form.tijdslot || null) : null, omschrijving: form.omschrijving, }; try { @@ -1117,10 +1137,18 @@ export function StagePage() { try { await deleteResource("stages", id); await reload(); } catch (err) { setError(err.message); } } + const dagLabel = (v) => (WEEKDAGEN.find((d) => d.value === v) || {}).label || "—"; + const tijdslotLabel = (id) => { const t = tijdsloten.find((x) => x.id === id); return t ? `${t.dag_label || ""} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; }; + function wanneerTekst(s) { + if (s.omvang === "blok") return `Blok: ${s.tijdslot_label || tijdslotLabel(s.tijdslot)}`; + if (s.omvang === "dagdeel") return `${dagLabel(s.dag)} — ${s.dagdeel_label || s.dagdeel}`; + return `${dagLabel(s.dag)} — hele dag`; + } + return (

Stage

-

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

+

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

@@ -1139,17 +1167,35 @@ export function StagePage() {
{isIntern ? ( - setForm({ ...form, stagelocatie: e.target.value })}> + + {stagelocaties.map((l) => )} ) : ( setForm({ ...form, organisatie: e.target.value })} /> )} - setForm({ ...form, omvang: e.target.value })}> + {OMVANGEN.map((o) => )} + {isBlok ? ( + + ) : ( + + )} + {isDagdeel && ( + + )} +
+
setForm({ ...form, omschrijving: e.target.value })} />
@@ -1159,17 +1205,17 @@ export function StagePage() { {loading ?

Laden…

: ( - + {items.map((s) => ( - - + + @@ -1178,10 +1224,63 @@ export function StagePage() {
LeerlingTypeBegeleiderWaarDag
LeerlingTypeBegeleiderWaarWanneer
{s.leerling_naam} {s.type_label} {s.begeleider_naam}{s.type === "intern" ? (s.locatie_naam || "—") : (s.organisatie || "—")}{s.dag_label || "—"}{s.type === "intern" ? (s.stagelocatie_naam || "—") : (s.organisatie || "—")}{wanneerTekst(s)} - +
)} +
); } +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" }, @@ -1688,6 +1787,7 @@ export function RoosterScherm({ mode }) { const [kalender, setKalender] = useState([]); const [afwezig, setAfwezig] = useState([]); const [uitzonderingen, setUitzonderingen] = useState([]); + const [stages, setStages] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]); const [maandag, setMaandag] = useState(() => maandagVan(new Date())); const [toonForm, setToonForm] = useState(false); @@ -1720,6 +1820,7 @@ export function RoosterScherm({ mode }) { listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); + listResource("stages", "?actief=1").then(setStages).catch(() => setStages([])); // Afdrukken alleen tonen als de print-module aanstaat. getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {}); }, []); @@ -1884,6 +1985,33 @@ export function RoosterScherm({ mode }) { // 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); const rolAfk = (id) => { const m = medewerkers.find((x) => x.id === id); return m ? (ROL_AFK[m.rol] || "") : ""; }; + // Stage: welke leerlingen zijn op een gegeven tijdslot op stage (en dus niet in de klas)? + const tijdslotById = (id) => tijdsloten.find((t) => t.id === id); + const leerlingNaam = (id) => { const l = leerlingen.find((x) => x.id === id); return l ? l.volledige_naam : "?"; }; + function stageDektTijdslot(stage, t) { + if (!t) return false; + if (stage.omvang === "blok") return stage.tijdslot === t.id; + if (stage.dag === null || stage.dag === undefined || stage.dag !== t.dag) return false; + if (stage.omvang === "hele_dag") return true; + const isOchtend = (t.start_tijd || "") < "12:00"; + return (stage.dagdeel === "ochtend" && isOchtend) || (stage.dagdeel === "middag" && !isOchtend); + } + function leerlingenOpStageBij(t) { + const set = new Set(); + for (const st of stages) { if (stageDektTijdslot(st, t)) set.add(st.leerling); } + return set; + } + function stageNoot(b) { + const t = tijdslotById(b.tijdslot); + const opStage = leerlingenOpStageBij(t); + if (opStage.size === 0) return null; + if (b.doel_type === "leerling") { + return opStage.has(b.leerling) ?
📍 Op stage — niet in de klas
: null; + } + const namen = leerlingenVanBlok(b, scopeCtx).filter((id) => opStage.has(id)).map(leerlingNaam); + if (namen.length === 0) return null; + return
📍 Op stage: {namen.join(", ")}
; + } const editorCtx = { activiteiten, locaties, 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)); @@ -1911,6 +2039,7 @@ export function RoosterScherm({ mode }) { {idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""} ))} )} + {stageNoot(b)} {kal &&
Vervalt - {kal.type_label}
} {!kal && uit && uit.vervalt &&
Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && }
} {!kal && uit && !uit.vervalt && uit.vervanger_naam &&
Vervanger: {uit.vervanger_naam}
} @@ -1942,6 +2071,7 @@ export function RoosterScherm({ mode }) { {b.begeleiders_namen.length > 0 && {b.begeleiders_namen.map((x) => x.naam).join(", ")}} {vervallen && Vervalt} + {stageNoot(b)} ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4cccfd4..23d810c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -348,3 +348,10 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); margin-top: 12px; } + +/* Stage in het rooster + stagelocatie-beheer */ +.rb-stage { color: #b45309; font-weight: 600; } +.rooster-blok.vervallen .rb-stage, +.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; }