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
|
||||
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
||||
|
||||
Status: **Fase 0–3** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
|
||||
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken) en
|
||||
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster). Medewerkers kunnen
|
||||
meerdere functies hebben uit een beheerbare functie-catalogus. Het Rooster-scherm
|
||||
is een **weekweergave met drag-and-drop** (muis én touch) en een
|
||||
**touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen worden
|
||||
automatisch in het rooster verwerkt.
|
||||
Status: **Fase 0–4** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
|
||||
groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken),
|
||||
Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster) en Fase 4
|
||||
(**conflictdetectie**: dubbele inzet van begeleider/leerling en locatiebotsingen).
|
||||
|
||||
Het Rooster-scherm is een **weekweergave met drag-and-drop** (muis én touch) en
|
||||
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
|
||||
|
||||
|
|
@ -63,6 +71,9 @@ API-eindpunten:
|
|||
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`),
|
||||
`/api/afwezigheden/` (filters `?persoon=`, `?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).
|
||||
|
||||
> 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
|
||||
aan/uit te zetten.
|
||||
|
||||
## Volgende stap (Fase 4)
|
||||
## Volgende stap (Fase 5 & 6)
|
||||
|
||||
Conflictdetectie: automatisch signaleren van dubbele inzet van leerkrachten,
|
||||
ondersteuners of leerlingen op hetzelfde moment, en locatiebotsingen.
|
||||
De losse modules verder uitbouwen: **Printen/Exporteren** (roosters als PDF) en
|
||||
**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 = [
|
||||
"modules.printing", # Printen/Exporteren
|
||||
"modules.accounts", # Multi-user & toegang
|
||||
"modules.leerplein", # Leerplein (groepsstructuur)
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
|
||||
|
|
@ -177,6 +178,9 @@ REST_FRAMEWORK = {
|
|||
"anon": "20/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.
|
||||
|
|
|
|||
|
|
@ -8,4 +8,6 @@ urlpatterns = [
|
|||
path("api/", include("plugins.urls")),
|
||||
# Kern-API.
|
||||
path("api/", include("core.urls")),
|
||||
# Module-API's.
|
||||
path("api/", include("modules.leerplein.urls")),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -27,16 +27,16 @@ class CoreConfig(AppConfig):
|
|||
category="Kern",
|
||||
core=True,
|
||||
menu_items=(
|
||||
# Hoofdniveau
|
||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
||||
MenuItem("Personen", "/personen", icon="users", order=20),
|
||||
MenuItem("Functies", "/functies", icon="badge", order=25),
|
||||
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
||||
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
||||
MenuItem("Activiteiten", "/activiteiten", icon="book", order=50),
|
||||
MenuItem("Locaties", "/locaties", icon="pin", order=55),
|
||||
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
|
||||
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
|
||||
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=80),
|
||||
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=15),
|
||||
# Submenu Personen
|
||||
MenuItem("Personen", "/personen", icon="users", order=20, group="Personen"),
|
||||
MenuItem("Functies", "/functies", icon="badge", order=25, group="Personen"),
|
||||
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Personen"),
|
||||
# Submenu Groepen
|
||||
MenuItem("Groepen", "/groepen", icon="layers", order=30, group="Groepen"),
|
||||
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40, group="Groepen"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,13 @@ class SchooljaarSerializer(serializers.ModelSerializer):
|
|||
model = Schooljaar
|
||||
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):
|
||||
type_label = serializers.CharField(source="get_type_display", read_only=True)
|
||||
|
|
@ -101,8 +108,20 @@ class TijdslotSerializer(serializers.ModelSerializer):
|
|||
model = Tijdslot
|
||||
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):
|
||||
kleur = serializers.RegexField(
|
||||
regex=r"^#[0-9a-fA-F]{6}$",
|
||||
error_messages={"invalid": "Kleur moet een hex-code zijn, bv. #2563eb."},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Activiteit
|
||||
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
|
||||
|
|
@ -156,6 +175,13 @@ class RoosterblokSerializer(serializers.ModelSerializer):
|
|||
raise serializers.ValidationError(
|
||||
"Kies precies één doelgroep: groep, subgroep of leerling."
|
||||
)
|
||||
# Begeleiders zijn medewerkers; een leerling kan geen begeleider zijn.
|
||||
# (Het Roosterblok-model kent geen rolbeperking, dus hier afdwingen.)
|
||||
for p in data.get("begeleiders", []):
|
||||
if p.rol == "leerling":
|
||||
raise serializers.ValidationError(
|
||||
f"'{p.volledige_naam}' is een leerling en kan geen begeleider zijn."
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -190,3 +216,10 @@ class BlokUitzonderingSerializer(serializers.ModelSerializer):
|
|||
"id", "roosterblok", "datum", "vervalt",
|
||||
"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 = [
|
||||
path("health/", views.health, name="health"),
|
||||
path("info/", views.info, name="info"),
|
||||
path("conflicten/", views.conflicten, name="conflicten"),
|
||||
path("auth/me/", views.auth_me, name="auth-me"),
|
||||
path("auth/login/", views.auth_login_view, name="auth-login"),
|
||||
path("auth/logout/", views.auth_logout_view, name="auth-logout"),
|
||||
|
|
|
|||
|
|
@ -208,3 +208,116 @@ class BlokUitzonderingViewSet(viewsets.ModelViewSet):
|
|||
if schooljaar:
|
||||
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
|
||||
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)
|
||||
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
|
||||
path: str
|
||||
icon: str = "puzzle"
|
||||
order: int = 100
|
||||
group: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ def describe_all() -> list[dict]:
|
|||
"path": m.path,
|
||||
"icon": m.icon,
|
||||
"order": m.order,
|
||||
"group": m.group,
|
||||
}
|
||||
for m in spec.menu_items
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Logo from "./Logo.jsx";
|
|||
import {
|
||||
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
||||
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
|
||||
AfwezigheidPage,
|
||||
AfwezigheidPage, LeerpleinPage,
|
||||
} from "./pages.jsx";
|
||||
|
||||
const ICONS = {
|
||||
|
|
@ -26,6 +26,7 @@ const PAGES = {
|
|||
"/tijdsloten": TijdslotenPage,
|
||||
"/schooljaar": SchooljaarPage,
|
||||
"/afwezigheid": AfwezigheidPage,
|
||||
"/leerplein": LeerpleinPage,
|
||||
};
|
||||
|
||||
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.
|
||||
const menu = useMemo(() => {
|
||||
// Items met een `group` komen onder een inklapbaar submenu.
|
||||
const { topItems, groups, alleItems } = useMemo(() => {
|
||||
const items = [];
|
||||
for (const m of modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
||||
}
|
||||
// Modulebeheer is een beheerdersactie.
|
||||
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]);
|
||||
|
||||
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];
|
||||
|
||||
return (
|
||||
|
|
@ -153,16 +174,19 @@ function Shell({ user, onLogout }) {
|
|||
<span className="brand-name">Roosterwijs</span>
|
||||
</div>
|
||||
<nav>
|
||||
{menu.map((it) => (
|
||||
<button
|
||||
key={it.path}
|
||||
className={active === it.path ? "nav-item active" : "nav-item"}
|
||||
onClick={() => setActive(it.path)}
|
||||
>
|
||||
<span className="ico">{ICONS[it.icon] || "•"}</span>
|
||||
{it.label}
|
||||
{topItems.map((it) => <NavKnop key={it.path} it={it} />)}
|
||||
{Object.entries(groups).map(([naam, kinderen]) => {
|
||||
const open = openGroups[naam] !== false;
|
||||
return (
|
||||
<div className="nav-group" key={naam}>
|
||||
<button className="nav-group-head" onClick={() => setOpenGroups((o) => ({ ...o, [naam]: !open }))}>
|
||||
<span>{naam}</span>
|
||||
<span className="nav-chev">{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
))}
|
||||
{open && kinderen.map((it) => <NavKnop key={it.path} it={it} indent />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-user">
|
||||
<span className="muted small">👤 {user.username}</span>
|
||||
|
|
@ -180,7 +204,7 @@ function Shell({ user, onLogout }) {
|
|||
) : Page ? (
|
||||
<Page />
|
||||
) : (
|
||||
<Placeholder path={active} menu={menu} />
|
||||
<Placeholder path={active} menu={alleItems} />
|
||||
)}
|
||||
</main>
|
||||
</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 ------------------------------ */
|
||||
function pad2(n) { return String(n).padStart(2, "0"); }
|
||||
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 [maandag, setMaandag] = useState(() => maandagVan(new Date()));
|
||||
const [toonForm, setToonForm] = useState(false);
|
||||
const [tab, setTab] = useState("week");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
|
||||
|
|
@ -874,7 +997,8 @@ export function RoosterPage() {
|
|||
// Hulpfuncties voor het effectieve rooster.
|
||||
const kalenderOp = (iso) => kalender.find((k) => k.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 (
|
||||
<section>
|
||||
|
|
@ -897,10 +1021,18 @@ export function RoosterPage() {
|
|||
<span className="week-label">week van {fmtDM(maandag)}</span>
|
||||
</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>
|
||||
|
||||
{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}>
|
||||
<div className="form-row">
|
||||
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
|
||||
|
|
@ -941,7 +1073,7 @@ export function RoosterPage() {
|
|||
</form>
|
||||
)}
|
||||
|
||||
{schooljaar && (
|
||||
{schooljaar && tab === "week" && (
|
||||
<div className="rooster-grid">
|
||||
{WEEKDAGEN.map((d, i) => {
|
||||
const datum = weekDatums[i];
|
||||
|
|
@ -982,7 +1114,7 @@ export function RoosterPage() {
|
|||
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
|
||||
{b.begeleiders_namen.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{kal && <div className="small rb-status">Vervalt — {kal.type_label}</div>}
|
||||
|
|
@ -1003,6 +1135,17 @@ export function RoosterPage() {
|
|||
</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>}
|
||||
</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); }
|
||||
|
||||
/* --- 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. */
|
||||
@media (pointer: coarse) {
|
||||
.btn, .chip, input, select { min-height: 44px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue