Fase 5: per-groep tijden, Stage-module (intern/extern), gesplitste personen-flow met functies/stamgroep/vakken en automatische theorievakken
This commit is contained in:
parent
e8f31f6cca
commit
52feabd744
21 changed files with 646 additions and 151 deletions
60
.gitignore
vendored
60
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -17,9 +17,18 @@ menu rustiger is). De zijbalk groepeert items onder inklapbare submenu's
|
|||
(Personen, Groepen).
|
||||
|
||||
Optionele module **Leerplein**: koppelt subgroepen aan groepen en bundelt
|
||||
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein. Aan/uit
|
||||
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein.
|
||||
Optionele module **Stage**: interne en externe stages van leerlingen — beide met
|
||||
een begeleider, een interne stage ook met een ruimte. Beide modules zijn aan/uit
|
||||
te zetten via Modulebeheer.
|
||||
|
||||
**Fase 5 (recent)**: vaste tijden zijn per groep aan te passen (een tijdslot
|
||||
zonder groep = algemene tijd, met groep = afwijkende tijd). Personeel en
|
||||
leerlingen hebben gescheiden invoerschermen. Bij personeel koppel je direct
|
||||
functie(s), stamgroep/plein en de vakken die ze kunnen geven; een leerkracht
|
||||
krijgt de theorievakken automatisch. Bij een leerling leg je groep en
|
||||
geboortedatum vast.
|
||||
|
||||
## Wat er nu staat
|
||||
|
||||
- **Backend** (`backend/`) — Django + Django REST Framework.
|
||||
|
|
@ -74,6 +83,9 @@ API-eindpunten:
|
|||
- `GET /api/conflicten/?schooljaar=<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
0
backend/modules/stage/__init__.py
Normal file
0
backend/modules/stage/__init__.py
Normal file
10
backend/modules/stage/admin.py
Normal file
10
backend/modules/stage/admin.py
Normal 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")
|
||||
29
backend/modules/stage/apps.py
Normal file
29
backend/modules/stage/apps.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
)
|
||||
36
backend/modules/stage/migrations/0001_initial.py
Normal file
36
backend/modules/stage/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/modules/stage/migrations/__init__.py
Normal file
0
backend/modules/stage/migrations/__init__.py
Normal file
60
backend/modules/stage/models.py
Normal file
60
backend/modules/stage/models.py
Normal 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()}"
|
||||
35
backend/modules/stage/serializers.py
Normal file
35
backend/modules/stage/serializers.py
Normal 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
|
||||
9
backend/modules/stage/urls.py
Normal file
9
backend/modules/stage/urls.py
Normal 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))]
|
||||
18
backend/modules/stage/views.py
Normal file
18
backend/modules/stage/views.py
Normal 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
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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:30–10: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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue