From 4e461a947147e5b3f585a82863f2657466758d5a Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:58:06 +0200 Subject: [PATCH] Koppelingen: personeel aan subgroep/leerplein, subgroep->groep, leerplein-medewerkers; inzetregel begeleider-vak (klassenondersteuner overal, leerkracht ook theorievakken) --- ROADMAP.md | 19 +-- backend/core/admin.py | 7 +- ...005_subgroep_begeleiders_subgroep_groep.py | 24 ++++ backend/core/serializers.py | 62 ++++++++-- backend/modules/leerplein/admin.py | 2 +- .../migrations/0002_leerplein_medewerkers.py | 19 +++ backend/modules/leerplein/models.py | 4 + backend/modules/leerplein/serializers.py | 11 +- frontend/src/pages.jsx | 109 ++++++++++++++---- 9 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py create mode 100644 backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py diff --git a/ROADMAP.md b/ROADMAP.md index 37ca9f7..d0a7f2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,15 +50,16 @@ volgt. ## Koppelingen & inzetregels -- [ ] **Personeel koppelen aan leerplein en subgroep** (nu alleen stamgroep - mogelijk in het personeelsformulier). -- [ ] **Theorievakken automatisch beschikbaar voor leerkrachten** (een leerkracht - mag altijd op een theorievak ingezet worden). -- [ ] **Inzetregel begeleider ↔ vak:** een leerkracht of praktijkondersteuner kan - alleen aan een vak/blok gekoppeld worden als hij/zij aan dat vak gekoppeld - is; een **klassenondersteuner kan overal** ingezet worden. -- [ ] **Hiërarchie vastleggen:** subgroepen verbonden aan groepen, en groepen aan - leerpleinen (subgroep → groep → leerplein). +- [x] **Personeel koppelen aan leerplein en subgroep** (personeelsformulier: + stamgroep, subgroep-begeleiding én leerpleinen). +- [x] **Theorievakken automatisch beschikbaar voor leerkrachten** (een leerkracht + mag altijd op een theorievak ingezet worden; daarnaast worden theorievakken + bij het aanmaken automatisch gekoppeld). +- [x] **Inzetregel begeleider ↔ vak:** leerkracht/praktijkondersteuner alleen op + gekoppelde vakken (leerkracht bovendien altijd op theorievakken); + **klassenondersteuner overal**. +- [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en + groep → leerplein (Leerplein.groepen). ## Module: Urenoverzicht diff --git a/backend/core/admin.py b/backend/core/admin.py index 7fdaca6..045b67e 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -31,10 +31,11 @@ class PersoonAdmin(admin.ModelAdmin): @admin.register(Subgroep) class SubgroepAdmin(admin.ModelAdmin): - list_display = ("naam", "omschrijving", "actief") - list_filter = ("actief",) + list_display = ("naam", "groep", "omschrijving", "actief") + list_filter = ("actief", "groep") search_fields = ("naam",) - filter_horizontal = ("leden",) + autocomplete_fields = ("groep",) + filter_horizontal = ("leden", "begeleiders") @admin.register(Schooljaar) diff --git a/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py b/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py new file mode 100644 index 0000000..cbcf619 --- /dev/null +++ b/backend/core/migrations/0005_subgroep_begeleiders_subgroep_groep.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_activiteit_theorievak_persoon_geboortedatum_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='subgroep', + name='begeleiders', + field=models.ManyToManyField(blank=True, help_text='Medewerkers die aan deze subgroep gekoppeld zijn.', related_name='begeleide_subgroepen', to='core.persoon', verbose_name='begeleiders'), + ), + migrations.AddField( + model_name='subgroep', + name='groep', + field=models.ForeignKey(blank=True, help_text='De groep waar deze subgroep onder valt.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subgroepen', to='core.groep', verbose_name='groep'), + ), + ] diff --git a/backend/core/serializers.py b/backend/core/serializers.py index e2d60c4..342fff1 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -38,6 +38,10 @@ class PersoonSerializer(serializers.ModelSerializer): many=True, queryset=Activiteit.objects.all(), required=False ) vakken_namen = serializers.SerializerMethodField() + begeleide_subgroepen = serializers.PrimaryKeyRelatedField( + many=True, queryset=Subgroep.objects.all(), required=False + ) + begeleide_subgroepen_namen = serializers.SerializerMethodField() class Meta: model = Persoon @@ -46,6 +50,7 @@ class PersoonSerializer(serializers.ModelSerializer): "rol", "rol_label", "groep", "groep_naam", "stamgroep", "stamgroep_naam", "functies", "functies_namen", "vakken", "vakken_namen", + "begeleide_subgroepen", "begeleide_subgroepen_namen", "geboortedatum", "actief", "opmerkingen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -56,13 +61,17 @@ class PersoonSerializer(serializers.ModelSerializer): def get_vakken_namen(self, obj): return [{"id": a.id, "naam": a.naam} for a in obj.vakken.all()] + def get_begeleide_subgroepen_namen(self, obj): + return [{"id": s.id, "naam": s.naam} for s in obj.begeleide_subgroepen.all()] + def validate(self, data): - # Functies/vakken horen alleen bij medewerkers; een leerling krijgt er geen. + # Functies/vakken/subgroep-begeleiding horen alleen bij medewerkers. # (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"] = [] + data["begeleide_subgroepen"] = [] return data @staticmethod @@ -73,17 +82,24 @@ class PersoonSerializer(serializers.ModelSerializer): return gekozen + [a for a in theorie if a.id not in ids] def create(self, validated_data): - # Een leerkracht krijgt de theorievakken standaard direct gekoppeld. + # begeleide_subgroepen is een reverse-M2M: apart afhandelen na save. + subg = validated_data.pop("begeleide_subgroepen", None) if validated_data.get("rol") == "leerkracht": validated_data["vakken"] = self._met_theorievakken(validated_data.get("vakken", [])) - return super().create(validated_data) + instance = super().create(validated_data) + if subg is not None: + instance.begeleide_subgroepen.set(subg) + return instance def update(self, instance, validated_data): - # Ook bij bewerken houdt een leerkracht altijd de theorievakken. + subg = validated_data.pop("begeleide_subgroepen", "__keep__") rol = validated_data.get("rol", instance.rol) if rol == "leerkracht" and "vakken" in validated_data: validated_data["vakken"] = self._met_theorievakken(validated_data["vakken"]) - return super().update(instance, validated_data) + instance = super().update(instance, validated_data) + if subg != "__keep__": + instance.begeleide_subgroepen.set(subg) + return instance class SubgroepSerializer(serializers.ModelSerializer): @@ -92,12 +108,19 @@ class SubgroepSerializer(serializers.ModelSerializer): ) leden_namen = serializers.SerializerMethodField() aantal_leden = serializers.SerializerMethodField() + groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None) + begeleiders = serializers.PrimaryKeyRelatedField( + many=True, queryset=Persoon.objects.exclude(rol="leerling"), required=False + ) + begeleiders_namen = serializers.SerializerMethodField() class Meta: model = Subgroep fields = [ "id", "naam", "omschrijving", "actief", - "leden", "leden_namen", "aantal_leden", "aangemaakt_op", + "groep", "groep_naam", + "leden", "leden_namen", "aantal_leden", + "begeleiders", "begeleiders_namen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -107,6 +130,9 @@ class SubgroepSerializer(serializers.ModelSerializer): def get_aantal_leden(self, obj): return obj.leden.count() + def get_begeleiders_namen(self, obj): + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.begeleiders.all()] + # --- Fase 2 --------------------------------------------------------------- @@ -207,13 +233,31 @@ class RoosterblokSerializer(serializers.ModelSerializer): raise serializers.ValidationError( "Kies precies één doelgroep: groep, subgroep of leerling." ) - # Begeleiders zijn medewerkers; een leerling kan geen begeleider zijn. - # (Het Roosterblok-model kent geen rolbeperking, dus hier afdwingen.) - for p in data.get("begeleiders", []): + # Inzetregels voor begeleiders t.o.v. het vak (de activiteit): + # - een leerling kan geen begeleider zijn; + # - een klassenondersteuner kan overal worden ingezet; + # - een leerkracht mag op theorievakken én op de aan hem/haar gekoppelde + # vakken; een praktijkondersteuner alleen op de gekoppelde vakken. + activiteit = data.get("activiteit", getattr(self.instance, "activiteit", None)) + begeleiders = data.get("begeleiders", None) + if begeleiders is None and self.instance is not None: + begeleiders = list(self.instance.begeleiders.all()) + for p in (begeleiders or []): if p.rol == "leerling": raise serializers.ValidationError( f"'{p.volledige_naam}' is een leerling en kan geen begeleider zijn." ) + if p.rol == "klassenondersteuner": + continue + if activiteit is None: + continue + mag = p.vakken.filter(pk=activiteit.pk).exists() + if p.rol == "leerkracht" and getattr(activiteit, "theorievak", False): + mag = True + if not mag: + raise serializers.ValidationError( + f"'{p.volledige_naam}' is niet gekoppeld aan het vak '{activiteit.naam}'." + ) return data diff --git a/backend/modules/leerplein/admin.py b/backend/modules/leerplein/admin.py index 79e0efa..10b6144 100644 --- a/backend/modules/leerplein/admin.py +++ b/backend/modules/leerplein/admin.py @@ -8,7 +8,7 @@ class LeerpleinAdmin(admin.ModelAdmin): list_display = ("naam", "omschrijving", "actief") list_filter = ("actief",) search_fields = ("naam",) - filter_horizontal = ("groepen", "subgroepen", "leerlingen") + filter_horizontal = ("groepen", "subgroepen", "leerlingen", "medewerkers") @admin.register(SubgroepGroep) diff --git a/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py b/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py new file mode 100644 index 0000000..2610bf7 --- /dev/null +++ b/backend/modules/leerplein/migrations/0002_leerplein_medewerkers.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_subgroep_begeleiders_subgroep_groep'), + ('module_leerplein', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='leerplein', + name='medewerkers', + field=models.ManyToManyField(blank=True, related_name='leerpleinen_medewerker', to='core.persoon', verbose_name='medewerkers'), + ), + ] diff --git a/backend/modules/leerplein/models.py b/backend/modules/leerplein/models.py index 973460e..87f9685 100644 --- a/backend/modules/leerplein/models.py +++ b/backend/modules/leerplein/models.py @@ -26,6 +26,10 @@ class Leerplein(models.Model): "core.Persoon", verbose_name="losse leerlingen", related_name="leerpleinen", blank=True, limit_choices_to={"rol": "leerling"}, ) + medewerkers = models.ManyToManyField( + "core.Persoon", verbose_name="medewerkers", related_name="leerpleinen_medewerker", + blank=True, + ) aangemaakt_op = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/backend/modules/leerplein/serializers.py b/backend/modules/leerplein/serializers.py index d8e143d..bb9becb 100644 --- a/backend/modules/leerplein/serializers.py +++ b/backend/modules/leerplein/serializers.py @@ -15,16 +15,20 @@ class LeerpleinSerializer(serializers.ModelSerializer): leerlingen = serializers.PrimaryKeyRelatedField( many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False ) + medewerkers = serializers.PrimaryKeyRelatedField( + many=True, queryset=Persoon.objects.exclude(rol="leerling"), required=False + ) groepen_namen = serializers.SerializerMethodField() subgroepen_namen = serializers.SerializerMethodField() leerlingen_namen = serializers.SerializerMethodField() + medewerkers_namen = serializers.SerializerMethodField() class Meta: model = Leerplein fields = [ "id", "naam", "omschrijving", "actief", - "groepen", "subgroepen", "leerlingen", - "groepen_namen", "subgroepen_namen", "leerlingen_namen", + "groepen", "subgroepen", "leerlingen", "medewerkers", + "groepen_namen", "subgroepen_namen", "leerlingen_namen", "medewerkers_namen", "aangemaakt_op", ] read_only_fields = ["aangemaakt_op"] @@ -38,6 +42,9 @@ class LeerpleinSerializer(serializers.ModelSerializer): def get_leerlingen_namen(self, obj): return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leerlingen.all()] + def get_medewerkers_namen(self, obj): + return [{"id": p.id, "naam": p.volledige_naam} for p in obj.medewerkers.all()] + class SubgroepGroepSerializer(serializers.ModelSerializer): groep_naam = serializers.CharField(source="groep.naam", read_only=True) diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 429350a..d355f99 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -262,19 +262,36 @@ export function PersonenPage() { const [groepen, setGroepen] = useState([]); const [functies, setFuncties] = useState([]); const [vakken, setVakken] = useState([]); + const [subgroepen, setSubgroepen] = useState([]); + const [leerpleinen, setLeerpleinen] = useState([]); // Twee losse flows: medewerker en leerling. const [modus, setModus] = useState("leerling"); - const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [] }; + const leegMedewerker = { voornaam: "", achternaam: "", rol: "leerkracht", stamgroep: "", functies: [], vakken: [], begeleide_subgroepen: [], leerpleinen: [] }; const leegLeerling = { voornaam: "", achternaam: "", groep: "", geboortedatum: "" }; const [form, setForm] = useState(leegLeerling); const [editId, setEditId] = useState(null); const [filter, setFilter] = useState(""); - useEffect(() => { + function laadHulplijsten() { listResource("groepen").then(setGroepen).catch(() => {}); listResource("functies").then(setFuncties).catch(() => {}); listResource("activiteiten").then(setVakken).catch(() => {}); - }, []); + listResource("subgroepen").then(setSubgroepen).catch(() => {}); + listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([])); + } + useEffect(() => { laadHulplijsten(); }, []); + + // Leerplein-koppeling loopt via de leerplein-API (de module bezit die relatie). + async function syncLeerpleinen(persoonId, gekozenIds) { + const huidige = await listResource("leerpleinen").catch(() => []); + for (const lp of huidige) { + const inLijst = (lp.medewerkers || []).includes(persoonId); + const moet = gekozenIds.includes(lp.id); + if (moet && !inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: [...lp.medewerkers, persoonId] }); + else if (!moet && inLijst) await updateResource("leerpleinen", lp.id, { medewerkers: lp.medewerkers.filter((x) => x !== persoonId) }); + } + listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); + } function kiesModus(m) { setModus(m); @@ -291,14 +308,19 @@ export function PersonenPage() { ? { voornaam: form.voornaam, achternaam: form.achternaam, rol: form.rol, stamgroep: form.stamgroep || null, functies: form.functies, vakken: form.vakken, + begeleide_subgroepen: form.begeleide_subgroepen, } : { 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); + const opgeslagen = editId + ? await updateResource("personen", editId, payload) + : await createResource("personen", payload); + if (modus === "medewerker" && opgeslagen && opgeslagen.id) { + await syncLeerpleinen(opgeslagen.id, form.leerpleinen || []); + } setForm(modus === "medewerker" ? leegMedewerker : leegLeerling); setEditId(null); await reload(); @@ -322,6 +344,8 @@ export function PersonenPage() { stamgroep: p.stamgroep || "", functies: p.functies_namen.map((x) => x.id), vakken: p.vakken_namen.map((x) => x.id), + begeleide_subgroepen: (p.begeleide_subgroepen_namen || []).map((x) => x.id), + leerpleinen: leerpleinen.filter((lp) => (lp.medewerkers || []).includes(p.id)).map((lp) => lp.id), }); } } @@ -382,6 +406,21 @@ export function PersonenPage() { {vakken.length === 0 && Nog geen vakken/activiteiten.} +
+
Subgroepen (begeleiding):
+
+ {subgroepen.map((s) => )} + {subgroepen.length === 0 && Nog geen subgroepen.} +
+
+ {leerpleinen.length > 0 && ( +
+
Leerpleinen:
+
+ {leerpleinen.map((lp) => )} +
+
+ )} )} @@ -440,27 +479,28 @@ export function PersonenPage() { export function SubgroepenPage() { const { items, error, loading, reload, setError } = useList("subgroepen"); const [leerlingen, setLeerlingen] = useState([]); - const leeg = { naam: "", omschrijving: "", leden: [] }; + const [groepen, setGroepen] = useState([]); + const [medewerkers, setMedewerkers] = useState([]); + const leeg = { naam: "", omschrijving: "", groep: "", leden: [], begeleiders: [] }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); useEffect(() => { listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); + listResource("groepen").then(setGroepen).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); }, []); - function toggleLid(id) { - setForm((f) => - f.leden.includes(id) - ? { ...f, leden: f.leden.filter((x) => x !== id) } - : { ...f, leden: [...f.leden, id] } - ); + 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 = { ...form, groep: form.groep || null }; try { - if (editId) await updateResource("subgroepen", editId, form); - else await createResource("subgroepen", form); + if (editId) await updateResource("subgroepen", editId, payload); + else await createResource("subgroepen", payload); setForm(leeg); setEditId(null); await reload(); @@ -481,22 +521,35 @@ export function SubgroepenPage() { return (

Subgroepen

-

Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).

+

Groepjes leerlingen binnen een groep (bv. niveaugroep rekenen), met eigen begeleiders.

setForm({ ...form, naam: e.target.value })} /> setForm({ ...form, omschrijving: e.target.value })} /> +
Leden (leerlingen):
{leerlingen.map((l) => ( - + ))} {leerlingen.length === 0 && Nog geen leerlingen ingevoerd.}
+
+
Begeleiders:
+
+ {medewerkers.map((m) => ( + + ))} + {medewerkers.length === 0 && Nog geen medewerkers.} +
+
{editId && } @@ -504,19 +557,21 @@ export function SubgroepenPage() { {loading ?

Laden…

: ( - + {items.map((s) => ( + + ))} - {items.length === 0 && } + {items.length === 0 && }
NaamLeden
NaamGroepLedenBegeleiders
{s.naam}{s.omschrijving &&
{s.omschrijving}
}
{s.groep_naam || "—"} {s.aantal_leden} {s.leden_namen.map((x) => x.naam).join(", ")}{s.begeleiders_namen.map((x) => x.naam).join(", ") || "—"} - +
Nog geen subgroepen.
Nog geen subgroepen.
)} @@ -784,7 +839,8 @@ export function LeerpleinPage() { const [groepen, setGroepen] = useState([]); const [subgroepen, setSubgroepen] = useState([]); const [leerlingen, setLeerlingen] = useState([]); - const leeg = { naam: "", omschrijving: "", groepen: [], subgroepen: [], leerlingen: [] }; + const [medewerkers, setMedewerkers] = useState([]); + const leeg = { naam: "", omschrijving: "", groepen: [], subgroepen: [], leerlingen: [], medewerkers: [] }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); @@ -792,6 +848,7 @@ export function LeerpleinPage() { listResource("groepen").then(setGroepen).catch(() => {}); listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); }, []); function toggle(veld, id) { @@ -841,6 +898,13 @@ export function LeerpleinPage() { {leerlingen.length === 0 && Geen leerlingen.}
+
+
Medewerkers:
+
+ {medewerkers.map((m) => )} + {medewerkers.length === 0 && Geen medewerkers.} +
+
{editId && } @@ -857,10 +921,11 @@ export function LeerpleinPage() { {lp.groepen_namen.length > 0 &&
🗂️ {lp.groepen_namen.map((x) => x.naam).join(", ")}
} {lp.subgroepen_namen.length > 0 &&
🔹 {lp.subgroepen_namen.map((x) => x.naam).join(", ")}
} {lp.leerlingen_namen.length > 0 &&
👤 {lp.leerlingen_namen.map((x) => x.naam).join(", ")}
} - {lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length === 0 && leeg} + {(lp.medewerkers_namen || []).length > 0 &&
🧑‍🏫 {lp.medewerkers_namen.map((x) => x.naam).join(", ")}
} + {lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length + (lp.medewerkers_namen || []).length === 0 && leeg} - +