Fase 5: per-groep tijden, Stage-module (intern/extern), gesplitste personen-flow met functies/stamgroep/vakken en automatische theorievakken

This commit is contained in:
bes-r 2026-06-12 22:54:47 +02:00
parent e8f31f6cca
commit 52feabd744
21 changed files with 646 additions and 151 deletions

60
.gitignore vendored
View file

@ -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

View file

@ -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=<id>` — roosterconflicten (dubbele inzet,
locatiebotsing, onderbezetting).
- Module Leerplein: `/api/leerpleinen/`, `/api/subgroep-groep-koppelingen/`.
- Module Stage: `/api/stages/` (filters `?type=intern|extern`, `?leerling=<id>`).
- 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

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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):

View file

@ -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)

Binary file not shown.

View file

View file

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

View file

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

View file

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

View file

@ -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()}"

View file

@ -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

View file

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

View file

@ -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

View file

@ -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() {

View file

@ -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 (
<section>
@ -91,32 +102,20 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) {
{uitleg && <p className="muted">{uitleg}</p>}
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<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 })}
/>
<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 })} />
{kleur && (
<input
type="color"
value={form.kleur}
title="Kleur"
onChange={(e) => setForm({ ...form, kleur: e.target.value })}
/>
<input type="color" value={form.kleur} title="Kleur" onChange={(e) => setForm({ ...form, kleur: e.target.value })} />
)}
<button className="btn primary" type="submit">
{editId ? "Opslaan" : "Toevoegen"}
</button>
{metTheorievak && (
<label className="veld">
<input type="checkbox" checked={!!form.theorievak} onChange={(e) => setForm({ ...form, theorievak: e.target.checked })} />
theorievak
</label>
)}
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && (
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>
Annuleren
</button>
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>
)}
</form>
{loading ? (
@ -127,6 +126,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) {
<tr>
<th>Naam</th>
<th>Omschrijving</th>
{metTheorievak && <th>Theorievak</th>}
{telKolom && <th>{telKolom}</th>}
<th></th>
</tr>
@ -135,37 +135,20 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) {
{items.map((it) => (
<tr key={it.id}>
<td>
{kleur && (
<span className="kleur-stip" style={{ background: it.kleur }} />
)}
{kleur && <span className="kleur-stip" style={{ background: it.kleur }} />}
{it.naam}
</td>
<td className="muted">{it.omschrijving || "—"}</td>
{metTheorievak && <td>{it.theorievak ? "✓" : "—"}</td>}
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
<td className="row-actions">
<button
className="link"
onClick={() => {
setEditId(it.id);
setForm(
kleur
? { naam: it.naam, omschrijving: it.omschrijving, kleur: it.kleur }
: { naam: it.naam, omschrijving: it.omschrijving }
);
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(it.id)}>
verwijderen
</button>
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(it.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={telKolom ? 4 : 3} className="muted">Nog niets.</td>
</tr>
<tr><td colSpan={kolommen} className="muted">Nog niets.</td></tr>
)}
</tbody>
</table>
@ -188,10 +171,11 @@ export function FunctiesPage() {
export function ActiviteitenPage() {
return (
<CatalogusPage
titel="Activiteiten"
uitleg="Wat er gedaan wordt: rekenen, gym, therapie, pauze … Elke activiteit heeft een kleur voor in het rooster."
titel="Activiteiten / vakken"
uitleg="Wat er gedaan wordt: rekenen, gym, therapie, pauze … Elke activiteit heeft een kleur. Markeer theorievakken; die koppelen automatisch aan nieuwe leerkrachten."
resource="activiteiten"
kleur
metTheorievak
/>
);
}
@ -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 (
<section>
<h1>Personen</h1>
<p className="muted">Leerlingen en medewerkers. Medewerkers kunnen meerdere functies hebben.</p>
<p className="muted">Personeel en leerlingen staan los van elkaar. Kies wat je wilt toevoegen.</p>
<Foutmelding msg={error} />
<div className="tabbar">
<button className={modus === "leerling" ? "tab on" : "tab"} onClick={() => kiesModus("leerling")}>Leerling toevoegen</button>
<button className={modus === "medewerker" ? "tab on" : "tab"} onClick={() => kiesModus("medewerker")}>Medewerker toevoegen</button>
</div>
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
{isLeerling && (
<select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> geen groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
{modus === "leerling" ? (
<>
<select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value=""> groep </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<label className="veld">Geb. <input type="date" value={form.geboortedatum || ""} onChange={(e) => setForm({ ...form, geboortedatum: e.target.value })} /></label>
</>
) : (
<>
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{STAFF_ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<select value={form.stamgroep || ""} onChange={(e) => setForm({ ...form, stamgroep: e.target.value })}>
<option value=""> stamgroep/plein </option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
</>
)}
</div>
{!isLeerling && (
<div className="leden-keuze">
<div className="muted small">Functies (meerdere mogelijk):</div>
<div className="chips">
{functies.map((f) => (
<button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggleFunctie(f.id)}>
{f.naam}
</button>
))}
{functies.length === 0 && <span className="muted small">Nog geen functies voeg ze toe bij Functies.</span>}
{modus === "medewerker" && (
<>
<div className="leden-keuze">
<div className="muted small">Functies:</div>
<div className="chips">
{functies.map((f) => <button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggle("functies", f.id)}>{f.naam}</button>)}
{functies.length === 0 && <span className="muted small">Nog geen functies.</span>}
</div>
</div>
</div>
<div className="leden-keuze">
<div className="muted small">Vakken die gegeven kunnen worden{form.rol === "leerkracht" ? " (theorievakken worden automatisch gekoppeld)" : ""}:</div>
<div className="chips">
{vakken.map((a) => <button type="button" key={a.id} className={form.vakken.includes(a.id) ? "chip on" : "chip"} onClick={() => toggle("vakken", a.id)}>{a.naam}{a.theorievak ? " ●" : ""}</button>)}
{vakken.length === 0 && <span className="muted small">Nog geen vakken/activiteiten.</span>}
</div>
</div>
</>
)}
<div className="form-row">
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
{editId && <button type="button" className="btn" onClick={() => kiesModus(modus)}>Annuleren</button>}
</div>
</form>
<div className="filterbar">
<span className="muted">Filter:</span>
<span className="muted">Toon:</span>
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
{ROLLEN.map((r) => (
<button key={r.value} className={filter === r.value ? "chip on" : "chip"} onClick={() => setFilter(r.value)}>{r.label}</button>
))}
<button className={filter === "leerling" ? "chip on" : "chip"} onClick={() => setFilter("leerling")}>Leerlingen</button>
<button className={filter === "medewerker" ? "chip on" : "chip"} onClick={() => setFilter("medewerker")}>Medewerkers</button>
</div>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Naam</th><th>Rol</th><th>Groep / Functies</th><th></th></tr></thead>
<thead><tr><th>Naam</th><th>Rol</th><th>Details</th><th></th></tr></thead>
<tbody>
{zichtbaar.map((p) => (
<tr key={p.id}>
<td>{p.volledige_naam}</td>
<td>{p.rol_label}</td>
<td className="muted">
{p.rol === "leerling"
? (p.groep_naam || "—")
: (p.functies_namen.length ? p.functies_namen.map((x) => x.naam).join(", ") : "—")}
<td className="muted small">
{p.rol === "leerling" ? (
<>
{p.groep_naam ? <span>🗂 {p.groep_naam} </span> : null}
{p.geboortedatum ? <span>🎂 {p.geboortedatum}</span> : null}
{!p.groep_naam && !p.geboortedatum ? "—" : null}
</>
) : (
<>
{p.stamgroep_naam ? <div>🗂 {p.stamgroep_naam}</div> : null}
{p.functies_namen.length ? <div>🪪 {p.functies_namen.map((x) => x.naam).join(", ")}</div> : null}
{p.vakken_namen.length ? <div>📚 {p.vakken_namen.map((x) => x.naam).join(", ")}</div> : null}
{!p.stamgroep_naam && !p.functies_namen.length && !p.vakken_namen.length ? "—" : null}
</>
)}
</td>
<td className="row-actions">
<button
className="link"
onClick={() => {
setEditId(p.id);
setForm({
voornaam: p.voornaam,
achternaam: p.achternaam,
rol: p.rol,
groep: p.groep || "",
functies: p.functies_namen.map((x) => x.id),
});
}}
>
bewerken
</button>
<button className="link" onClick={() => bewerk(p)}>bewerken</button>
<button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
</td>
</tr>
@ -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 (
<section>
<h1>Tijdsloten</h1>
<p className="muted">Vaste tijdblokken per schooldag (bv. Blok 1, maandag 08:3010:00).</p>
<p className="muted">Vaste tijden per schooldag. Laat de groep leeg voor de algemene tijden, of kies een groep voor afwijkende tijden.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. Blok 1)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
@ -543,25 +569,30 @@ export function TijdslotenPage() {
<input type="time" value={form.start_tijd} onChange={(e) => setForm({ ...form, start_tijd: e.target.value })} />
<input type="time" value={form.eind_tijd} onChange={(e) => setForm({ ...form, eind_tijd: e.target.value })} />
<input type="number" min="0" style={{ width: 80 }} title="Volgorde" value={form.volgorde} onChange={(e) => setForm({ ...form, volgorde: Number(e.target.value) })} />
<select value={form.groep || ""} title="Groep (leeg = algemeen)" onChange={(e) => setForm({ ...form, groep: e.target.value })}>
<option value="">algemeen</option>
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
</select>
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Dag</th><th>Tijd</th><th>Naam</th><th></th></tr></thead>
<thead><tr><th>Dag</th><th>Tijd</th><th>Naam</th><th>Geldt voor</th><th></th></tr></thead>
<tbody>
{items.map((t) => (
<tr key={t.id}>
<td>{t.dag_label}</td>
<td>{t.start_tijd?.slice(0, 5)}{t.eind_tijd?.slice(0, 5)}</td>
<td>{t.naam}</td>
<td className="muted">{t.groep_naam || "algemeen"}</td>
<td className="row-actions">
<button className="link" onClick={() => { setEditId(t.id); setForm({ naam: t.naam, dag: t.dag, start_tijd: t.start_tijd?.slice(0, 5), eind_tijd: t.eind_tijd?.slice(0, 5), volgorde: t.volgorde }); }}>bewerken</button>
<button className="link" onClick={() => { setEditId(t.id); setForm({ naam: t.naam, dag: t.dag, start_tijd: t.start_tijd?.slice(0, 5), eind_tijd: t.eind_tijd?.slice(0, 5), volgorde: t.volgorde, groep: t.groep || "" }); }}>bewerken</button>
<button className="link danger" onClick={() => verwijder(t.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen tijdsloten.</td></tr>}
{items.length === 0 && <tr><td colSpan="5" className="muted">Nog geen tijdsloten.</td></tr>}
</tbody>
</table>
)}
@ -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 (
<section>
<h1>Stage</h1>
<p className="muted">Interne en externe stages. Beide krijgen een begeleider; een interne stage ook een ruimte.</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<select value={form.leerling} required onChange={(e) => setForm({ ...form, leerling: e.target.value })}>
<option value=""> leerling </option>
{leerlingen.map((l) => <option key={l.id} value={l.id}>{l.volledige_naam}</option>)}
</select>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="intern">Interne stage</option>
<option value="extern">Externe stage</option>
</select>
<select value={form.begeleider} required onChange={(e) => setForm({ ...form, begeleider: e.target.value })}>
<option value=""> begeleider </option>
{medewerkers.map((m) => <option key={m.id} value={m.id}>{m.volledige_naam}</option>)}
</select>
</div>
<div className="form-row">
{isIntern ? (
<select value={form.locatie} required onChange={(e) => setForm({ ...form, locatie: e.target.value })}>
<option value=""> ruimte </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
) : (
<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 })}>
<option value=""> dag (optioneel) </option>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input placeholder="Opmerking (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
</div>
<div className="form-row">
<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>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Leerling</th><th>Type</th><th>Begeleider</th><th>Waar</th><th>Dag</th><th></th></tr></thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>{s.leerling_naam}</td>
<td>{s.type_label}</td>
<td>{s.begeleider_naam}</td>
<td className="muted">{s.type === "intern" ? (s.locatie_naam || "—") : (s.organisatie || "—")}</td>
<td className="muted">{s.dag_label || "—"}</td>
<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 danger" onClick={() => verwijder(s.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="6" className="muted">Nog geen stages.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- Conflicten ------------------------------ */
function Conflicten({ schooljaar }) {
const [data, setData] = useState(null);