Fase 1-4: functies, afwezigheid & uitzonderingen, weekrooster met drag-and-drop, conflictdetectie, Leerplein-module en menu-herstructurering
This commit is contained in:
parent
0117c754fa
commit
e8f31f6cca
21 changed files with 635 additions and 43 deletions
32
README.md
32
README.md
|
|
@ -4,13 +4,21 @@ Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een
|
||||||
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
|
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
|
||||||
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
||||||
|
|
||||||
Status: **Fase 0–3** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
|
Status: **Fase 0–4** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
|
||||||
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken) en
|
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken),
|
||||||
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster). Medewerkers kunnen
|
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster) en Fase 4
|
||||||
meerdere functies hebben uit een beheerbare functie-catalogus. Het Rooster-scherm
|
(**conflictdetectie**: dubbele inzet van begeleider/leerling en locatiebotsingen).
|
||||||
is een **weekweergave met drag-and-drop** (muis én touch) en een
|
|
||||||
**touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen worden
|
Het Rooster-scherm is een **weekweergave met drag-and-drop** (muis én touch) en
|
||||||
automatisch in het rooster verwerkt.
|
een **touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen
|
||||||
|
worden automatisch verwerkt. Het Rooster heeft tabs **Weekrooster / Conflicten /
|
||||||
|
Instellingen** (tijdsloten, activiteiten en locaties beheer je daar, zodat het
|
||||||
|
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
|
||||||
|
te zetten via Modulebeheer.
|
||||||
|
|
||||||
## Wat er nu staat
|
## Wat er nu staat
|
||||||
|
|
||||||
|
|
@ -63,6 +71,9 @@ API-eindpunten:
|
||||||
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`),
|
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`),
|
||||||
`/api/afwezigheden/` (filters `?persoon=`, `?van=`, `?tot=`),
|
`/api/afwezigheden/` (filters `?persoon=`, `?van=`, `?tot=`),
|
||||||
`/api/blok-uitzonderingen/` (filters `?roosterblok=`, `?schooljaar=`, `?van=`, `?tot=`).
|
`/api/blok-uitzonderingen/` (filters `?roosterblok=`, `?schooljaar=`, `?van=`, `?tot=`).
|
||||||
|
- `GET /api/conflicten/?schooljaar=<id>` — roosterconflicten (dubbele inzet,
|
||||||
|
locatiebotsing, onderbezetting).
|
||||||
|
- Module Leerplein: `/api/leerpleinen/`, `/api/subgroep-groep-koppelingen/`.
|
||||||
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
|
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
|
||||||
|
|
||||||
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
|
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
|
||||||
|
|
@ -80,7 +91,8 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
|
||||||
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
|
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
|
||||||
aan/uit te zetten.
|
aan/uit te zetten.
|
||||||
|
|
||||||
## Volgende stap (Fase 4)
|
## Volgende stap (Fase 5 & 6)
|
||||||
|
|
||||||
Conflictdetectie: automatisch signaleren van dubbele inzet van leerkrachten,
|
De losse modules verder uitbouwen: **Printen/Exporteren** (roosters als PDF) en
|
||||||
ondersteuners of leerlingen op hetzelfde moment, en locatiebotsingen.
|
**Multi-user & toegang** verfijnen (leerling-/ouder-/collega-rollen). Eventueel de
|
||||||
|
conflictdetectie datum-specifiek maken (rekening houdend met afwezigheid per dag).
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ CORE_APPS = [
|
||||||
MODULE_APPS = [
|
MODULE_APPS = [
|
||||||
"modules.printing", # Printen/Exporteren
|
"modules.printing", # Printen/Exporteren
|
||||||
"modules.accounts", # Multi-user & toegang
|
"modules.accounts", # Multi-user & toegang
|
||||||
|
"modules.leerplein", # Leerplein (groepsstructuur)
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
|
||||||
|
|
@ -177,6 +178,9 @@ REST_FRAMEWORK = {
|
||||||
"anon": "20/min",
|
"anon": "20/min",
|
||||||
"user": "300/min",
|
"user": "300/min",
|
||||||
},
|
},
|
||||||
|
# Achter nginx zien we anders het proxy-IP voor iedereen: dan zou één
|
||||||
|
# throttle-emmer voor de hele school gelden. nginx zet X-Forwarded-For.
|
||||||
|
"NUM_PROXIES": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend (React dev-server) mag de API benaderen.
|
# Frontend (React dev-server) mag de API benaderen.
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,6 @@ urlpatterns = [
|
||||||
path("api/", include("plugins.urls")),
|
path("api/", include("plugins.urls")),
|
||||||
# Kern-API.
|
# Kern-API.
|
||||||
path("api/", include("core.urls")),
|
path("api/", include("core.urls")),
|
||||||
|
# Module-API's.
|
||||||
|
path("api/", include("modules.leerplein.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,16 @@ class CoreConfig(AppConfig):
|
||||||
category="Kern",
|
category="Kern",
|
||||||
core=True,
|
core=True,
|
||||||
menu_items=(
|
menu_items=(
|
||||||
|
# Hoofdniveau
|
||||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
||||||
MenuItem("Personen", "/personen", icon="users", order=20),
|
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=15),
|
||||||
MenuItem("Functies", "/functies", icon="badge", order=25),
|
# Submenu Personen
|
||||||
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
MenuItem("Personen", "/personen", icon="users", order=20, group="Personen"),
|
||||||
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
MenuItem("Functies", "/functies", icon="badge", order=25, group="Personen"),
|
||||||
MenuItem("Activiteiten", "/activiteiten", icon="book", order=50),
|
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Personen"),
|
||||||
MenuItem("Locaties", "/locaties", icon="pin", order=55),
|
# Submenu Groepen
|
||||||
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
|
MenuItem("Groepen", "/groepen", icon="layers", order=30, group="Groepen"),
|
||||||
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
|
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40, group="Groepen"),
|
||||||
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=80),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,13 @@ class SchooljaarSerializer(serializers.ModelSerializer):
|
||||||
model = Schooljaar
|
model = Schooljaar
|
||||||
fields = ["id", "naam", "start_datum", "eind_datum", "actief"]
|
fields = ["id", "naam", "start_datum", "eind_datum", "actief"]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
start = data.get("start_datum", getattr(self.instance, "start_datum", None))
|
||||||
|
eind = data.get("eind_datum", getattr(self.instance, "eind_datum", None))
|
||||||
|
if start and eind and eind <= start:
|
||||||
|
raise serializers.ValidationError("De einddatum moet na de startdatum liggen.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class KalenderDagSerializer(serializers.ModelSerializer):
|
class KalenderDagSerializer(serializers.ModelSerializer):
|
||||||
type_label = serializers.CharField(source="get_type_display", read_only=True)
|
type_label = serializers.CharField(source="get_type_display", read_only=True)
|
||||||
|
|
@ -101,8 +108,20 @@ class TijdslotSerializer(serializers.ModelSerializer):
|
||||||
model = Tijdslot
|
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", "actief"]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
start = data.get("start_tijd", getattr(self.instance, "start_tijd", None))
|
||||||
|
eind = data.get("eind_tijd", getattr(self.instance, "eind_tijd", None))
|
||||||
|
if start and eind and eind <= start:
|
||||||
|
raise serializers.ValidationError("De eindtijd moet na de starttijd liggen.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ActiviteitSerializer(serializers.ModelSerializer):
|
class ActiviteitSerializer(serializers.ModelSerializer):
|
||||||
|
kleur = serializers.RegexField(
|
||||||
|
regex=r"^#[0-9a-fA-F]{6}$",
|
||||||
|
error_messages={"invalid": "Kleur moet een hex-code zijn, bv. #2563eb."},
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Activiteit
|
model = Activiteit
|
||||||
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
|
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
|
||||||
|
|
@ -156,6 +175,13 @@ class RoosterblokSerializer(serializers.ModelSerializer):
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"Kies precies één doelgroep: groep, subgroep of leerling."
|
"Kies precies één doelgroep: groep, subgroep of leerling."
|
||||||
)
|
)
|
||||||
|
# Begeleiders zijn medewerkers; een leerling kan geen begeleider zijn.
|
||||||
|
# (Het Roosterblok-model kent geen rolbeperking, dus hier afdwingen.)
|
||||||
|
for p in data.get("begeleiders", []):
|
||||||
|
if p.rol == "leerling":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"'{p.volledige_naam}' is een leerling en kan geen begeleider zijn."
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -190,3 +216,10 @@ class BlokUitzonderingSerializer(serializers.ModelSerializer):
|
||||||
"id", "roosterblok", "datum", "vervalt",
|
"id", "roosterblok", "datum", "vervalt",
|
||||||
"vervanger", "vervanger_naam", "opmerking",
|
"vervanger", "vervanger_naam", "opmerking",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate_vervanger(self, value):
|
||||||
|
if value is not None and value.rol == "leerling":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Een leerling kan geen vervanger zijn."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ router.register("blok-uitzonderingen", views.BlokUitzonderingViewSet, basename="
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("health/", views.health, name="health"),
|
path("health/", views.health, name="health"),
|
||||||
path("info/", views.info, name="info"),
|
path("info/", views.info, name="info"),
|
||||||
|
path("conflicten/", views.conflicten, name="conflicten"),
|
||||||
path("auth/me/", views.auth_me, name="auth-me"),
|
path("auth/me/", views.auth_me, name="auth-me"),
|
||||||
path("auth/login/", views.auth_login_view, name="auth-login"),
|
path("auth/login/", views.auth_login_view, name="auth-login"),
|
||||||
path("auth/logout/", views.auth_logout_view, name="auth-logout"),
|
path("auth/logout/", views.auth_logout_view, name="auth-logout"),
|
||||||
|
|
|
||||||
|
|
@ -208,3 +208,116 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet):
|
||||||
if schooljaar:
|
if schooljaar:
|
||||||
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
|
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
def _leerlingen_van_blok(blok):
|
||||||
|
"""De leerlingen die een blok raakt (via groep, subgroep of individueel)."""
|
||||||
|
if blok.groep_id:
|
||||||
|
return [p.id for p in blok.groep.leerlingen.all()]
|
||||||
|
if blok.subgroep_id:
|
||||||
|
return [p.id for p in blok.subgroep.leden.all()]
|
||||||
|
if blok.leerling_id:
|
||||||
|
return [blok.leerling_id]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def conflicten(request):
|
||||||
|
"""Signaleer roosterconflicten binnen een schooljaar.
|
||||||
|
|
||||||
|
Per tijdslot (zelfde dag + tijd) controleren we op:
|
||||||
|
- dubbele inzet van een begeleider;
|
||||||
|
- een leerling die in twee blokken tegelijk zit;
|
||||||
|
- twee blokken in dezelfde locatie;
|
||||||
|
- onderbezetting (blok zonder begeleider) als waarschuwing.
|
||||||
|
"""
|
||||||
|
schooljaar = request.query_params.get("schooljaar")
|
||||||
|
qs = (
|
||||||
|
Roosterblok.objects
|
||||||
|
.select_related("tijdslot", "locatie", "groep", "subgroep", "leerling")
|
||||||
|
.prefetch_related("begeleiders", "groep__leerlingen", "subgroep__leden")
|
||||||
|
)
|
||||||
|
if schooljaar:
|
||||||
|
qs = qs.filter(schooljaar_id=schooljaar)
|
||||||
|
|
||||||
|
# Groepeer blokken per tijdslot.
|
||||||
|
per_slot = {}
|
||||||
|
for blok in qs:
|
||||||
|
per_slot.setdefault(blok.tijdslot_id, []).append(blok)
|
||||||
|
|
||||||
|
resultaat = []
|
||||||
|
|
||||||
|
def naam_van(persoon):
|
||||||
|
return persoon.volledige_naam
|
||||||
|
|
||||||
|
for slot_id, blokken in per_slot.items():
|
||||||
|
slot_label = str(blokken[0].tijdslot)
|
||||||
|
|
||||||
|
# Begeleiders dubbel ingezet.
|
||||||
|
begeleider_map = {}
|
||||||
|
for b in blokken:
|
||||||
|
for p in b.begeleiders.all():
|
||||||
|
begeleider_map.setdefault(p.id, {"naam": naam_van(p), "blokken": []})
|
||||||
|
begeleider_map[p.id]["blokken"].append(b)
|
||||||
|
for info in begeleider_map.values():
|
||||||
|
if len(info["blokken"]) > 1:
|
||||||
|
resultaat.append({
|
||||||
|
"type": "begeleider",
|
||||||
|
"ernst": "fout",
|
||||||
|
"tijdslot": slot_label,
|
||||||
|
"detail": f"{info['naam']} is op {len(info['blokken'])} plekken tegelijk ingepland",
|
||||||
|
"blokken": [b.activiteit.naam for b in info["blokken"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Leerlingen dubbel ingepland.
|
||||||
|
leerling_map = {}
|
||||||
|
for b in blokken:
|
||||||
|
for lid in _leerlingen_van_blok(b):
|
||||||
|
leerling_map.setdefault(lid, [])
|
||||||
|
leerling_map[lid].append(b)
|
||||||
|
if leerling_map:
|
||||||
|
namen = {p.id: p.volledige_naam for p in Persoon.objects.filter(id__in=leerling_map.keys())}
|
||||||
|
for lid, bl in leerling_map.items():
|
||||||
|
if len(bl) > 1:
|
||||||
|
resultaat.append({
|
||||||
|
"type": "leerling",
|
||||||
|
"ernst": "fout",
|
||||||
|
"tijdslot": slot_label,
|
||||||
|
"detail": f"{namen.get(lid, 'Leerling')} zit in {len(bl)} blokken tegelijk",
|
||||||
|
"blokken": [b.activiteit.naam for b in bl],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Locatiebotsing.
|
||||||
|
locatie_map = {}
|
||||||
|
for b in blokken:
|
||||||
|
if b.locatie_id:
|
||||||
|
locatie_map.setdefault(b.locatie_id, {"naam": b.locatie.naam, "blokken": []})
|
||||||
|
locatie_map[b.locatie_id]["blokken"].append(b)
|
||||||
|
for info in locatie_map.values():
|
||||||
|
if len(info["blokken"]) > 1:
|
||||||
|
resultaat.append({
|
||||||
|
"type": "locatie",
|
||||||
|
"ernst": "fout",
|
||||||
|
"tijdslot": slot_label,
|
||||||
|
"detail": f"Locatie {info['naam']} is dubbel geboekt",
|
||||||
|
"blokken": [b.activiteit.naam for b in info["blokken"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Onderbezetting (waarschuwing).
|
||||||
|
for b in blokken:
|
||||||
|
if b.begeleiders.count() == 0:
|
||||||
|
resultaat.append({
|
||||||
|
"type": "onderbezetting",
|
||||||
|
"ernst": "waarschuwing",
|
||||||
|
"tijdslot": slot_label,
|
||||||
|
"detail": f"{b.activiteit.naam} ({b.doel_type}) heeft geen begeleider",
|
||||||
|
"blokken": [b.activiteit.naam],
|
||||||
|
})
|
||||||
|
|
||||||
|
fouten = sum(1 for r in resultaat if r["ernst"] == "fout")
|
||||||
|
return Response({
|
||||||
|
"aantal": len(resultaat),
|
||||||
|
"fouten": fouten,
|
||||||
|
"waarschuwingen": len(resultaat) - fouten,
|
||||||
|
"conflicten": resultaat,
|
||||||
|
})
|
||||||
|
|
|
||||||
0
backend/modules/leerplein/__init__.py
Normal file
0
backend/modules/leerplein/__init__.py
Normal file
16
backend/modules/leerplein/admin.py
Normal file
16
backend/modules/leerplein/admin.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Leerplein, SubgroepGroep
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Leerplein)
|
||||||
|
class LeerpleinAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "omschrijving", "actief")
|
||||||
|
list_filter = ("actief",)
|
||||||
|
search_fields = ("naam",)
|
||||||
|
filter_horizontal = ("groepen", "subgroepen", "leerlingen")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SubgroepGroep)
|
||||||
|
class SubgroepGroepAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("subgroep", "groep")
|
||||||
29
backend/modules/leerplein/apps.py
Normal file
29
backend/modules/leerplein/apps.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LeerpleinConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "modules.leerplein"
|
||||||
|
label = "module_leerplein"
|
||||||
|
verbose_name = "Module: Leerplein (groepsstructuur)"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from plugins.registry import MenuItem, ModuleSpec, registry
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
ModuleSpec(
|
||||||
|
key="leerplein",
|
||||||
|
name="Leerplein (groepsstructuur)",
|
||||||
|
description=(
|
||||||
|
"Koppel subgroepen aan groepen en bundel groepen, subgroepen "
|
||||||
|
"en losse leerlingen onder een overkoepelend leerplein."
|
||||||
|
),
|
||||||
|
version="0.1.0",
|
||||||
|
category="Structuur",
|
||||||
|
core=False,
|
||||||
|
default_enabled=False,
|
||||||
|
menu_items=(
|
||||||
|
MenuItem("Leerplein", "/leerplein", icon="layers", order=50, group="Groepen"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
48
backend/modules/leerplein/migrations/0001_initial.py
Normal file
48
backend/modules/leerplein/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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', '0003_afwezigheid_blokuitzondering'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Leerplein',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('naam', models.CharField(max_length=100, unique=True, verbose_name='naam')),
|
||||||
|
('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')),
|
||||||
|
('actief', models.BooleanField(default=True, verbose_name='actief')),
|
||||||
|
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('groepen', models.ManyToManyField(blank=True, related_name='leerpleinen', to='core.groep')),
|
||||||
|
('leerlingen', models.ManyToManyField(blank=True, limit_choices_to={'rol': 'leerling'}, related_name='leerpleinen', to='core.persoon')),
|
||||||
|
('subgroepen', models.ManyToManyField(blank=True, related_name='leerpleinen', to='core.subgroep')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'leerplein',
|
||||||
|
'verbose_name_plural': 'leerpleinen',
|
||||||
|
'ordering': ['naam'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SubgroepGroep',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('groep', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subgroep_koppelingen', to='core.groep')),
|
||||||
|
('subgroep', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groep_koppelingen', to='core.subgroep')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'subgroep-groep-koppeling',
|
||||||
|
'verbose_name_plural': 'subgroep-groep-koppelingen',
|
||||||
|
'ordering': ['groep__naam', 'subgroep__naam'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('groep', 'subgroep'), name='uniek_groep_subgroep')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/modules/leerplein/migrations/__init__.py
Normal file
0
backend/modules/leerplein/migrations/__init__.py
Normal file
61
backend/modules/leerplein/models.py
Normal file
61
backend/modules/leerplein/models.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
Leerplein-module: een overkoepelende groepsstructuur.
|
||||||
|
|
||||||
|
- Een Leerplein bundelt meerdere groepen en/of subgroepen, en kan daarnaast
|
||||||
|
losse (individuele) leerlingen bevatten.
|
||||||
|
- Een SubgroepGroep-koppeling verbindt een subgroep met een groep.
|
||||||
|
|
||||||
|
De module raakt de kernmodellen niet aan; het zijn losse koppeltabellen die
|
||||||
|
alleen bestaan als de module geïnstalleerd is.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Leerplein(models.Model):
|
||||||
|
naam = models.CharField("naam", max_length=100, unique=True)
|
||||||
|
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
|
||||||
|
groepen = models.ManyToManyField(
|
||||||
|
"core.Groep", verbose_name="groepen", related_name="leerpleinen", blank=True
|
||||||
|
)
|
||||||
|
subgroepen = models.ManyToManyField(
|
||||||
|
"core.Subgroep", verbose_name="subgroepen", related_name="leerpleinen", blank=True
|
||||||
|
)
|
||||||
|
leerlingen = models.ManyToManyField(
|
||||||
|
"core.Persoon", verbose_name="losse leerlingen", related_name="leerpleinen",
|
||||||
|
blank=True, limit_choices_to={"rol": "leerling"},
|
||||||
|
)
|
||||||
|
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "leerplein"
|
||||||
|
verbose_name_plural = "leerpleinen"
|
||||||
|
ordering = ["naam"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
|
class SubgroepGroep(models.Model):
|
||||||
|
"""Koppelt een subgroep aan een groep (subgroep hoort bij groep)."""
|
||||||
|
|
||||||
|
groep = models.ForeignKey(
|
||||||
|
"core.Groep", on_delete=models.CASCADE, related_name="subgroep_koppelingen"
|
||||||
|
)
|
||||||
|
subgroep = models.ForeignKey(
|
||||||
|
"core.Subgroep", on_delete=models.CASCADE, related_name="groep_koppelingen"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "subgroep-groep-koppeling"
|
||||||
|
verbose_name_plural = "subgroep-groep-koppelingen"
|
||||||
|
ordering = ["groep__naam", "subgroep__naam"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["groep", "subgroep"], name="uniek_groep_subgroep"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.subgroep} ↔ {self.groep}"
|
||||||
48
backend/modules/leerplein/serializers.py
Normal file
48
backend/modules/leerplein/serializers.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.models import Groep, Persoon, Subgroep
|
||||||
|
|
||||||
|
from .models import Leerplein, SubgroepGroep
|
||||||
|
|
||||||
|
|
||||||
|
class LeerpleinSerializer(serializers.ModelSerializer):
|
||||||
|
groepen = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Groep.objects.all(), required=False
|
||||||
|
)
|
||||||
|
subgroepen = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Subgroep.objects.all(), required=False
|
||||||
|
)
|
||||||
|
leerlingen = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
|
||||||
|
)
|
||||||
|
groepen_namen = serializers.SerializerMethodField()
|
||||||
|
subgroepen_namen = serializers.SerializerMethodField()
|
||||||
|
leerlingen_namen = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Leerplein
|
||||||
|
fields = [
|
||||||
|
"id", "naam", "omschrijving", "actief",
|
||||||
|
"groepen", "subgroepen", "leerlingen",
|
||||||
|
"groepen_namen", "subgroepen_namen", "leerlingen_namen",
|
||||||
|
"aangemaakt_op",
|
||||||
|
]
|
||||||
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
|
def get_groepen_namen(self, obj):
|
||||||
|
return [{"id": g.id, "naam": g.naam} for g in obj.groepen.all()]
|
||||||
|
|
||||||
|
def get_subgroepen_namen(self, obj):
|
||||||
|
return [{"id": s.id, "naam": s.naam} for s in obj.subgroepen.all()]
|
||||||
|
|
||||||
|
def get_leerlingen_namen(self, obj):
|
||||||
|
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leerlingen.all()]
|
||||||
|
|
||||||
|
|
||||||
|
class SubgroepGroepSerializer(serializers.ModelSerializer):
|
||||||
|
groep_naam = serializers.CharField(source="groep.naam", read_only=True)
|
||||||
|
subgroep_naam = serializers.CharField(source="subgroep.naam", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SubgroepGroep
|
||||||
|
fields = ["id", "groep", "groep_naam", "subgroep", "subgroep_naam"]
|
||||||
10
backend/modules/leerplein/urls.py
Normal file
10
backend/modules/leerplein/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register("leerpleinen", views.LeerpleinViewSet, basename="leerplein")
|
||||||
|
router.register("subgroep-groep-koppelingen", views.SubgroepGroepViewSet, basename="subgroep-groep")
|
||||||
|
|
||||||
|
urlpatterns = [path("", include(router.urls))]
|
||||||
23
backend/modules/leerplein/views.py
Normal file
23
backend/modules/leerplein/views.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from .models import Leerplein, SubgroepGroep
|
||||||
|
from .serializers import LeerpleinSerializer, SubgroepGroepSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LeerpleinViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Leerplein.objects.prefetch_related("groepen", "subgroepen", "leerlingen").all()
|
||||||
|
serializer_class = LeerpleinSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SubgroepGroepViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = SubgroepGroepSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = SubgroepGroep.objects.select_related("groep", "subgroep").all()
|
||||||
|
groep = self.request.query_params.get("groep")
|
||||||
|
if groep:
|
||||||
|
qs = qs.filter(groep_id=groep)
|
||||||
|
subgroep = self.request.query_params.get("subgroep")
|
||||||
|
if subgroep:
|
||||||
|
qs = qs.filter(subgroep_id=subgroep)
|
||||||
|
return qs
|
||||||
|
|
@ -17,12 +17,17 @@ from dataclasses import dataclass, field
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MenuItem:
|
class MenuItem:
|
||||||
"""Een navigatie-item dat een module toevoegt aan de app."""
|
"""Een navigatie-item dat een module toevoegt aan de app.
|
||||||
|
|
||||||
|
`group` plaatst het item onder een (inklapbaar) submenu in de zijbalk.
|
||||||
|
Leeg = een item op het hoofdniveau.
|
||||||
|
"""
|
||||||
|
|
||||||
label: str
|
label: str
|
||||||
path: str
|
path: str
|
||||||
icon: str = "puzzle"
|
icon: str = "puzzle"
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
group: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ def describe_all() -> list[dict]:
|
||||||
"path": m.path,
|
"path": m.path,
|
||||||
"icon": m.icon,
|
"icon": m.icon,
|
||||||
"order": m.order,
|
"order": m.order,
|
||||||
|
"group": m.group,
|
||||||
}
|
}
|
||||||
for m in spec.menu_items
|
for m in spec.menu_items
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Logo from "./Logo.jsx";
|
||||||
import {
|
import {
|
||||||
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
||||||
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
|
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
|
||||||
AfwezigheidPage,
|
AfwezigheidPage, LeerpleinPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|
@ -26,6 +26,7 @@ const PAGES = {
|
||||||
"/tijdsloten": TijdslotenPage,
|
"/tijdsloten": TijdslotenPage,
|
||||||
"/schooljaar": SchooljaarPage,
|
"/schooljaar": SchooljaarPage,
|
||||||
"/afwezigheid": AfwezigheidPage,
|
"/afwezigheid": AfwezigheidPage,
|
||||||
|
"/leerplein": LeerpleinPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -129,20 +130,40 @@ function Shell({ user, onLogout }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [openGroups, setOpenGroups] = useState({});
|
||||||
|
|
||||||
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
|
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
|
||||||
const menu = useMemo(() => {
|
// Items met een `group` komen onder een inklapbaar submenu.
|
||||||
|
const { topItems, groups, alleItems } = useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const m of modules) {
|
for (const m of modules) {
|
||||||
if (!m.enabled) continue;
|
if (!m.enabled) continue;
|
||||||
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
||||||
}
|
}
|
||||||
// Modulebeheer is een beheerdersactie.
|
|
||||||
if (user.is_staff) {
|
if (user.is_staff) {
|
||||||
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
|
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999, group: "" });
|
||||||
}
|
}
|
||||||
return items.sort((a, b) => a.order - b.order);
|
items.sort((a, b) => a.order - b.order);
|
||||||
|
const top = items.filter((i) => !i.group);
|
||||||
|
const grouped = {};
|
||||||
|
for (const i of items) {
|
||||||
|
if (i.group) (grouped[i.group] = grouped[i.group] || []).push(i);
|
||||||
|
}
|
||||||
|
return { topItems: top, groups: grouped, alleItems: items };
|
||||||
}, [modules, user.is_staff]);
|
}, [modules, user.is_staff]);
|
||||||
|
|
||||||
|
function NavKnop({ it, indent }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={(active === it.path ? "nav-item active" : "nav-item") + (indent ? " nav-sub" : "")}
|
||||||
|
onClick={() => setActive(it.path)}
|
||||||
|
>
|
||||||
|
<span className="ico">{ICONS[it.icon] || "•"}</span>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Page = PAGES[active];
|
const Page = PAGES[active];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -153,16 +174,19 @@ function Shell({ user, onLogout }) {
|
||||||
<span className="brand-name">Roosterwijs</span>
|
<span className="brand-name">Roosterwijs</span>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
{menu.map((it) => (
|
{topItems.map((it) => <NavKnop key={it.path} it={it} />)}
|
||||||
<button
|
{Object.entries(groups).map(([naam, kinderen]) => {
|
||||||
key={it.path}
|
const open = openGroups[naam] !== false;
|
||||||
className={active === it.path ? "nav-item active" : "nav-item"}
|
return (
|
||||||
onClick={() => setActive(it.path)}
|
<div className="nav-group" key={naam}>
|
||||||
>
|
<button className="nav-group-head" onClick={() => setOpenGroups((o) => ({ ...o, [naam]: !open }))}>
|
||||||
<span className="ico">{ICONS[it.icon] || "•"}</span>
|
<span>{naam}</span>
|
||||||
{it.label}
|
<span className="nav-chev">{open ? "▾" : "▸"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
{open && kinderen.map((it) => <NavKnop key={it.path} it={it} indent />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-user">
|
<div className="sidebar-user">
|
||||||
<span className="muted small">👤 {user.username}</span>
|
<span className="muted small">👤 {user.username}</span>
|
||||||
|
|
@ -180,7 +204,7 @@ function Shell({ user, onLogout }) {
|
||||||
) : Page ? (
|
) : Page ? (
|
||||||
<Page />
|
<Page />
|
||||||
) : (
|
) : (
|
||||||
<Placeholder path={active} menu={menu} />
|
<Placeholder path={active} menu={alleItems} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -744,6 +744,128 @@ export function AfwezigheidPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- Leerplein ------------------------------- */
|
||||||
|
// Module-scherm: koppel subgroepen aan groepen en bundel groepen, subgroepen
|
||||||
|
// en losse leerlingen onder een overkoepelend leerplein.
|
||||||
|
export function LeerpleinPage() {
|
||||||
|
const { items, error, loading, reload, setError } = useList("leerpleinen");
|
||||||
|
const [groepen, setGroepen] = useState([]);
|
||||||
|
const [subgroepen, setSubgroepen] = useState([]);
|
||||||
|
const [leerlingen, setLeerlingen] = useState([]);
|
||||||
|
const leeg = { naam: "", omschrijving: "", groepen: [], subgroepen: [], leerlingen: [] };
|
||||||
|
const [form, setForm] = useState(leeg);
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listResource("groepen").then(setGroepen).catch(() => {});
|
||||||
|
listResource("subgroepen").then(setSubgroepen).catch(() => {});
|
||||||
|
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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();
|
||||||
|
try {
|
||||||
|
if (editId) await updateResource("leerpleinen", editId, form);
|
||||||
|
else await createResource("leerpleinen", form);
|
||||||
|
setForm(leeg); setEditId(null); await reload();
|
||||||
|
} catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
async function verwijder(id) {
|
||||||
|
if (!confirm("Dit leerplein verwijderen?")) return;
|
||||||
|
try { await deleteResource("leerpleinen", id); await reload(); } catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Leerplein</h1>
|
||||||
|
<p className="muted">Bundel groepen, subgroepen en losse leerlingen onder een overkoepelend leerplein.</p>
|
||||||
|
<Foutmelding msg={error} />
|
||||||
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
|
<div className="form-row">
|
||||||
|
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
|
||||||
|
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="leden-keuze">
|
||||||
|
<div className="muted small">Groepen:</div>
|
||||||
|
<div className="chips">
|
||||||
|
{groepen.map((g) => <button type="button" key={g.id} className={form.groepen.includes(g.id) ? "chip on" : "chip"} onClick={() => toggle("groepen", g.id)}>{g.naam}</button>)}
|
||||||
|
{groepen.length === 0 && <span className="muted small">Geen groepen.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="leden-keuze">
|
||||||
|
<div className="muted small">Subgroepen:</div>
|
||||||
|
<div className="chips">
|
||||||
|
{subgroepen.map((s) => <button type="button" key={s.id} className={form.subgroepen.includes(s.id) ? "chip on" : "chip"} onClick={() => toggle("subgroepen", s.id)}>{s.naam}</button>)}
|
||||||
|
{subgroepen.length === 0 && <span className="muted small">Geen subgroepen.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="leden-keuze">
|
||||||
|
<div className="muted small">Losse leerlingen:</div>
|
||||||
|
<div className="chips">
|
||||||
|
{leerlingen.map((l) => <button type="button" key={l.id} className={form.leerlingen.includes(l.id) ? "chip on" : "chip"} onClick={() => toggle("leerlingen", l.id)}>{l.volledige_naam}</button>)}
|
||||||
|
{leerlingen.length === 0 && <span className="muted small">Geen leerlingen.</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>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{loading ? <p>Laden…</p> : (
|
||||||
|
<table className="grid">
|
||||||
|
<thead><tr><th>Naam</th><th>Inhoud</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((lp) => (
|
||||||
|
<tr key={lp.id}>
|
||||||
|
<td>{lp.naam}{lp.omschrijving && <div className="muted small">{lp.omschrijving}</div>}</td>
|
||||||
|
<td className="small">
|
||||||
|
{lp.groepen_namen.length > 0 && <div>🗂️ {lp.groepen_namen.map((x) => x.naam).join(", ")}</div>}
|
||||||
|
{lp.subgroepen_namen.length > 0 && <div>🔹 {lp.subgroepen_namen.map((x) => x.naam).join(", ")}</div>}
|
||||||
|
{lp.leerlingen_namen.length > 0 && <div>👤 {lp.leerlingen_namen.map((x) => x.naam).join(", ")}</div>}
|
||||||
|
{lp.groepen_namen.length + lp.subgroepen_namen.length + lp.leerlingen_namen.length === 0 && <span className="muted">leeg</span>}
|
||||||
|
</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button className="link" onClick={() => { setEditId(lp.id); setForm({ naam: lp.naam, omschrijving: lp.omschrijving, groepen: lp.groepen_namen.map((x) => x.id), subgroepen: lp.subgroepen_namen.map((x) => x.id), leerlingen: lp.leerlingen_namen.map((x) => x.id) }); }}>bewerken</button>
|
||||||
|
<button className="link danger" onClick={() => verwijder(lp.id)}>verwijderen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen leerpleinen.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- Conflicten ------------------------------ */
|
||||||
|
function Conflicten({ schooljaar }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schooljaar) return;
|
||||||
|
listResource("conflicten", `?schooljaar=${schooljaar}`).then(setData).catch((e) => setError(e.message));
|
||||||
|
}, [schooljaar]);
|
||||||
|
if (!schooljaar) return <p className="muted">Kies eerst een schooljaar.</p>;
|
||||||
|
if (error) return <Foutmelding msg={error} />;
|
||||||
|
if (!data) return <p>Laden…</p>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="muted">{data.aantal === 0 ? "Geen conflicten gevonden 🎉" : `${data.fouten} fout(en) en ${data.waarschuwingen} waarschuwing(en).`}</p>
|
||||||
|
{data.conflicten.map((c, i) => (
|
||||||
|
<div className={c.ernst === "fout" ? "conflict fout" : "conflict waarschuwing"} key={i}>
|
||||||
|
<div className="conflict-kop"><strong>{c.tijdslot}</strong> <span className="tag">{c.type}</span></div>
|
||||||
|
<div>{c.detail}</div>
|
||||||
|
<div className="muted small">Blokken: {c.blokken.join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------ Datumhulpjes ------------------------------ */
|
/* ------------------------------ Datumhulpjes ------------------------------ */
|
||||||
function pad2(n) { return String(n).padStart(2, "0"); }
|
function pad2(n) { return String(n).padStart(2, "0"); }
|
||||||
function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
|
function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
|
||||||
|
|
@ -770,6 +892,7 @@ export function RoosterPage() {
|
||||||
const [uitzonderingen, setUitzonderingen] = useState([]);
|
const [uitzonderingen, setUitzonderingen] = useState([]);
|
||||||
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
|
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
|
||||||
const [toonForm, setToonForm] = useState(false);
|
const [toonForm, setToonForm] = useState(false);
|
||||||
|
const [tab, setTab] = useState("week");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
|
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
|
||||||
|
|
@ -874,7 +997,8 @@ export function RoosterPage() {
|
||||||
// Hulpfuncties voor het effectieve rooster.
|
// Hulpfuncties voor het effectieve rooster.
|
||||||
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
|
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
|
||||||
const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
|
const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
|
||||||
const persoonAfwezig = (naam, iso) => afwezig.some((a) => a.persoon_naam === naam && a.start_datum <= iso && a.eind_datum >= iso);
|
// Op id vergelijken, niet op naam: twee personen kunnen dezelfde naam hebben.
|
||||||
|
const persoonAfwezig = (id, iso) => afwezig.some((a) => a.persoon === id && a.start_datum <= iso && a.eind_datum >= iso);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -897,10 +1021,18 @@ export function RoosterPage() {
|
||||||
<span className="week-label">week van {fmtDM(maandag)}</span>
|
<span className="week-label">week van {fmtDM(maandag)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{schooljaar && <button className="btn primary" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
|
{schooljaar && tab === "week" && <button className="btn primary" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{schooljaar && toonForm && (
|
{schooljaar && (
|
||||||
|
<div className="tabbar">
|
||||||
|
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
|
||||||
|
<button className={tab === "conflicten" ? "tab on" : "tab"} onClick={() => setTab("conflicten")}>Conflicten</button>
|
||||||
|
<button className={tab === "instellingen" ? "tab on" : "tab"} onClick={() => setTab("instellingen")}>Instellingen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schooljaar && tab === "week" && toonForm && (
|
||||||
<form className="form-card" onSubmit={opslaan}>
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
|
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
|
||||||
|
|
@ -941,7 +1073,7 @@ export function RoosterPage() {
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schooljaar && (
|
{schooljaar && tab === "week" && (
|
||||||
<div className="rooster-grid">
|
<div className="rooster-grid">
|
||||||
{WEEKDAGEN.map((d, i) => {
|
{WEEKDAGEN.map((d, i) => {
|
||||||
const datum = weekDatums[i];
|
const datum = weekDatums[i];
|
||||||
|
|
@ -982,7 +1114,7 @@ export function RoosterPage() {
|
||||||
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
|
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
|
||||||
{b.begeleiders_namen.length > 0 && (
|
{b.begeleiders_namen.length > 0 && (
|
||||||
<div className="small muted">👤 {b.begeleiders_namen.map((x, idx) => (
|
<div className="small muted">👤 {b.begeleiders_namen.map((x, idx) => (
|
||||||
<span key={x.id} className={persoonAfwezig(x.naam, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}</span>
|
<span key={x.id} className={persoonAfwezig(x.id, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}</span>
|
||||||
))}</div>
|
))}</div>
|
||||||
)}
|
)}
|
||||||
{kal && <div className="small rb-status">Vervalt — {kal.type_label}</div>}
|
{kal && <div className="small rb-status">Vervalt — {kal.type_label}</div>}
|
||||||
|
|
@ -1003,6 +1135,17 @@ export function RoosterPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{schooljaar && tab === "conflicten" && <Conflicten schooljaar={schooljaar} />}
|
||||||
|
|
||||||
|
{schooljaar && tab === "instellingen" && (
|
||||||
|
<div className="instellingen">
|
||||||
|
<p className="muted">Bouwstenen van het rooster — beheer ze hier zonder het Rooster te verlaten.</p>
|
||||||
|
<TijdslotenPage />
|
||||||
|
<ActiviteitenPage />
|
||||||
|
<LocatiesPage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
|
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,25 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
||||||
|
|
||||||
.drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
|
.drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
|
||||||
|
|
||||||
|
/* --- Submenu's in de zijbalk --- */
|
||||||
|
.nav-group { margin-top: 2px; }
|
||||||
|
.nav-group-head { display: flex; justify-content: space-between; align-items: center; width: 100%; background: transparent; border: 0; cursor: pointer; padding: 10px 10px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); font-weight: 700; }
|
||||||
|
.nav-chev { font-size: 10px; }
|
||||||
|
.nav-item.nav-sub { padding-left: 26px; font-size: 13.5px; }
|
||||||
|
|
||||||
|
/* --- Tabs (o.a. in het Rooster) --- */
|
||||||
|
.tabbar { display: flex; gap: 6px; margin: 8px 0 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.tab { background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 10px 14px; font: inherit; cursor: pointer; color: var(--muted); }
|
||||||
|
.tab:hover { color: var(--ink); }
|
||||||
|
.tab.on { color: var(--brand); border-bottom-color: var(--brand); font-weight: 600; }
|
||||||
|
.instellingen > section { margin-top: 24px; padding-top: 8px; border-top: 1px dashed var(--line); }
|
||||||
|
.instellingen > section:first-of-type { border-top: 0; }
|
||||||
|
|
||||||
|
/* --- Conflicten --- */
|
||||||
|
.conflict { border: 1px solid var(--line); border-left: 4px solid #b91c1c; border-radius: 8px; padding: 10px 14px; margin-bottom: 10px; background: #fff; }
|
||||||
|
.conflict.waarschuwing { border-left-color: #b45309; }
|
||||||
|
.conflict-kop { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
|
||||||
/* Touch-vriendelijk: ruimere tikdoelen op kleinere/aanraakschermen. */
|
/* Touch-vriendelijk: ruimere tikdoelen op kleinere/aanraakschermen. */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.btn, .chip, input, select { min-height: 44px; }
|
.btn, .chip, input, select { min-height: 44px; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue