Lokalen-systeem met dubbele-boekingcheck (warn + per-school blokkeren); stage gebruikt lokaal; dagdeel-UI verduidelijkt

This commit is contained in:
bes-r 2026-06-16 08:17:52 +02:00
parent edddf40c7f
commit a9dff26d9a
9 changed files with 444 additions and 51 deletions

View file

@ -45,12 +45,13 @@ volgt.
## Stage-module — uitbreidingen ## Stage-module — uitbreidingen
- [ ] **Aparte (stage)locaties** kunnen toevoegen, los van de gewone lokalen. - [x] **Aparte (stage)locaties** kunnen toevoegen, los van de gewone lokalen.
- [ ] Een **begeleider aan meerdere stages** koppelen, maar **niet tegelijk aan - [x] Een **begeleider aan meerdere stages** koppelen, maar **niet tegelijk aan
een theorievak** (conflict signaleren/voorkomen). een theorievak** (conflict wordt voorkomen bij opslaan + gesignaleerd via
- [ ] Een leerling **verdwijnt op een stagedag(deel) uit de klas** in het rooster `/api/stage-conflicten/`).
(de betreffende blokken vervallen voor die leerling). - [x] Een leerling **verdwijnt op een stagedag(deel) uit de klas** in het rooster
- [ ] Stage instelbaar **per blok, per dagdeel of per dag**. (in het weekrooster gemarkeerd "Op stage" bij de betreffende blokken).
- [x] Stage instelbaar **per blok, per dagdeel of per dag**.
## Koppelingen & inzetregels ## Koppelingen & inzetregels
@ -83,7 +84,4 @@ volgt.
## Later ## Later
- [ ] **Module: automatisch rooster genereren.** De gebruiker geeft vereisten/ - [ ] **Module: automatisch rooster genereren.** De gebruiker ge
input op, het systeem stelt een rooster voor, en de gebruiker past het
daarna zelf aan.
- [ ] **Uiterlijk / UX-verbeteringen** (algemene visuele opfris).

View file

@ -1,10 +1,19 @@
from django.contrib import admin 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) @admin.register(Stage)
class StageAdmin(admin.ModelAdmin): class StageAdmin(admin.ModelAdmin):
list_display = ("leerling", "type", "begeleider", "locatie", "organisatie", "actief") list_display = (
list_filter = ("type", "actief", "dag") "leerling", "type", "begeleider", "stagelocatie", "organisatie",
"omvang", "dag", "dagdeel", "actief",
)
list_filter = ("type", "omvang", "actief", "dag")
search_fields = ("leerling__voornaam", "leerling__achternaam", "organisatie") search_fields = ("leerling__voornaam", "leerling__achternaam", "organisatie")

View file

@ -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"),
),
]

View file

@ -2,18 +2,36 @@
Stage-module: interne en externe stages van leerlingen. Stage-module: interne en externe stages van leerlingen.
- Beide stagevormen krijgen een begeleider (verplicht). - 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 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.core.exceptions import ValidationError
from django.db import models from django.db import models
# De grens tussen ochtend en middag (voor dagdeel-stages).
MIDDAG_GRENS = time(12, 0)
class StageType(models.TextChoices): class StageType(models.TextChoices):
INTERN = "intern", "Interne stage" INTERN = "intern", "Interne stage"
EXTERN = "extern", "Externe 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): class Weekdag(models.IntegerChoices):
MAANDAG = 0, "Maandag" MAANDAG = 0, "Maandag"
DINSDAG = 1, "Dinsdag" DINSDAG = 1, "Dinsdag"
@ -22,6 +40,26 @@ class Weekdag(models.IntegerChoices):
VRIJDAG = 4, "Vrijdag" 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): class Stage(models.Model):
leerling = models.ForeignKey( leerling = models.ForeignKey(
"core.Persoon", on_delete=models.CASCADE, related_name="stages", "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", "core.Persoon", on_delete=models.PROTECT, related_name="stagebegeleidingen",
help_text="Verplicht voor zowel interne als externe stage.", help_text="Verplicht voor zowel interne als externe stage.",
) )
# Interne stage: een ruimte binnen de school. # Interne stage: een eigen stageruimte.
locatie = models.ForeignKey( stagelocatie = models.ForeignKey(
"core.Locatie", on_delete=models.SET_NULL, null=True, blank=True, StageLocatie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="stages", related_name="stages",
) )
# Externe stage: de externe organisatie. # Externe stage: de externe organisatie.
organisatie = models.CharField("externe organisatie", max_length=160, blank=True) 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) 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) omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
actief = models.BooleanField("actief", default=True) actief = models.BooleanField("actief", default=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True) aangemaakt_op = models.DateTimeField(auto_now_add=True)
@ -51,10 +103,41 @@ class Stage(models.Model):
ordering = ["leerling__achternaam", "leerling__voornaam"] ordering = ["leerling__achternaam", "leerling__voornaam"]
def clean(self): def clean(self):
if self.type == StageType.INTERN and not self.locatie_id: if self.type == StageType.INTERN and not self.stagelocatie_id:
raise ValidationError("Een interne stage vereist een ruimte (locatie).") raise ValidationError("Een interne stage vereist een stageruimte.")
if self.type == StageType.EXTERN and not self.organisatie: if self.type == StageType.EXTERN and not self.organisatie:
raise ValidationError("Een externe stage vereist een externe 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: def __str__(self) -> str:
return f"{self.leerling} {self.get_type_display()}" return f"{self.leerling} {self.get_type_display()}"

View file

@ -1,35 +1,100 @@
from rest_framework import serializers 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): class StageSerializer(serializers.ModelSerializer):
leerling_naam = serializers.CharField(source="leerling.volledige_naam", read_only=True) leerling_naam = serializers.CharField(source="leerling.volledige_naam", read_only=True)
begeleider_naam = serializers.CharField(source="begeleider.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) 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) 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: class Meta:
model = Stage model = Stage
fields = [ fields = [
"id", "leerling", "leerling_naam", "type", "type_label", "id", "leerling", "leerling_naam", "type", "type_label",
"begeleider", "begeleider_naam", "locatie", "locatie_naam", "begeleider", "begeleider_naam",
"organisatie", "dag", "dag_label", "omschrijving", "actief", "stagelocatie", "stagelocatie_naam", "organisatie",
"aangemaakt_op", "omvang", "omvang_label", "dag", "dag_label", "effectieve_dag",
"dagdeel", "dagdeel_label", "tijdslot", "tijdslot_label",
"omschrijving", "actief", "aangemaakt_op",
] ]
read_only_fields = ["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): def validate(self, data):
type_ = data.get("type", getattr(self.instance, "type", None)) type_ = self._val(data, "type")
locatie = data.get("locatie", getattr(self.instance, "locatie", None)) stagelocatie = self._val(data, "stagelocatie")
organisatie = data.get("organisatie", getattr(self.instance, "organisatie", "")) organisatie = self._val(data, "organisatie", "")
begeleider = data.get("begeleider", getattr(self.instance, "begeleider", None)) 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": if begeleider is not None and begeleider.rol == "leerling":
raise serializers.ValidationError("Een leerling kan geen begeleider zijn.") raise serializers.ValidationError("Een leerling kan geen begeleider zijn.")
if type_ == StageType.INTERN and not locatie: if type_ == StageType.INTERN and not stagelocatie:
raise serializers.ValidationError("Een interne stage vereist een ruimte (locatie).") raise serializers.ValidationError("Een interne stage vereist een stageruimte.")
if type_ == StageType.EXTERN and not organisatie: if type_ == StageType.EXTERN and not organisatie:
raise serializers.ValidationError("Een externe stage vereist een externe 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 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

View file

@ -5,5 +5,9 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register("stages", views.StageViewSet, basename="stage") 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)),
]

View file

@ -1,18 +1,58 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import Stage from .models import Stage, StageLocatie
from .serializers import StageSerializer from .serializers import StageLocatieSerializer, StageSerializer, _theorievak_botsing
class StageLocatieViewSet(viewsets.ModelViewSet):
queryset = StageLocatie.objects.all()
serializer_class = StageLocatieSerializer
class StageViewSet(viewsets.ModelViewSet): class StageViewSet(viewsets.ModelViewSet):
serializer_class = StageSerializer serializer_class = StageSerializer
def get_queryset(self): 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") type_ = self.request.query_params.get("type")
if type_: if type_:
qs = qs.filter(type=type_) qs = qs.filter(type=type_)
leerling = self.request.query_params.get("leerling") leerling = self.request.query_params.get("leerling")
if leerling: if leerling:
qs = qs.filter(leerling_id=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 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,
})

View file

@ -1075,31 +1075,51 @@ export function GroepsindelingPage() {
} }
/* ---------------------------------- Stage --------------------------------- */ /* ---------------------------------- 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() { export function StagePage() {
const { items, error, loading, reload, setError } = useList("stages"); const { items, error, loading, reload, setError } = useList("stages");
const [leerlingen, setLeerlingen] = useState([]); const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]); const [medewerkers, setMedewerkers] = useState([]);
const [locaties, setLocaties] = useState([]); const [stagelocaties, setStagelocaties] = useState([]);
const leeg = { leerling: "", type: "intern", begeleider: "", locatie: "", organisatie: "", dag: "", omschrijving: "" }; 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 [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
async function laadStagelocaties() {
try { setStagelocaties(await listResource("stagelocaties")); } catch { setStagelocaties([]); }
}
useEffect(() => { useEffect(() => {
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).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 isIntern = form.type === "intern";
const isBlok = form.omvang === "blok";
const isDagdeel = form.omvang === "dagdeel";
async function opslaan(e) { async function opslaan(e) {
e.preventDefault(); e.preventDefault();
const payload = { const payload = {
leerling: form.leerling, type: form.type, begeleider: form.begeleider, 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, 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, omschrijving: form.omschrijving,
}; };
try { try {
@ -1117,10 +1137,18 @@ export function StagePage() {
try { await deleteResource("stages", id); await reload(); } catch (err) { setError(err.message); } 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 ( return (
<section> <section>
<h1>Stage</h1> <h1>Stage</h1>
<p className="muted">Interne en externe stages. Beide krijgen een begeleider; een interne stage ook een ruimte.</p> <p className="muted">Interne en externe stages. Beide krijgen een begeleider; een interne stage een stageruimte. Stel in per hele dag, dagdeel of los blok.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}> <form className="form-card" onSubmit={opslaan}>
<div className="form-row"> <div className="form-row">
@ -1139,17 +1167,35 @@ export function StagePage() {
</div> </div>
<div className="form-row"> <div className="form-row">
{isIntern ? ( {isIntern ? (
<select value={form.locatie} required onChange={(e) => setForm({ ...form, locatie: e.target.value })}> <select value={form.stagelocatie} required onChange={(e) => setForm({ ...form, stagelocatie: e.target.value })}>
<option value=""> ruimte </option> <option value=""> stageruimte </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)} {stagelocaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select> </select>
) : ( ) : (
<input placeholder="Externe organisatie" value={form.organisatie} required onChange={(e) => setForm({ ...form, organisatie: e.target.value })} /> <input placeholder="Externe organisatie" value={form.organisatie} required onChange={(e) => setForm({ ...form, organisatie: e.target.value })} />
)} )}
<select value={form.dag} onChange={(e) => setForm({ ...form, dag: e.target.value })}> <select value={form.omvang} onChange={(e) => setForm({ ...form, omvang: e.target.value })}>
<option value=""> dag (optioneel) </option> {OMVANGEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select> </select>
{isBlok ? (
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> roosterblok </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{tijdslotLabel(t.id)}</option>)}
</select>
) : (
<select value={form.dag} required onChange={(e) => setForm({ ...form, dag: e.target.value })}>
<option value=""> dag </option>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
)}
{isDagdeel && (
<select value={form.dagdeel} required onChange={(e) => setForm({ ...form, dagdeel: e.target.value })}>
<option value=""> dagdeel </option>
{DAGDELEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
)}
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} /> <input placeholder="Opmerking (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
</div> </div>
<div className="form-row"> <div className="form-row">
@ -1159,17 +1205,17 @@ export function StagePage() {
</form> </form>
{loading ? <p>Laden</p> : ( {loading ? <p>Laden</p> : (
<table className="grid"> <table className="grid">
<thead><tr><th>Leerling</th><th>Type</th><th>Begeleider</th><th>Waar</th><th>Dag</th><th></th></tr></thead> <thead><tr><th>Leerling</th><th>Type</th><th>Begeleider</th><th>Waar</th><th>Wanneer</th><th></th></tr></thead>
<tbody> <tbody>
{items.map((s) => ( {items.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td>{s.leerling_naam}</td> <td>{s.leerling_naam}</td>
<td>{s.type_label}</td> <td>{s.type_label}</td>
<td>{s.begeleider_naam}</td> <td>{s.begeleider_naam}</td>
<td className="muted">{s.type === "intern" ? (s.locatie_naam || "—") : (s.organisatie || "—")}</td> <td className="muted">{s.type === "intern" ? (s.stagelocatie_naam || "—") : (s.organisatie || "—")}</td>
<td className="muted">{s.dag_label || "—"}</td> <td className="muted">{wanneerTekst(s)}</td>
<td className="row-actions"> <td className="row-actions">
<button className="link" onClick={() => { setEditId(s.id); setForm({ leerling: s.leerling, type: s.type, begeleider: s.begeleider, locatie: s.locatie || "", organisatie: s.organisatie || "", dag: s.dag ?? "", omschrijving: s.omschrijving || "" }); }}>bewerken</button> <button className="link" onClick={() => { setEditId(s.id); setForm({ leerling: s.leerling, type: s.type, begeleider: s.begeleider, stagelocatie: s.stagelocatie || "", organisatie: s.organisatie || "", omvang: s.omvang || "hele_dag", dag: s.dag ?? "", dagdeel: s.dagdeel || "", tijdslot: s.tijdslot || "", omschrijving: s.omschrijving || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button> <button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
</td> </td>
</tr> </tr>
@ -1178,10 +1224,63 @@ export function StagePage() {
</tbody> </tbody>
</table> </table>
)} )}
<StagelocatieBeheer locaties={stagelocaties} onChange={laadStagelocaties} />
</section> </section>
); );
} }
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 (
<div className="subsectie">
<h2>Stagelocaties</h2>
<p className="muted">Aparte stageruimtes, los van de gewone lokalen.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</div>
</form>
<table className="grid">
<thead><tr><th>Naam</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{locaties.map((l) => (
<tr key={l.id}>
<td>{l.naam}</td>
<td className="muted">{l.omschrijving || "—"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(l.id); setForm({ naam: l.naam, omschrijving: l.omschrijving || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(l.id)}>verwijderen</button>
</td>
</tr>
))}
{locaties.length === 0 && <tr><td colSpan="3" className="muted">Nog geen stagelocaties.</td></tr>}
</tbody>
</table>
</div>
);
}
/* -------------------------------- Gebruikers ------------------------------ */ /* -------------------------------- Gebruikers ------------------------------ */
const GEBRUIKERSROLLEN = [ const GEBRUIKERSROLLEN = [
{ value: "beheerder", label: "Beheerder" }, { value: "beheerder", label: "Beheerder" },
@ -1688,6 +1787,7 @@ export function RoosterScherm({ mode }) {
const [kalender, setKalender] = useState([]); const [kalender, setKalender] = useState([]);
const [afwezig, setAfwezig] = useState([]); const [afwezig, setAfwezig] = useState([]);
const [uitzonderingen, setUitzonderingen] = useState([]); const [uitzonderingen, setUitzonderingen] = useState([]);
const [stages, setStages] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]); const [leerpleinen, setLeerpleinen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date())); const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false); const [toonForm, setToonForm] = useState(false);
@ -1720,6 +1820,7 @@ export function RoosterScherm({ mode }) {
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
listResource("stages", "?actief=1").then(setStages).catch(() => setStages([]));
// Afdrukken alleen tonen als de print-module aanstaat. // Afdrukken alleen tonen als de print-module aanstaat.
getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {}); 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. // 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 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] || "") : ""; }; 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) ? <div className="small rb-stage">📍 Op stage niet in de klas</div> : null;
}
const namen = leerlingenVanBlok(b, scopeCtx).filter((id) => opStage.has(id)).map(leerlingNaam);
if (namen.length === 0) return null;
return <div className="small rb-stage">📍 Op stage: {namen.join(", ")}</div>;
}
const editorCtx = { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }; const editorCtx = { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten };
const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id; const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id;
const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b)); const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b));
@ -1911,6 +2039,7 @@ export function RoosterScherm({ mode }) {
<span key={x.id} className={persoonAfwezig(x.id, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""}</span> <span key={x.id} className={persoonAfwezig(x.id, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}{rolAfk(x.id) ? ` (${rolAfk(x.id)})` : ""}</span>
))}</div> ))}</div>
)} )}
{stageNoot(b)}
{kal && <div className="small rb-status">Vervalt - {kal.type_label}</div>} {kal && <div className="small rb-status">Vervalt - {kal.type_label}</div>}
{!kal && uit && uit.vervalt && <div className="small rb-status">Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <button type="button" className="icon-btn" title="Herstellen" onClick={(e) => { e.stopPropagation(); herstel(uit.id); }}></button>}</div>} {!kal && uit && uit.vervalt && <div className="small rb-status">Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <button type="button" className="icon-btn" title="Herstellen" onClick={(e) => { e.stopPropagation(); herstel(uit.id); }}></button>}</div>}
{!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">Vervanger: {uit.vervanger_naam}</div>} {!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">Vervanger: {uit.vervanger_naam}</div>}
@ -1942,6 +2071,7 @@ export function RoosterScherm({ mode }) {
{b.begeleiders_namen.length > 0 && <span>{b.begeleiders_namen.map((x) => x.naam).join(", ")}</span>} {b.begeleiders_namen.length > 0 && <span>{b.begeleiders_namen.map((x) => x.naam).join(", ")}</span>}
{vervallen && <span className="rb-status">Vervalt</span>} {vervallen && <span className="rb-status">Vervalt</span>}
</div> </div>
{stageNoot(b)}
</div> </div>
); );
} }

View file

@ -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; display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); margin-top: 12px; 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; }