Fase 2 - Kerndomein, security-audit en configwijzigingen

This commit is contained in:
bes-r 2026-06-10 15:05:39 +02:00
parent f3373a9c2d
commit 4fcc169c7c
22 changed files with 1759 additions and 295 deletions

View file

@ -17,6 +17,10 @@ DJANGO_ALLOWED_HOSTS=rooster-test.example.nl,localhost,127.0.0.1
# Volledige origin(s) inclusief http(s):// voor CSRF achter nginx. # Volledige origin(s) inclusief http(s):// voor CSRF achter nginx.
DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080 DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080
# Zet op 1 zodra de server achter HTTPS draait (TLS-certificaat aanwezig).
# Activeert o.a. veilige cookies, HSTS en een redirect naar https.
DJANGO_SECURE=0
# --- PostgreSQL ----------------------------------------------------------- # --- PostgreSQL -----------------------------------------------------------
POSTGRES_DB=rooster POSTGRES_DB=rooster
POSTGRES_USER=rooster POSTGRES_USER=rooster

View file

@ -88,7 +88,12 @@ Migraties draaien automatisch mee bij het opstarten van de backend.
- **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie - **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie
zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of
je breidt de nginx-config uit met certificaten. De nginx-container is daar al je breidt de nginx-config uit met certificaten. De nginx-container is daar al
het logische punt voor. het logische punt voor. **Zet daarna `DJANGO_SECURE=1` in `.env`** — dat
activeert veilige cookies, HSTS en een https-redirect in Django.
- **Inloggen verplicht:** de hele API vereist een ingelogde gebruiker; de
frontend toont eerst een inlogscherm. Maak gebruikers aan via
`createsuperuser` (zie boven) of in de Django-admin. Modulebeheer is
voorbehouden aan beheerders (`is_staff`).
- **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups, - **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups,
bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`. bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`.
- **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie. - **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie.

View file

@ -4,8 +4,10 @@ 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** (fundament + plugin-framework) en **Fase 1** (kerndomein: Status: **Fase 0** (fundament + plugin-framework), **Fase 1** (kerndomein:
personen, groepen, subgroepen) zijn klaar. personen, groepen, subgroepen, functies) en **Fase 2** (schooljaarkalender +
roosterblokken) zijn klaar. Medewerkers kunnen meerdere functies hebben uit een
beheerbare functie-catalogus.
## Wat er nu staat ## Wat er nu staat
@ -50,8 +52,12 @@ API-eindpunten:
- `GET /api/modules/` — alle modules met status. - `GET /api/modules/` — alle modules met status.
- `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`. - `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`.
- `GET /api/info/` — korte systeem-/gezondheidsinfo. - `GET /api/info/` — korte systeem-/gezondheidsinfo.
- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD - CRUD-endpoints (telkens met `PATCH`/`DELETE` op `/<id>/`):
(plus `PATCH`/`DELETE` op `/<id>/`). Personen filteren kan met `?rol=leerling`. `/api/personen/` (filter `?rol=leerling`), `/api/groepen/`,
`/api/subgroepen/`, `/api/functies/`, `/api/schooljaren/`,
`/api/kalenderdagen/` (filter `?schooljaar=<id>`), `/api/tijdsloten/`,
`/api/activiteiten/`, `/api/locaties/`, `/api/roosterblokken/`
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`).
- `/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
@ -69,8 +75,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 2) ## Volgende stap (Fase 3 & 4)
Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties) Flexibiliteit & uitzonderingen (individuele afwijkingen, afwezigheid/vervanging,
en roosterblokken die je toewijst aan een groep, subgroep of individuele vrije dagen die blokken automatisch uitschakelen) en conflictdetectie
leerling, met begeleiders en locatie. (dubbele inzet van personen/leerlingen, locatiebotsingen).

138
SECURITY-AUDIT.md Normal file
View file

@ -0,0 +1,138 @@
# Security & Bug Audit — Roosterwijs
Date: 2026-06-09 · Scope: backend (Django), frontend (React), Docker/nginx deployment.
Verdict: code quality is good and the architecture is clean, but the system is **not safe to expose on a network yet**. One critical gap (no authentication) and two high-risk items must be fixed before the test server holds real student data.
> **STATUS 2026-06-10: all findings below are FIXED** (verified with 13 functional tests + frontend build). Remaining for you: (1) run `npm install` once in `frontend/` and commit `package-lock.json` so Docker switches to `npm ci`; (2) set `DJANGO_SECURE=1` in `.env` once the server runs behind HTTPS. Note #2 (TLS itself) still requires a certificate/reverse proxy on the server.
---
## CRITICAL
### 1. The entire API is unauthenticated
`REST_FRAMEWORK` has no `DEFAULT_PERMISSION_CLASSES`, so DRF defaults to `AllowAny`. Anyone who can reach port 8080 can, anonymously:
- read, create, edit and **delete all persons** — including student names and the free-text `opmerkingen` field (sensitive special-education data → AVG/GDPR);
- delete groups and subgroups;
- enable/disable modules via `POST /api/modules/<key>/state/`.
**Fix:**
```python
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
...
}
```
Then add a login flow to the React app and send the CSRF token (see Low #11 — these must land together, otherwise all writes break). This naturally belongs to the `accounts` module, but a minimal session login must protect the API **before** that module is fully built — security cannot be an optional plugin.
---
## HIGH
### 2. No TLS anywhere; admin login travels in plain text
`docker-compose.yml` exposes plain HTTP on 8080 and `.env.example` shows `http://` CSRF origins. Django admin passwords and all student data cross the network unencrypted.
**Fix:** terminate TLS (reverse proxy or certs in nginx), then enable in `settings.py`, driven by an env flag:
```python
if _env_bool("DJANGO_SECURE", not DEBUG):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
```
### 3. SECRET_KEY silently falls back to a known dev key
If `DJANGO_SECRET_KEY` is missing in production, the app runs with `"dev-only-change-me-in-productie"` — sessions and password-reset tokens become forgeable, with no warning.
**Fix:** fail hard:
```python
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if _env_bool("DJANGO_DEBUG", False):
SECRET_KEY = "dev-only-change-me-in-productie"
else:
raise RuntimeError("DJANGO_SECRET_KEY ontbreekt; weiger te starten zonder geheime sleutel.")
```
(Note: `DEBUG` is read after this block today — reorder accordingly.)
---
## MEDIUM
### 4. Browsable API enabled in production
`BrowsableAPIRenderer` gives visitors a friendly UI to explore and submit writes. Gate it: include it in `DEFAULT_RENDERER_CLASSES` only when `DEBUG` is true.
### 5. Bug — module toggle truthiness
`plugins/views.py`: `enabled = bool(request.data.get("enabled", False))`. A form-encoded or string payload `"false"` becomes `True`. Parse explicitly:
```python
enabled = request.data.get("enabled")
if not isinstance(enabled, bool):
return Response({"detail": "enabled moet true of false zijn."}, status=400)
```
### 6. Bug — role change leaves stale subgroup membership
`Persoon.save()` clears `groep` when the rol is no longer *leerling*, but M2M `subgroepen` membership stays (`limit_choices_to` only affects admin forms, not the API). A teacher can remain "lid" of a niveaugroepje.
**Fix:** in `Persoon.save()` (after `super().save()`): `if self.rol != Rol.LEERLING: self.subgroepen.clear()` — or validate in `PersoonSerializer.update()`.
### 7. No rate limiting
No DRF throttling and no nginx `limit_req`. Add `DEFAULT_THROTTLE_CLASSES`/`RATES` (e.g. `"anon": "20/min"`, `"user": "200/min"`) — cheap insurance once the API is internet-reachable.
### 8. Backend container runs as root
Add to the Dockerfile after `COPY . .`:
```dockerfile
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser
```
(Static volume permissions: run `collectstatic` before switching user, or chown the volume in entrypoint.)
---
## LOW
### 9. Race in `sync_states()`
Three gunicorn workers can pass the `key not in existing` check simultaneously → `IntegrityError` on the unique key. Use `ModuleState.objects.get_or_create(key=spec.key, defaults={"enabled": spec.default_enabled})`.
### 10. Missing security headers on the SPA
Django adds `X-Frame-Options`/`nosniff` only to `/api` and `/admin`. Add to the nginx `location /` block: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, and later a CSP.
### 11. Frontend sends no CSRF token
`api.js` never sends `X-CSRFToken`. Harmless today (anonymous = no CSRF check), but the moment Fix #1 lands, every POST/PATCH/DELETE will 403. Read the `csrftoken` cookie and set the header in `request()`. Ship together with #1.
### 12. `npm install` in Docker build
Use `npm ci` with a committed `package-lock.json` for reproducible, tamper-evident builds.
### 13. No logging / audit trail
No `LOGGING` config; with student data you want a record of logins and destructive actions. Minimal console logging config now; an audit-log module fits the plugin architecture later.
---
## Verified non-issues
- `.gitignore` and `backend/.dockerignore` correctly exclude `.env`, `db.sqlite3` (image and repo stay clean).
- CORS is locked to localhost dev origins; production traffic is same-origin via nginx — correct.
- `ALLOWED_HOSTS` from env with safe defaults; nginx forwards `Host` so host-header spoofing is caught.
- Dependency-aware module enable/disable logic in `plugins/services.py` is sound (checked both directions).
- Postgres is not port-mapped to the host; only nginx is exposed.
- Strong password validators active; `DEBUG` defaults to off.
## Recommended order of work
1. #1 + #11 together (auth + CSRF in frontend) — blocks everything else being meaningful.
2. #3 (fail hard on missing SECRET_KEY) — one-file change, do immediately.
3. #2 (TLS + secure cookies) when the test server gets a hostname.
4. #5, #6, #9 (bugs) — small, independent fixes.
5. #4, #7, #8, #10, #12, #13 as hardening follow-ups.

View file

@ -21,6 +21,13 @@ COPY . .
# entrypoint wacht op de database, migreert en verzamelt statics. # entrypoint wacht op de database, migreert en verzamelt statics.
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Draai niet als root: een eventueel lek in de app geeft dan geen
# root-rechten in de container.
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
&& mkdir -p /app/staticfiles \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
# 3 workers is ruim voldoende voor de kleine aantallen in het SO. # 3 workers is ruim voldoende voor de kleine aantallen in het SO.

View file

@ -19,12 +19,23 @@ def _env_bool(name, default):
return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on") return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
# Dev-default alleen voor lokaal werken; ZET DJANGO_SECRET_KEY in productie.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me-in-productie")
# Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1. # Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1.
DEBUG = _env_bool("DJANGO_DEBUG", False) DEBUG = _env_bool("DJANGO_DEBUG", False)
# Geheime sleutel: VERPLICHT via omgeving zodra DEBUG uit staat.
# Een stille terugval op een bekende dev-sleutel zou sessies en tokens
# vervalsbaar maken; daarom weigert de app dan te starten.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "dev-only-change-me-in-productie"
else:
raise RuntimeError(
"DJANGO_SECRET_KEY ontbreekt. Genereer er een met "
"'python -c \"import secrets; print(secrets.token_urlsafe(50))\"' "
"en zet hem in het .env-bestand."
)
# Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost". # Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost".
ALLOWED_HOSTS = [h.strip() for h in os.environ.get( ALLOWED_HOSTS = [h.strip() for h in os.environ.get(
"DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1" "DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1"
@ -139,11 +150,33 @@ STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- API ------------------------------------------------------------------ # --- API ------------------------------------------------------------------
# Veiligheid eerst: alles vereist standaard een ingelogde gebruiker.
# Uitzonderingen (zoals het login-endpoint) krijgen expliciet AllowAny.
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
# JSON altijd; de browsbare API alleen tijdens ontwikkelen.
"DEFAULT_RENDERER_CLASSES": (
[
"rest_framework.renderers.JSONRenderer", "rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer", "rest_framework.renderers.BrowsableAPIRenderer",
]
if DEBUG
else ["rest_framework.renderers.JSONRenderer"]
),
# Eenvoudige rem op misbruik (bv. wachtwoord-raden op het login-endpoint).
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
], ],
"DEFAULT_THROTTLE_RATES": {
"anon": "20/min",
"user": "300/min",
},
} }
# Frontend (React dev-server) mag de API benaderen. # Frontend (React dev-server) mag de API benaderen.
@ -151,3 +184,39 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", "http://localhost:5173",
"http://127.0.0.1:5173", "http://127.0.0.1:5173",
] ]
CORS_ALLOW_CREDENTIALS = True
# --- HTTPS-hardening --------------------------------------------------------
# Zet DJANGO_SECURE=1 zodra de server achter TLS draait (zie DEPLOY.md).
# Staat los van DEBUG omdat de test-server nu nog op http://...:8080 draait.
if _env_bool("DJANGO_SECURE", False):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 60 * 60 * 24 * 365
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
# Sessie-cookies nooit leesbaar voor JavaScript; de CSRF-cookie moet juist
# wél leesbaar zijn (de frontend stuurt hem terug in de X-CSRFToken-header).
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_HTTPONLY = False
# --- Logging ----------------------------------------------------------------
# Console-logging zodat 'docker compose logs' een bruikbaar spoor geeft,
# o.a. van mislukte logins en serverfouten.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {"format": "{levelname} {asctime} {name} {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "simple"},
},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {
"django.request": {"level": "WARNING"},
"roosterwijs": {"level": "INFO"},
},
}

View file

@ -1,6 +1,16 @@
from django.contrib import admin from django.contrib import admin
from .models import Groep, Persoon, Subgroep from .models import (
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
Roosterblok, Schooljaar, Subgroep, Tijdslot,
)
@admin.register(Functie)
class FunctieAdmin(admin.ModelAdmin):
list_display = ("naam", "omschrijving", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
@admin.register(Groep) @admin.register(Groep)
@ -13,9 +23,10 @@ class GroepAdmin(admin.ModelAdmin):
@admin.register(Persoon) @admin.register(Persoon)
class PersoonAdmin(admin.ModelAdmin): class PersoonAdmin(admin.ModelAdmin):
list_display = ("achternaam", "voornaam", "rol", "groep", "actief") list_display = ("achternaam", "voornaam", "rol", "groep", "actief")
list_filter = ("rol", "actief", "groep") list_filter = ("rol", "actief", "groep", "functies")
search_fields = ("voornaam", "achternaam") search_fields = ("voornaam", "achternaam")
autocomplete_fields = ("groep",) autocomplete_fields = ("groep",)
filter_horizontal = ("functies",)
@admin.register(Subgroep) @admin.register(Subgroep)
@ -24,3 +35,44 @@ class SubgroepAdmin(admin.ModelAdmin):
list_filter = ("actief",) list_filter = ("actief",)
search_fields = ("naam",) search_fields = ("naam",)
filter_horizontal = ("leden",) filter_horizontal = ("leden",)
@admin.register(Schooljaar)
class SchooljaarAdmin(admin.ModelAdmin):
list_display = ("naam", "start_datum", "eind_datum", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
@admin.register(KalenderDag)
class KalenderDagAdmin(admin.ModelAdmin):
list_display = ("datum", "type", "omschrijving", "schooljaar")
list_filter = ("type", "schooljaar")
date_hierarchy = "datum"
@admin.register(Tijdslot)
class TijdslotAdmin(admin.ModelAdmin):
list_display = ("naam", "dag", "start_tijd", "eind_tijd", "volgorde", "actief")
list_filter = ("dag", "actief")
@admin.register(Activiteit)
class ActiviteitAdmin(admin.ModelAdmin):
list_display = ("naam", "kleur", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
@admin.register(Locatie)
class LocatieAdmin(admin.ModelAdmin):
list_display = ("naam", "omschrijving", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
@admin.register(Roosterblok)
class RoosterblokAdmin(admin.ModelAdmin):
list_display = ("activiteit", "tijdslot", "doel_type", "locatie", "schooljaar")
list_filter = ("schooljaar", "tijdslot__dag", "activiteit")
filter_horizontal = ("begeleiders",)

View file

@ -29,8 +29,13 @@ class CoreConfig(AppConfig):
menu_items=( menu_items=(
MenuItem("Rooster", "/rooster", icon="calendar", order=10), MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Personen", "/personen", icon="users", order=20), MenuItem("Personen", "/personen", icon="users", order=20),
MenuItem("Functies", "/functies", icon="badge", order=25),
MenuItem("Groepen", "/groepen", icon="layers", order=30), MenuItem("Groepen", "/groepen", icon="layers", order=30),
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40), 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),
), ),
) )
) )

View file

@ -0,0 +1,132 @@
# Generated by Django 5.2.15 on 2026-06-10 12:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Activiteit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')),
('kleur', models.CharField(default='#2563eb', help_text='hex, bv. #2563eb', max_length=7, verbose_name='kleur')),
('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
],
options={
'verbose_name': 'activiteit',
'verbose_name_plural': 'activiteiten',
'ordering': ['naam'],
},
),
migrations.CreateModel(
name='Functie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=80, 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)),
],
options={
'verbose_name': 'functie',
'verbose_name_plural': 'functies',
'ordering': ['naam'],
},
),
migrations.CreateModel(
name='Locatie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=80, unique=True, verbose_name='naam')),
('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
],
options={
'verbose_name': 'locatie',
'verbose_name_plural': 'locaties',
'ordering': ['naam'],
},
),
migrations.CreateModel(
name='Schooljaar',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(help_text='bv. 2026-2027', max_length=40, unique=True, verbose_name='naam')),
('start_datum', models.DateField(verbose_name='startdatum')),
('eind_datum', models.DateField(verbose_name='einddatum')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
],
options={
'verbose_name': 'schooljaar',
'verbose_name_plural': 'schooljaren',
'ordering': ['-start_datum'],
},
),
migrations.CreateModel(
name='Tijdslot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=60, verbose_name='naam')),
('dag', models.IntegerField(choices=[(0, 'Maandag'), (1, 'Dinsdag'), (2, 'Woensdag'), (3, 'Donderdag'), (4, 'Vrijdag')], verbose_name='weekdag')),
('start_tijd', models.TimeField(verbose_name='starttijd')),
('eind_tijd', models.TimeField(verbose_name='eindtijd')),
('volgorde', models.PositiveSmallIntegerField(default=0, verbose_name='volgorde')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
],
options={
'verbose_name': 'tijdslot',
'verbose_name_plural': 'tijdsloten',
'ordering': ['dag', 'volgorde', 'start_tijd'],
},
),
migrations.AddField(
model_name='persoon',
name='functies',
field=models.ManyToManyField(blank=True, help_text='Alleen van toepassing op medewerkers.', related_name='personen', to='core.functie', verbose_name='functies'),
),
migrations.CreateModel(
name='Roosterblok',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('opmerkingen', models.CharField(blank=True, max_length=255, verbose_name='opmerkingen')),
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
('activiteit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roosterblokken', to='core.activiteit')),
('begeleiders', models.ManyToManyField(blank=True, related_name='begeleider_roosterblokken', to='core.persoon')),
('groep', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.groep')),
('leerling', models.ForeignKey(blank=True, limit_choices_to={'rol': 'leerling'}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='leerling_roosterblokken', to='core.persoon')),
('locatie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roosterblokken', to='core.locatie')),
('subgroep', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.subgroep')),
('schooljaar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roosterblokken', to='core.schooljaar')),
('tijdslot', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='roosterblokken', to='core.tijdslot')),
],
options={
'verbose_name': 'roosterblok',
'verbose_name_plural': 'roosterblokken',
'ordering': ['tijdslot__dag', 'tijdslot__volgorde'],
},
),
migrations.CreateModel(
name='KalenderDag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datum', models.DateField(verbose_name='datum')),
('type', models.CharField(choices=[('studiedag', 'Studiedag'), ('vrije_dag', 'Vrije dag'), ('vakantie', 'Vakantie')], max_length=16, verbose_name='type')),
('omschrijving', models.CharField(blank=True, max_length=120, verbose_name='omschrijving')),
('schooljaar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kalenderdagen', to='core.schooljaar')),
],
options={
'verbose_name': 'kalenderdag',
'verbose_name_plural': 'kalenderdagen',
'ordering': ['datum'],
'constraints': [models.UniqueConstraint(fields=('schooljaar', 'datum'), name='uniek_schooljaar_datum')],
},
),
]

View file

@ -1,13 +1,17 @@
""" """
Kerndomein van Roosterwijs (Fase 1). Kerndomein van Roosterwijs.
Bewust klein gehouden, met veel flexibiliteit voor het speciaal onderwijs: Fase 1: Persoon, Groep, Subgroep, Functie (catalogus).
- één Persoon-tabel met een rol (leerling, leerkracht, ondersteuner); Fase 2: Schooljaar + KalenderDag (jaaroverzicht), Tijdslot, Activiteit,
- leerlingen horen bij een groep; Locatie en Roosterblok (de spil van het rooster).
- subgroepen zijn vrije, groep-overstijgende verzamelingen leerlingen.
Roosterblokken, tijdslots en de kalender volgen in Fase 2. Veel flexibiliteit voor het speciaal onderwijs:
- één Persoon-tabel met een rol (leerling of medewerker-rol);
- medewerkers kunnen meerdere FUNCTIES hebben uit een beheerbare catalogus;
- roosterblokken zijn toewijsbaar aan een groep, subgroep OF een individuele
leerling, met één of meer begeleiders.
""" """
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -18,6 +22,27 @@ class Rol(models.TextChoices):
KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner" KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner"
class Functie(models.Model):
"""Een beheerbare functie/rol voor medewerkers (bv. Directie, Logopedist).
Dit is een vrije catalogus: een beheerder maakt functies aan en verwijdert
ze. Een medewerker kan meerdere functies hebben (zie Persoon.functies).
"""
naam = models.CharField("naam", max_length=80, unique=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 = "functie"
verbose_name_plural = "functies"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class Groep(models.Model): class Groep(models.Model):
"""Een klas/groep waar leerlingen bij horen.""" """Een klas/groep waar leerlingen bij horen."""
@ -36,7 +61,11 @@ class Groep(models.Model):
class Persoon(models.Model): class Persoon(models.Model):
"""Leerling, leerkracht of ondersteuner - een tabel met een rol.""" """Leerling of medewerker - een tabel met een rol.
Leerlingen horen bij een groep. Medewerkers (niet-leerlingen) kunnen
meerdere functies hebben uit de Functie-catalogus.
"""
voornaam = models.CharField("voornaam", max_length=80) voornaam = models.CharField("voornaam", max_length=80)
achternaam = models.CharField("achternaam", max_length=120) achternaam = models.CharField("achternaam", max_length=120)
@ -50,6 +79,13 @@ class Persoon(models.Model):
related_name="leerlingen", related_name="leerlingen",
help_text="Alleen van toepassing op leerlingen.", help_text="Alleen van toepassing op leerlingen.",
) )
functies = models.ManyToManyField(
Functie,
verbose_name="functies",
related_name="personen",
blank=True,
help_text="Alleen van toepassing op medewerkers.",
)
actief = models.BooleanField("actief", default=True) actief = models.BooleanField("actief", default=True)
opmerkingen = models.TextField("opmerkingen", blank=True) opmerkingen = models.TextField("opmerkingen", blank=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True) aangemaakt_op = models.DateTimeField(auto_now_add=True)
@ -75,6 +111,14 @@ class Persoon(models.Model):
if self.rol != Rol.LEERLING: if self.rol != Rol.LEERLING:
self.groep = None self.groep = None
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.pk:
# Wie geen leerling (meer) is, hoort niet in subgroepen thuis.
# (limit_choices_to geldt alleen voor formulieren, niet voor de API.)
if self.rol != Rol.LEERLING:
self.subgroepen.clear()
else:
# Een leerling heeft geen medewerkersfuncties.
self.functies.clear()
class Subgroep(models.Model): class Subgroep(models.Model):
@ -102,3 +146,191 @@ class Subgroep(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.naam return self.naam
# --------------------------------------------------------------------------
# Fase 2: schooljaar/kalender en roosterblokken
# --------------------------------------------------------------------------
class Schooljaar(models.Model):
"""Een schooljaar met begin- en einddatum; bevat de kalender."""
naam = models.CharField("naam", max_length=40, unique=True, help_text="bv. 2026-2027")
start_datum = models.DateField("startdatum")
eind_datum = models.DateField("einddatum")
actief = models.BooleanField("actief", default=True)
class Meta:
verbose_name = "schooljaar"
verbose_name_plural = "schooljaren"
ordering = ["-start_datum"]
def __str__(self) -> str:
return self.naam
class DagType(models.TextChoices):
STUDIEDAG = "studiedag", "Studiedag"
VRIJE_DAG = "vrije_dag", "Vrije dag"
VAKANTIE = "vakantie", "Vakantie"
class KalenderDag(models.Model):
"""Een bijzondere dag in het schooljaar: studiedag, vrije dag of vakantie.
Op zulke dagen vervallen de roosterblokken (geen les).
"""
schooljaar = models.ForeignKey(
Schooljaar, on_delete=models.CASCADE, related_name="kalenderdagen"
)
datum = models.DateField("datum")
type = models.CharField("type", max_length=16, choices=DagType.choices)
omschrijving = models.CharField("omschrijving", max_length=120, blank=True)
class Meta:
verbose_name = "kalenderdag"
verbose_name_plural = "kalenderdagen"
ordering = ["datum"]
constraints = [
models.UniqueConstraint(
fields=["schooljaar", "datum"], name="uniek_schooljaar_datum"
)
]
def __str__(self) -> str:
return f"{self.datum} ({self.get_type_display()})"
@property
def is_lesdag(self) -> bool:
# Alle drie de types betekenen 'geen les'.
return False
class Weekdag(models.IntegerChoices):
MAANDAG = 0, "Maandag"
DINSDAG = 1, "Dinsdag"
WOENSDAG = 2, "Woensdag"
DONDERDAG = 3, "Donderdag"
VRIJDAG = 4, "Vrijdag"
class Tijdslot(models.Model):
"""Een vast tijdblok op een vaste schooldag (bv. 'Blok 1' ma 08:30-10:00)."""
naam = models.CharField("naam", max_length=60)
dag = models.IntegerField("weekdag", choices=Weekdag.choices)
start_tijd = models.TimeField("starttijd")
eind_tijd = models.TimeField("eindtijd")
volgorde = models.PositiveSmallIntegerField("volgorde", default=0)
actief = models.BooleanField("actief", default=True)
class Meta:
verbose_name = "tijdslot"
verbose_name_plural = "tijdsloten"
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}"
class Activiteit(models.Model):
"""Wat er gedaan wordt: rekenen, gym, therapie, pauze ..."""
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)
actief = models.BooleanField("actief", default=True)
class Meta:
verbose_name = "activiteit"
verbose_name_plural = "activiteiten"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class Locatie(models.Model):
"""Een lokaal/ruimte (handig voor conflictdetectie in fase 4)."""
naam = models.CharField("naam", max_length=80, unique=True)
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
actief = models.BooleanField("actief", default=True)
class Meta:
verbose_name = "locatie"
verbose_name_plural = "locaties"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class Roosterblok(models.Model):
"""De spil: wie doet wat, wanneer, waar, met wie.
De doelgroep is precies één van: groep, subgroep of individuele leerling.
Zo kun je een groepsrooster maken én er individueel van afwijken.
"""
schooljaar = models.ForeignKey(
Schooljaar, on_delete=models.CASCADE, related_name="roosterblokken"
)
tijdslot = models.ForeignKey(
Tijdslot, on_delete=models.PROTECT, related_name="roosterblokken"
)
activiteit = models.ForeignKey(
Activiteit, on_delete=models.PROTECT, related_name="roosterblokken"
)
locatie = models.ForeignKey(
Locatie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="roosterblokken",
)
# Doelgroep: precies één van de drie is gevuld.
groep = models.ForeignKey(
Groep, on_delete=models.CASCADE, null=True, blank=True,
related_name="roosterblokken",
)
subgroep = models.ForeignKey(
Subgroep, on_delete=models.CASCADE, null=True, blank=True,
related_name="roosterblokken",
)
leerling = models.ForeignKey(
Persoon, on_delete=models.CASCADE, null=True, blank=True,
related_name="leerling_roosterblokken",
limit_choices_to={"rol": Rol.LEERLING},
)
begeleiders = models.ManyToManyField(
Persoon, related_name="begeleider_roosterblokken", blank=True,
)
opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "roosterblok"
verbose_name_plural = "roosterblokken"
ordering = ["tijdslot__dag", "tijdslot__volgorde"]
def clean(self):
doelen = [self.groep_id, self.subgroep_id, self.leerling_id]
gevuld = [d for d in doelen if d]
if len(gevuld) != 1:
raise ValidationError(
"Kies precies één doelgroep: groep, subgroep of leerling."
)
@property
def doel_type(self) -> str:
if self.groep_id:
return "groep"
if self.subgroep_id:
return "subgroep"
if self.leerling_id:
return "leerling"
return "onbekend"
def __str__(self) -> str:
return f"{self.activiteit} @ {self.tijdslot}"

View file

@ -1,42 +1,62 @@
"""DRF-serializers voor het kerndomein.""" """DRF-serializers voor het kerndomein."""
from rest_framework import serializers from rest_framework import serializers
from .models import Groep, Persoon, Subgroep from .models import (
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
Roosterblok, Schooljaar, Subgroep, Tijdslot,
)
class FunctieSerializer(serializers.ModelSerializer):
aantal_personen = serializers.IntegerField(source="personen.count", read_only=True)
class Meta:
model = Functie
fields = ["id", "naam", "omschrijving", "actief", "aantal_personen", "aangemaakt_op"]
read_only_fields = ["aangemaakt_op"]
class GroepSerializer(serializers.ModelSerializer): class GroepSerializer(serializers.ModelSerializer):
aantal_leerlingen = serializers.IntegerField( aantal_leerlingen = serializers.IntegerField(source="leerlingen.count", read_only=True)
source="leerlingen.count", read_only=True
)
class Meta: class Meta:
model = Groep model = Groep
fields = [ fields = ["id", "naam", "omschrijving", "actief", "aantal_leerlingen", "aangemaakt_op"]
"id", "naam", "omschrijving", "actief",
"aantal_leerlingen", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"] read_only_fields = ["aangemaakt_op"]
class PersoonSerializer(serializers.ModelSerializer): class PersoonSerializer(serializers.ModelSerializer):
volledige_naam = serializers.CharField(read_only=True) volledige_naam = serializers.CharField(read_only=True)
rol_label = serializers.CharField(source="get_rol_display", read_only=True) rol_label = serializers.CharField(source="get_rol_display", read_only=True)
groep_naam = serializers.CharField( groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None)
source="groep.naam", read_only=True, default=None functies = serializers.PrimaryKeyRelatedField(
many=True, queryset=Functie.objects.all(), required=False
) )
functies_namen = serializers.SerializerMethodField()
class Meta: class Meta:
model = Persoon model = Persoon
fields = [ fields = [
"id", "voornaam", "achternaam", "volledige_naam", "id", "voornaam", "achternaam", "volledige_naam",
"rol", "rol_label", "groep", "groep_naam", "rol", "rol_label", "groep", "groep_naam",
"functies", "functies_namen",
"actief", "opmerkingen", "aangemaakt_op", "actief", "opmerkingen", "aangemaakt_op",
] ]
read_only_fields = ["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 validate(self, data):
# Functies 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"] = []
return data
class SubgroepSerializer(serializers.ModelSerializer): class SubgroepSerializer(serializers.ModelSerializer):
# Lezen: namen tonen. Schrijven: lijst met leerling-id's.
leden = serializers.PrimaryKeyRelatedField( leden = serializers.PrimaryKeyRelatedField(
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
) )
@ -52,10 +72,88 @@ class SubgroepSerializer(serializers.ModelSerializer):
read_only_fields = ["aangemaakt_op"] read_only_fields = ["aangemaakt_op"]
def get_leden_namen(self, obj): def get_leden_namen(self, obj):
return [ return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leden.all()]
{"id": p.id, "naam": p.volledige_naam}
for p in obj.leden.all()
]
def get_aantal_leden(self, obj): def get_aantal_leden(self, obj):
return obj.leden.count() return obj.leden.count()
# --- Fase 2 ---------------------------------------------------------------
class SchooljaarSerializer(serializers.ModelSerializer):
class Meta:
model = Schooljaar
fields = ["id", "naam", "start_datum", "eind_datum", "actief"]
class KalenderDagSerializer(serializers.ModelSerializer):
type_label = serializers.CharField(source="get_type_display", read_only=True)
class Meta:
model = KalenderDag
fields = ["id", "schooljaar", "datum", "type", "type_label", "omschrijving"]
class TijdslotSerializer(serializers.ModelSerializer):
dag_label = serializers.CharField(source="get_dag_display", read_only=True)
class Meta:
model = Tijdslot
fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "actief"]
class ActiviteitSerializer(serializers.ModelSerializer):
class Meta:
model = Activiteit
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
class LocatieSerializer(serializers.ModelSerializer):
class Meta:
model = Locatie
fields = ["id", "naam", "omschrijving", "actief"]
class RoosterblokSerializer(serializers.ModelSerializer):
activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True)
activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True)
tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True)
locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None)
doel_type = serializers.CharField(read_only=True)
doel_naam = serializers.SerializerMethodField()
begeleiders_namen = serializers.SerializerMethodField()
class Meta:
model = Roosterblok
fields = [
"id", "schooljaar", "tijdslot", "tijdslot_label",
"activiteit", "activiteit_naam", "activiteit_kleur",
"locatie", "locatie_naam",
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
"begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
def get_doel_naam(self, obj):
if obj.groep_id:
return obj.groep.naam
if obj.subgroep_id:
return obj.subgroep.naam
if obj.leerling_id:
return obj.leerling.volledige_naam
return None
def get_begeleiders_namen(self, obj):
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.begeleiders.all()]
def validate(self, data):
# Precies één doelgroep (groep / subgroep / leerling).
groep = data.get("groep", getattr(self.instance, "groep", None))
subgroep = data.get("subgroep", getattr(self.instance, "subgroep", None))
leerling = data.get("leerling", getattr(self.instance, "leerling", None))
gevuld = [x for x in (groep, subgroep, leerling) if x]
if len(gevuld) != 1:
raise serializers.ValidationError(
"Kies precies één doelgroep: groep, subgroep of leerling."
)
return data

View file

@ -7,8 +7,19 @@ router = DefaultRouter()
router.register("personen", views.PersoonViewSet, basename="persoon") router.register("personen", views.PersoonViewSet, basename="persoon")
router.register("groepen", views.GroepViewSet, basename="groep") router.register("groepen", views.GroepViewSet, basename="groep")
router.register("subgroepen", views.SubgroepViewSet, basename="subgroep") router.register("subgroepen", views.SubgroepViewSet, basename="subgroep")
router.register("functies", views.FunctieViewSet, basename="functie")
router.register("schooljaren", views.SchooljaarViewSet, basename="schooljaar")
router.register("kalenderdagen", views.KalenderDagViewSet, basename="kalenderdag")
router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot")
router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit")
router.register("locaties", views.LocatieViewSet, basename="locatie")
router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok")
urlpatterns = [ urlpatterns = [
path("health/", views.health, name="health"),
path("info/", views.info, name="info"), path("info/", views.info, name="info"),
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"),
path("", include(router.urls)), path("", include(router.urls)),
] ]

View file

@ -1,16 +1,38 @@
from rest_framework import viewsets import logging
from rest_framework.decorators import api_view
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from plugins import services from plugins import services
from .models import Groep, Persoon, Subgroep from .models import (
from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
Roosterblok, Schooljaar, Subgroep, Tijdslot,
)
from .serializers import (
ActiviteitSerializer, FunctieSerializer, GroepSerializer,
KalenderDagSerializer, LocatieSerializer, PersoonSerializer,
RoosterblokSerializer, SchooljaarSerializer, SubgroepSerializer,
TijdslotSerializer,
)
logger = logging.getLogger("roosterwijs.auth")
@api_view(["GET"])
@permission_classes([AllowAny])
def health(request):
"""Anonieme gezondheidscheck: bewust zonder inhoudelijke informatie."""
return Response({"naam": "Roosterwijs", "status": "ok"})
@api_view(["GET"]) @api_view(["GET"])
def info(request): def info(request):
"""Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck.""" """Systeeminfo + welke modules actief zijn (alleen voor ingelogde gebruikers)."""
services.sync_states() services.sync_states()
modules = services.describe_all() modules = services.describe_all()
return Response( return Response(
@ -23,6 +45,57 @@ def info(request):
) )
# --- Authenticatie ----------------------------------------------------------
def _user_payload(user):
return {
"authenticated": True,
"username": user.get_username(),
"is_staff": user.is_staff,
}
@api_view(["GET"])
@permission_classes([AllowAny])
@ensure_csrf_cookie
def auth_me(request):
"""Wie ben ik? Zet tegelijk de CSRF-cookie voor de frontend."""
if request.user.is_authenticated:
return Response(_user_payload(request.user))
return Response({"authenticated": False})
@api_view(["POST"])
@permission_classes([AllowAny])
def auth_login_view(request):
"""Inloggen met gebruikersnaam + wachtwoord (sessie-cookie)."""
username = str(request.data.get("username", "")).strip()
password = str(request.data.get("password", ""))
if not username or not password:
return Response(
{"detail": "Vul gebruikersnaam en wachtwoord in."},
status=status.HTTP_400_BAD_REQUEST,
)
user = authenticate(request, username=username, password=password)
if user is None:
logger.warning("Mislukte inlogpoging voor gebruiker %r", username)
return Response(
{"detail": "Onjuiste gebruikersnaam of wachtwoord."},
status=status.HTTP_401_UNAUTHORIZED,
)
auth_login(request, user)
logger.info("Gebruiker %r ingelogd", username)
return Response(_user_payload(user))
@api_view(["POST"])
def auth_logout_view(request):
"""Uitloggen: sessie beëindigen."""
logger.info("Gebruiker %r uitgelogd", request.user.get_username())
auth_logout(request)
return Response({"authenticated": False})
class GroepViewSet(viewsets.ModelViewSet): class GroepViewSet(viewsets.ModelViewSet):
queryset = Groep.objects.all() queryset = Groep.objects.all()
serializer_class = GroepSerializer serializer_class = GroepSerializer
@ -32,7 +105,7 @@ class PersoonViewSet(viewsets.ModelViewSet):
serializer_class = PersoonSerializer serializer_class = PersoonSerializer
def get_queryset(self): def get_queryset(self):
qs = Persoon.objects.select_related("groep").all() qs = Persoon.objects.select_related("groep").prefetch_related("functies").all()
rol = self.request.query_params.get("rol") rol = self.request.query_params.get("rol")
if rol: if rol:
qs = qs.filter(rol=rol) qs = qs.filter(rol=rol)
@ -42,3 +115,58 @@ class PersoonViewSet(viewsets.ModelViewSet):
class SubgroepViewSet(viewsets.ModelViewSet): class SubgroepViewSet(viewsets.ModelViewSet):
queryset = Subgroep.objects.prefetch_related("leden").all() queryset = Subgroep.objects.prefetch_related("leden").all()
serializer_class = SubgroepSerializer serializer_class = SubgroepSerializer
class FunctieViewSet(viewsets.ModelViewSet):
queryset = Functie.objects.all()
serializer_class = FunctieSerializer
# --- Fase 2 ---------------------------------------------------------------
class SchooljaarViewSet(viewsets.ModelViewSet):
queryset = Schooljaar.objects.all()
serializer_class = SchooljaarSerializer
class KalenderDagViewSet(viewsets.ModelViewSet):
serializer_class = KalenderDagSerializer
def get_queryset(self):
qs = KalenderDag.objects.all()
schooljaar = self.request.query_params.get("schooljaar")
if schooljaar:
qs = qs.filter(schooljaar_id=schooljaar)
return qs
class TijdslotViewSet(viewsets.ModelViewSet):
queryset = Tijdslot.objects.all()
serializer_class = TijdslotSerializer
class ActiviteitViewSet(viewsets.ModelViewSet):
queryset = Activiteit.objects.all()
serializer_class = ActiviteitSerializer
class LocatieViewSet(viewsets.ModelViewSet):
queryset = Locatie.objects.all()
serializer_class = LocatieSerializer
class RoosterblokViewSet(viewsets.ModelViewSet):
serializer_class = RoosterblokSerializer
def get_queryset(self):
qs = (
Roosterblok.objects
.select_related("tijdslot", "activiteit", "locatie", "groep", "subgroep", "leerling")
.prefetch_related("begeleiders")
.all()
)
for veld in ("schooljaar", "groep", "subgroep", "leerling"):
waarde = self.request.query_params.get(veld)
if waarde:
qs = qs.filter(**{f"{veld}_id": waarde})
return qs

View file

@ -27,13 +27,19 @@ def is_enabled(key: str) -> bool:
def sync_states() -> None: def sync_states() -> None:
"""Zorg dat elke geregistreerde module een rij in de database heeft.""" """Zorg dat elke geregistreerde module een rij in de database heeft.
get_or_create is race-bestendig: meerdere gunicorn-workers kunnen dit
tegelijk aanroepen zonder een unique-constraint-fout te veroorzaken.
"""
existing = set(ModuleState.objects.values_list("key", flat=True)) existing = set(ModuleState.objects.values_list("key", flat=True))
for spec in registry.all(): for spec in registry.all():
if spec.core: if spec.core:
continue continue
if spec.key not in existing: if spec.key not in existing:
ModuleState.objects.create(key=spec.key, enabled=spec.default_enabled) ModuleState.objects.get_or_create(
key=spec.key, defaults={"enabled": spec.default_enabled}
)
def describe_all() -> list[dict]: def describe_all() -> list[dict]:

View file

@ -1,6 +1,7 @@
"""API voor het plugin-framework: modules opvragen en aan/uit zetten.""" """API voor het plugin-framework: modules opvragen en aan/uit zetten."""
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from . import services from . import services
@ -14,9 +15,16 @@ def module_list(request):
@api_view(["POST"]) @api_view(["POST"])
@permission_classes([IsAdminUser])
def module_set_state(request, key: str): def module_set_state(request, key: str):
"""Zet één module aan of uit. Body: {"enabled": true|false}.""" """Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}."""
enabled = bool(request.data.get("enabled", False)) enabled = request.data.get("enabled")
# Expliciet een echte boolean eisen: bool("false") zou True zijn.
if not isinstance(enabled, bool):
return Response(
{"detail": "Veld 'enabled' moet true of false zijn."},
status=status.HTTP_400_BAD_REQUEST,
)
try: try:
services.set_enabled(key, enabled) services.set_enabled(key, enabled)
except services.ModuleError as exc: except services.ModuleError as exc:

View file

@ -29,6 +29,7 @@ services:
environment: environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_DEBUG: ${DJANGO_DEBUG:-0} DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
DJANGO_SECURE: ${DJANGO_SECURE:-0}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS} DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}

View file

@ -6,7 +6,9 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install # npm ci (reproduceerbaar) zodra er een package-lock.json in git staat;
# genereer die eenmalig lokaal met `npm install` en commit hem.
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY . . COPY . .
RUN npm run build RUN npm run build

View file

@ -6,6 +6,12 @@ server {
# Upload-/postlimiet wat ruimer (bv. importbestanden later). # Upload-/postlimiet wat ruimer (bv. importbestanden later).
client_max_body_size 20M; client_max_body_size 20M;
# Beveiligingsheaders voor alles wat nginx zelf serveert (de SPA).
# Django zet vergelijkbare headers al op /api en /admin.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy same-origin always;
# Door Django verzamelde statische bestanden (o.a. admin-CSS). # Door Django verzamelde statische bestanden (o.a. admin-CSS).
# Deze map komt uit het gedeelde volume 'static_volume'. # Deze map komt uit het gedeelde volume 'static_volume'.
location /static/ { location /static/ {

View file

@ -1,22 +1,104 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { getModules, setModuleState } from "./api.js"; import { getMe, login, logout, getModules, setModuleState } from "./api.js";
import Logo from "./Logo.jsx"; import Logo from "./Logo.jsx";
import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx"; import {
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
} from "./pages.jsx";
const ICONS = { const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩", key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
}; };
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een // Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
// placeholder (worden in een latere fase ingevuld). // placeholder (worden in een latere fase ingevuld).
const PAGES = { const PAGES = {
"/rooster": RoosterPage,
"/personen": PersonenPage, "/personen": PersonenPage,
"/functies": FunctiesPage,
"/groepen": GroepenPage, "/groepen": GroepenPage,
"/subgroepen": SubgroepenPage, "/subgroepen": SubgroepenPage,
"/activiteiten": ActiviteitenPage,
"/locaties": LocatiesPage,
"/tijdsloten": TijdslotenPage,
"/schooljaar": SchooljaarPage,
}; };
export default function App() { export default function App() {
// null = nog aan het controleren; {authenticated:false} = login tonen.
const [user, setUser] = useState(null);
const [checking, setChecking] = useState(true);
useEffect(() => {
getMe()
.then(setUser)
.catch(() => setUser({ authenticated: false }))
.finally(() => setChecking(false));
}, []);
if (checking) return <p className="center-msg">Laden</p>;
if (!user || !user.authenticated) {
return <LoginPage onLogin={setUser} />;
}
return <Shell user={user} onLogout={() => setUser({ authenticated: false })} />;
}
/* --------------------------------- Login ---------------------------------- */
function LoginPage({ onLogin }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
onLogin(await login(username, password));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<div className="brand login-brand">
<span className="brand-logo"><Logo size={36} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
<p className="muted">Log in om verder te gaan.</p>
{error && <div className="banner error"> {error}</div>}
<input
placeholder="Gebruikersnaam"
value={username}
required
autoFocus
autoComplete="username"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Wachtwoord"
value={password}
required
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
/>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Bezig…" : "Inloggen"}
</button>
</form>
</div>
);
}
/* ---------------------------------- Shell --------------------------------- */
function Shell({ user, onLogout }) {
const [modules, setModules] = useState([]); const [modules, setModules] = useState([]);
const [active, setActive] = useState("/personen"); const [active, setActive] = useState("/personen");
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -36,6 +118,14 @@ export default function App() {
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
async function doLogout() {
try {
await logout();
} finally {
onLogout();
}
}
// 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(() => { const menu = useMemo(() => {
const items = []; const items = [];
@ -43,9 +133,12 @@ export default function App() {
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) {
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 }); items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
}
return items.sort((a, b) => a.order - b.order); return items.sort((a, b) => a.order - b.order);
}, [modules]); }, [modules, user.is_staff]);
const Page = PAGES[active]; const Page = PAGES[active];
@ -68,6 +161,10 @@ export default function App() {
</button> </button>
))} ))}
</nav> </nav>
<div className="sidebar-user">
<span className="muted small">👤 {user.username}</span>
<button className="link" onClick={doLogout}>uitloggen</button>
</div>
<div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div> <div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div>
</aside> </aside>
@ -75,7 +172,7 @@ export default function App() {
{error && <div className="banner error"> {error}</div>} {error && <div className="banner error"> {error}</div>}
{loading ? ( {loading ? (
<p>Laden</p> <p>Laden</p>
) : active === "/modules" ? ( ) : active === "/modules" && user.is_staff ? (
<ModuleManager modules={modules} onChange={load} /> <ModuleManager modules={modules} onChange={load} />
) : Page ? ( ) : Page ? (
<Page /> <Page />

View file

@ -1,8 +1,19 @@
// Dunne API-laag richting de Django-backend. // Dunne API-laag richting de Django-backend.
const BASE = "/api"; const BASE = "/api";
// Lees een cookie (nodig voor het CSRF-token dat Django zet).
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
async function request(method, url, body) { async function request(method, url, body) {
const opts = { method, headers: {} }; const opts = { method, headers: {}, credentials: "same-origin" };
// Schrijfacties beveiligt Django met een CSRF-token; stuur het mee.
if (method !== "GET") {
const token = getCookie("csrftoken");
if (token) opts.headers["X-CSRFToken"] = token;
}
if (body !== undefined) { if (body !== undefined) {
opts.headers["Content-Type"] = "application/json"; opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
@ -16,13 +27,22 @@ async function request(method, url, body) {
data = null; data = null;
} }
if (!res.ok) { if (!res.ok) {
const msg = const err = new Error(
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis"; (data && (data.detail || JSON.stringify(data))) || "Er ging iets mis"
throw new Error(msg); );
err.status = res.status;
throw err;
} }
return data; return data;
} }
// --- Authenticatie ----------------------------------------------------------
// getMe zet meteen de CSRF-cookie; daarom altijd eerst aanroepen.
export const getMe = () => request("GET", "/auth/me/");
export const login = (username, password) =>
request("POST", "/auth/login/", { username, password });
export const logout = () => request("POST", "/auth/logout/");
// --- Plugin-framework ----------------------------------------------------- // --- Plugin-framework -----------------------------------------------------
export const getModules = () => request("GET", "/modules/"); export const getModules = () => request("GET", "/modules/");
export const setModuleState = (key, enabled) => export const setModuleState = (key, enabled) =>

View file

@ -13,16 +13,28 @@ const ROLLEN = [
{ value: "klassenondersteuner", label: "Klassenondersteuner" }, { value: "klassenondersteuner", label: "Klassenondersteuner" },
]; ];
// Kleine helper voor laden + foutmelding. const WEEKDAGEN = [
function useList(resource) { { value: 0, label: "Maandag" },
{ value: 1, label: "Dinsdag" },
{ value: 2, label: "Woensdag" },
{ value: 3, label: "Donderdag" },
{ value: 4, label: "Vrijdag" },
];
const DAGTYPES = [
{ value: "studiedag", label: "Studiedag" },
{ value: "vrije_dag", label: "Vrije dag" },
{ value: "vakantie", label: "Vakantie" },
];
function useList(resource, query = "") {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
async function reload() { async function reload() {
setLoading(true); setLoading(true);
try { try {
setItems(await listResource(resource)); setItems(await listResource(resource, query));
setError(null); setError(null);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
@ -32,7 +44,7 @@ function useList(resource) {
} }
useEffect(() => { useEffect(() => {
reload(); reload();
}, [resource]); }, [resource, query]);
return { items, error, loading, reload, setError }; return { items, error, loading, reload, setError };
} }
@ -41,6 +53,159 @@ function Foutmelding({ msg }) {
return <div className="banner error"> {msg}</div>; return <div className="banner error"> {msg}</div>;
} }
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties).
function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) {
const { items, error, loading, reload, setError } = useList(resource);
const leeg = kleur
? { naam: "", omschrijving: "", kleur: "#2563eb" }
: { naam: "", omschrijving: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource(resource, editId, form);
else await createResource(resource, form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Verwijderen?")) return;
try {
await deleteResource(resource, id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>{titel}</h1>
{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 })}
/>
{kleur && (
<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>
{editId && (
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>
Annuleren
</button>
)}
</form>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Omschrijving</th>
{telKolom && <th>{telKolom}</th>}
<th></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td>
{kleur && (
<span className="kleur-stip" style={{ background: it.kleur }} />
)}
{it.naam}
</td>
<td className="muted">{it.omschrijving || "—"}</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>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={telKolom ? 4 : 3} className="muted">Nog niets.</td>
</tr>
)}
</tbody>
</table>
)}
</section>
);
}
export function FunctiesPage() {
return (
<CatalogusPage
titel="Functies"
uitleg="Beheer de functies die medewerkers kunnen hebben (bv. Directie, Logopedist). Medewerkers kunnen meerdere functies hebben."
resource="functies"
telKolom="Medewerkers"
/>
);
}
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."
resource="activiteiten"
kleur
/>
);
}
export function LocatiesPage() {
return (
<CatalogusPage
titel="Locaties"
uitleg="Lokalen en ruimtes. Worden gebruikt voor conflictdetectie."
resource="locaties"
/>
);
}
/* --------------------------------- Groepen -------------------------------- */ /* --------------------------------- Groepen -------------------------------- */
export function GroepenPage() { export function GroepenPage() {
const { items, error, loading, reload, setError } = useList("groepen"); const { items, error, loading, reload, setError } = useList("groepen");
@ -75,48 +240,15 @@ export function GroepenPage() {
<h1>Groepen</h1> <h1>Groepen</h1>
<p className="muted">Klassen waar leerlingen bij horen.</p> <p className="muted">Klassen waar leerlingen bij horen.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}> <form className="form-row" onSubmit={opslaan}>
<input <input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
placeholder="Naam" <input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
value={form.naam} <button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
required {editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
onChange={(e) => setForm({ ...form, naam: e.target.value })}
/>
<input
placeholder="Omschrijving (optioneel)"
value={form.omschrijving}
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
/>
<button className="btn primary" type="submit">
{editId ? "Opslaan" : "Toevoegen"}
</button>
{editId && (
<button
type="button"
className="btn"
onClick={() => {
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button>
)}
</form> </form>
{loading ? <p>Laden</p> : (
{loading ? (
<p>Laden</p>
) : (
<table className="grid"> <table className="grid">
<thead> <thead><tr><th>Naam</th><th>Omschrijving</th><th>Leerlingen</th><th></th></tr></thead>
<tr>
<th>Naam</th>
<th>Omschrijving</th>
<th>Leerlingen</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
{items.map((g) => ( {items.map((g) => (
<tr key={g.id}> <tr key={g.id}>
@ -124,28 +256,12 @@ export function GroepenPage() {
<td className="muted">{g.omschrijving || "—"}</td> <td className="muted">{g.omschrijving || "—"}</td>
<td>{g.aantal_leerlingen}</td> <td>{g.aantal_leerlingen}</td>
<td className="row-actions"> <td className="row-actions">
<button <button className="link" onClick={() => { setEditId(g.id); setForm({ naam: g.naam, omschrijving: g.omschrijving }); }}>bewerken</button>
className="link" <button className="link danger" onClick={() => verwijder(g.id)}>verwijderen</button>
onClick={() => {
setEditId(g.id);
setForm({ naam: g.naam, omschrijving: g.omschrijving });
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(g.id)}>
verwijderen
</button>
</td> </td>
</tr> </tr>
))} ))}
{items.length === 0 && ( {items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen groepen.</td></tr>}
<tr>
<td colSpan="4" className="muted">
Nog geen groepen.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
)} )}
@ -157,18 +273,36 @@ export function GroepenPage() {
export function PersonenPage() { export function PersonenPage() {
const { items, error, loading, reload, setError } = useList("personen"); const { items, error, loading, reload, setError } = useList("personen");
const [groepen, setGroepen] = useState([]); const [groepen, setGroepen] = useState([]);
const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "" }; const [functies, setFuncties] = useState([]);
const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "", functies: [] };
const [form, setForm] = useState(leeg); const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
useEffect(() => { useEffect(() => {
listResource("groepen").then(setGroepen).catch(() => {}); listResource("groepen").then(setGroepen).catch(() => {});
listResource("functies").then(setFuncties).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] }
);
}
async function opslaan(e) { async function opslaan(e) {
e.preventDefault(); e.preventDefault();
const payload = { ...form, groep: form.rol === "leerling" && form.groep ? form.groep : null }; const payload = {
voornaam: form.voornaam,
achternaam: form.achternaam,
rol: form.rol,
groep: isLeerling && form.groep ? form.groep : null,
functies: isLeerling ? [] : form.functies,
};
try { try {
if (editId) await updateResource("personen", editId, payload); if (editId) await updateResource("personen", editId, payload);
else await createResource("personen", payload); else await createResource("personen", payload);
@ -194,96 +328,63 @@ export function PersonenPage() {
return ( return (
<section> <section>
<h1>Personen</h1> <h1>Personen</h1>
<p className="muted">Leerlingen, leerkrachten en ondersteuners.</p> <p className="muted">Leerlingen en medewerkers. Medewerkers kunnen meerdere functies hebben.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}> <form className="form-card" onSubmit={opslaan}>
<input <div className="form-row">
placeholder="Voornaam" <input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
value={form.voornaam} <input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
required <select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
onChange={(e) => setForm({ ...form, voornaam: e.target.value })} {ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
/>
<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> </select>
{form.rol === "leerling" && ( {isLeerling && (
<select <select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
value={form.groep || ""}
onChange={(e) => setForm({ ...form, groep: e.target.value })}
>
<option value=""> geen groep </option> <option value=""> geen groep </option>
{groepen.map((g) => ( {groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
<option key={g.id} value={g.id}>
{g.naam}
</option>
))}
</select> </select>
)} )}
<button className="btn primary" type="submit"> </div>
{editId ? "Opslaan" : "Toevoegen"} {!isLeerling && (
</button> <div className="leden-keuze">
{editId && ( <div className="muted small">Functies (meerdere mogelijk):</div>
<button <div className="chips">
type="button" {functies.map((f) => (
className="btn" <button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggleFunctie(f.id)}>
onClick={() => { {f.naam}
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button> </button>
))}
{functies.length === 0 && <span className="muted small">Nog geen functies voeg ze toe bij Functies.</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> </form>
<div className="filterbar"> <div className="filterbar">
<span className="muted">Filter:</span> <span className="muted">Filter:</span>
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}> <button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
Alle
</button>
{ROLLEN.map((r) => ( {ROLLEN.map((r) => (
<button <button key={r.value} className={filter === r.value ? "chip on" : "chip"} onClick={() => setFilter(r.value)}>{r.label}</button>
key={r.value}
className={filter === r.value ? "chip on" : "chip"}
onClick={() => setFilter(r.value)}
>
{r.label}
</button>
))} ))}
</div> </div>
{loading ? ( {loading ? <p>Laden</p> : (
<p>Laden</p>
) : (
<table className="grid"> <table className="grid">
<thead> <thead><tr><th>Naam</th><th>Rol</th><th>Groep / Functies</th><th></th></tr></thead>
<tr>
<th>Naam</th>
<th>Rol</th>
<th>Groep</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
{zichtbaar.map((p) => ( {zichtbaar.map((p) => (
<tr key={p.id}> <tr key={p.id}>
<td>{p.volledige_naam}</td> <td>{p.volledige_naam}</td>
<td>{p.rol_label}</td> <td>{p.rol_label}</td>
<td className="muted">{p.groep_naam || "—"}</td> <td className="muted">
{p.rol === "leerling"
? (p.groep_naam || "—")
: (p.functies_namen.length ? p.functies_namen.map((x) => x.naam).join(", ") : "—")}
</td>
<td className="row-actions"> <td className="row-actions">
<button <button
className="link" className="link"
@ -294,24 +395,17 @@ export function PersonenPage() {
achternaam: p.achternaam, achternaam: p.achternaam,
rol: p.rol, rol: p.rol,
groep: p.groep || "", groep: p.groep || "",
functies: p.functies_namen.map((x) => x.id),
}); });
}} }}
> >
bewerken bewerken
</button> </button>
<button className="link danger" onClick={() => verwijder(p.id)}> <button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
verwijderen
</button>
</td> </td>
</tr> </tr>
))} ))}
{zichtbaar.length === 0 && ( {zichtbaar.length === 0 && <tr><td colSpan="4" className="muted">Geen personen.</td></tr>}
<tr>
<td colSpan="4" className="muted">
Geen personen.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
)} )}
@ -364,118 +458,421 @@ export function SubgroepenPage() {
return ( return (
<section> <section>
<h1>Subgroepen</h1> <h1>Subgroepen</h1>
<p className="muted"> <p className="muted">Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).</p>
Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).
</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}> <form className="form-card" onSubmit={opslaan}>
<div className="form-row"> <div className="form-row">
<input <input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
placeholder="Naam" <input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
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>
<div className="leden-keuze"> <div className="leden-keuze">
<div className="muted small">Leden (leerlingen):</div> <div className="muted small">Leden (leerlingen):</div>
<div className="chips"> <div className="chips">
{leerlingen.map((l) => ( {leerlingen.map((l) => (
<button <button type="button" key={l.id} className={form.leden.includes(l.id) ? "chip on" : "chip"} onClick={() => toggleLid(l.id)}>{l.volledige_naam}</button>
type="button"
key={l.id}
className={form.leden.includes(l.id) ? "chip on" : "chip"}
onClick={() => toggleLid(l.id)}
>
{l.volledige_naam}
</button>
))} ))}
{leerlingen.length === 0 && ( {leerlingen.length === 0 && <span className="muted small">Nog geen leerlingen ingevoerd.</span>}
<span className="muted small">Nog geen leerlingen ingevoerd.</span>
)}
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<button className="btn primary" type="submit"> <button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
{editId ? "Opslaan" : "Toevoegen"} {editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
</button>
{editId && (
<button
type="button"
className="btn"
onClick={() => {
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button>
)}
</div> </div>
</form> </form>
{loading ? <p>Laden</p> : (
{loading ? (
<p>Laden</p>
) : (
<table className="grid"> <table className="grid">
<thead> <thead><tr><th>Naam</th><th>Leden</th><th></th></tr></thead>
<tr>
<th>Naam</th>
<th>Leden</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
{items.map((s) => ( {items.map((s) => (
<tr key={s.id}> <tr key={s.id}>
<td> <td>{s.naam}{s.omschrijving && <div className="muted small">{s.omschrijving}</div>}</td>
{s.naam} <td><span className="badge">{s.aantal_leden}</span> <span className="muted small">{s.leden_namen.map((x) => x.naam).join(", ")}</span></td>
{s.omschrijving && (
<div className="muted small">{s.omschrijving}</div>
)}
</td>
<td>
<span className="badge">{s.aantal_leden}</span>{" "}
<span className="muted small">
{s.leden_namen.map((x) => x.naam).join(", ")}
</span>
</td>
<td className="row-actions"> <td className="row-actions">
<button <button className="link" onClick={() => { setEditId(s.id); setForm({ naam: s.naam, omschrijving: s.omschrijving, leden: s.leden_namen.map((x) => x.id) }); }}>bewerken</button>
className="link" <button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
onClick={() => {
setEditId(s.id);
setForm({
naam: s.naam,
omschrijving: s.omschrijving,
leden: s.leden_namen.map((x) => x.id),
});
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(s.id)}>
verwijderen
</button>
</td> </td>
</tr> </tr>
))} ))}
{items.length === 0 && ( {items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen subgroepen.</td></tr>}
<tr>
<td colSpan="3" className="muted">
Nog geen subgroepen.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
)} )}
</section> </section>
); );
} }
/* ------------------------------- 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 [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource("tijdsloten", editId, form);
else await createResource("tijdsloten", form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Dit tijdslot verwijderen?")) return;
try {
await deleteResource("tijdsloten", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Tijdsloten</h1>
<p className="muted">Vaste tijdblokken per schooldag (bv. Blok 1, maandag 08:3010:00).</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 })} />
<select value={form.dag} onChange={(e) => setForm({ ...form, dag: Number(e.target.value) })}>
{WEEKDAGEN.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<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) })} />
<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>
<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="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 danger" onClick={() => verwijder(t.id)}>verwijderen</button>
</td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen tijdsloten.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* --------------------------- Schooljaar & kalender ------------------------ */
export function SchooljaarPage() {
const { items: jaren, error, loading, reload, setError } = useList("schooljaren");
const leeg = { naam: "", start_datum: "", eind_datum: "" };
const [form, setForm] = useState(leeg);
const [gekozen, setGekozen] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("schooljaren", form);
setForm(leeg);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Dit schooljaar (incl. kalender) verwijderen?")) return;
try {
await deleteResource("schooljaren", id);
if (gekozen === id) setGekozen(null);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Schooljaar &amp; kalender</h1>
<p className="muted">Schooljaren met studiedagen, vrije dagen en vakanties. Op die dagen vervallen de roosterblokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input placeholder="Naam (bv. 2026-2027)" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
<label className="veld">Start <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">Eind <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Schooljaar</th><th>Periode</th><th></th></tr></thead>
<tbody>
{jaren.map((j) => (
<tr key={j.id} className={gekozen === j.id ? "rij-actief" : ""}>
<td>{j.naam}</td>
<td className="muted">{j.start_datum} t/m {j.eind_datum}</td>
<td className="row-actions">
<button className="link" onClick={() => setGekozen(gekozen === j.id ? null : j.id)}>
{gekozen === j.id ? "kalender verbergen" : "kalender beheren"}
</button>
<button className="link danger" onClick={() => verwijder(j.id)}>verwijderen</button>
</td>
</tr>
))}
{jaren.length === 0 && <tr><td colSpan="3" className="muted">Nog geen schooljaren.</td></tr>}
</tbody>
</table>
)}
{gekozen && <Kalender schooljaarId={gekozen} />}
</section>
);
}
function Kalender({ schooljaarId }) {
const { items, error, reload, setError } = useList("kalenderdagen", `?schooljaar=${schooljaarId}`);
const leeg = { datum: "", type: "vrije_dag", omschrijving: "" };
const [form, setForm] = useState(leeg);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("kalenderdagen", { ...form, schooljaar: schooljaarId });
setForm(leeg);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
try {
await deleteResource("kalenderdagen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<div className="subpaneel">
<h2>Kalender</h2>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input type="date" value={form.datum} required onChange={(e) => setForm({ ...form, datum: e.target.value })} />
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{DAGTYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input placeholder="Omschrijving (bv. Kerstvakantie)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
<button className="btn primary" type="submit">Dag toevoegen</button>
</form>
<table className="grid">
<thead><tr><th>Datum</th><th>Type</th><th>Omschrijving</th><th></th></tr></thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td>{d.datum}</td>
<td><span className={`tag dag-${d.type}`}>{d.type_label}</span></td>
<td className="muted">{d.omschrijving || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(d.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen bijzondere dagen.</td></tr>}
</tbody>
</table>
</div>
);
}
/* ---------------------------------- Rooster ------------------------------- */
export function RoosterPage() {
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [activiteiten, setActiviteiten] = useState([]);
const [locaties, setLocaties] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]);
const [blokken, setBlokken] = useState([]);
const [error, setError] = useState(null);
const leeg = {
tijdslot: "", activiteit: "", locatie: "",
doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "",
};
const [form, setForm] = useState(leeg);
useEffect(() => {
listResource("schooljaren").then((j) => {
setSchooljaren(j);
if (j.length && !schooljaar) setSchooljaar(String(j[0].id));
}).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("locaties").then(setLocaties).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
}, []);
async function laadBlokken() {
if (!schooljaar) return;
try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`));
setError(null);
} catch (e) {
setError(e.message);
}
}
useEffect(() => { laadBlokken(); }, [schooljaar]);
const doelOpties = form.doel_type === "groep" ? groepen
: form.doel_type === "subgroep" ? subgroepen
: leerlingen;
function toggleBegeleider(id) {
setForm((f) =>
f.begeleiders.includes(id)
? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) }
: { ...f, begeleiders: [...f.begeleiders, id] }
);
}
async function opslaan(e) {
e.preventDefault();
const payload = {
schooljaar,
tijdslot: form.tijdslot,
activiteit: form.activiteit,
locatie: form.locatie || null,
groep: form.doel_type === "groep" ? form.doel : null,
subgroep: form.doel_type === "subgroep" ? form.doel : null,
leerling: form.doel_type === "leerling" ? form.doel : null,
begeleiders: form.begeleiders,
opmerkingen: form.opmerkingen,
};
try {
await createResource("roosterblokken", payload);
setForm({ ...leeg, doel_type: form.doel_type });
await laadBlokken();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Dit roosterblok verwijderen?")) return;
try {
await deleteResource("roosterblokken", id);
await laadBlokken();
} catch (err) {
setError(err.message);
}
}
// Groepeer blokken per weekdag voor een overzichtelijk rooster.
const perDag = WEEKDAGEN.map((d) => ({
dag: d,
blokken: blokken
.filter((b) => {
const t = tijdsloten.find((x) => x.id === b.tijdslot);
return t && t.dag === d.value;
})
.sort((a, b) => {
const ta = tijdsloten.find((x) => x.id === a.tijdslot);
const tb = tijdsloten.find((x) => x.id === b.tijdslot);
return (ta?.volgorde ?? 0) - (tb?.volgorde ?? 0);
}),
}));
return (
<section>
<h1>Rooster</h1>
<p className="muted">Roosterblokken: wie doet wat, wanneer, waar en met wie. Koppel aan een groep, subgroep of individuele leerling.</p>
<Foutmelding msg={error} />
<div className="form-row">
<label className="veld">Schooljaar
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> kies </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
</label>
</div>
{schooljaar && (
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0,5)} {t.naam}</option>)}
</select>
<select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}>
<option value=""> activiteit </option>
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
</select>
<select value={form.locatie} onChange={(e) => setForm({ ...form, locatie: e.target.value })}>
<option value=""> locatie (optioneel) </option>
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select>
</div>
<div className="form-row">
<select value={form.doel_type} onChange={(e) => setForm({ ...form, doel_type: e.target.value, doel: "" })}>
<option value="groep">Groep</option>
<option value="subgroep">Subgroep</option>
<option value="leerling">Individuele leerling</option>
</select>
<select value={form.doel} required onChange={(e) => setForm({ ...form, doel: e.target.value })}>
<option value=""> kies {form.doel_type} </option>
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
</div>
<div className="leden-keuze">
<div className="muted small">Begeleiders:</div>
<div className="chips">
{medewerkers.map((m) => (
<button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}</button>
))}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div>
</div>
<div className="form-row">
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
<button className="btn primary" type="submit">Blok toevoegen</button>
</div>
</form>
)}
{schooljaar && (
<div className="rooster-grid">
{perDag.map(({ dag, blokken }) => (
<div className="rooster-kolom" key={dag.value}>
<div className="rooster-dag">{dag.label}</div>
{blokken.length === 0 && <div className="muted small"></div>}
{blokken.map((b) => (
<div className="rooster-blok" key={b.id} style={{ borderLeftColor: b.activiteit_kleur }}>
<div className="rb-top">
<strong>{b.activiteit_naam}</strong>
<button className="link danger small" onClick={() => verwijder(b.id)}>×</button>
</div>
<div className="muted small">{b.tijdslot_label}</div>
<div className="small">🎯 {b.doel_naam} <span className="muted">({b.doel_type})</span></div>
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
{b.begeleiders_namen.length > 0 && (
<div className="small muted">👤 {b.begeleiders_namen.map((x) => x.naam).join(", ")}</div>
)}
{b.opmerkingen && <div className="small muted">📝 {b.opmerkingen}</div>}
</div>
))}
</div>
))}
</div>
)}
</section>
);
}

View file

@ -99,3 +99,43 @@ input:focus, select:focus { outline: 2px solid var(--brand-soft); border-color:
.leden-keuze { margin: 4px 0 12px; } .leden-keuze { margin: 4px 0 12px; }
.badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 600; } .badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 600; }
/* --- Fase 2 --- */
.kleur-stip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 8px; vertical-align: middle; border: 1px solid rgba(0,0,0,.1); }
.veld { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
.veld input, .veld select { min-width: auto; }
.rij-actief { background: var(--brand-soft); }
input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; }
.subpaneel { margin-top: 24px; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: #fafbff; }
.subpaneel h2 { margin: 0 0 12px; font-size: 18px; }
.tag.dag-studiedag { background: #e0e7ff; color: #3730a3; }
.tag.dag-vrije_dag { background: #fee2e2; color: #b91c1c; }
.tag.dag-vakantie { background: #dcfce7; color: #166534; }
.rooster-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 20px; }
.rooster-kolom { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px; min-height: 80px; }
.rooster-dag { font-weight: 600; font-size: 13px; text-align: center; padding: 6px 0; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.rooster-blok { border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: #fff; }
.rb-top { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.link.small { font-size: 16px; line-height: 1; padding: 0 4px; }
@media (max-width: 900px) { .rooster-grid { grid-template-columns: 1fr; } }
/* --- Login --- */
.center-msg { text-align: center; margin-top: 20vh; color: var(--muted); }
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 28px; width: 320px; display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 8px 30px rgba(31, 41, 55, .06);
}
.login-brand { margin: 0 0 4px; justify-content: center; }
.login-card input { width: 100%; }
.login-card .btn { width: 100%; }
/* --- Gebruiker in zijbalk --- */
.sidebar-user {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); margin-top: 12px;
}