Fase 2 - Kerndomein, security-audit en configwijzigingen
This commit is contained in:
parent
f3373a9c2d
commit
4fcc169c7c
22 changed files with 1759 additions and 295 deletions
|
|
@ -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.
|
||||
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 -----------------------------------------------------------
|
||||
POSTGRES_DB=rooster
|
||||
POSTGRES_USER=rooster
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
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,
|
||||
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.
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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
|
||||
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
||||
|
||||
Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein:
|
||||
personen, groepen, subgroepen) zijn klaar.
|
||||
Status: **Fase 0** (fundament + plugin-framework), **Fase 1** (kerndomein:
|
||||
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
|
||||
|
||||
|
|
@ -50,8 +52,12 @@ API-eindpunten:
|
|||
- `GET /api/modules/` — alle modules met status.
|
||||
- `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`.
|
||||
- `GET /api/info/` — korte systeem-/gezondheidsinfo.
|
||||
- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD
|
||||
(plus `PATCH`/`DELETE` op `/<id>/`). Personen filteren kan met `?rol=leerling`.
|
||||
- CRUD-endpoints (telkens met `PATCH`/`DELETE` op `/<id>/`):
|
||||
`/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).
|
||||
|
||||
> 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
|
||||
aan/uit te zetten.
|
||||
|
||||
## Volgende stap (Fase 2)
|
||||
## Volgende stap (Fase 3 & 4)
|
||||
|
||||
Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties)
|
||||
en roosterblokken die je toewijst aan een groep, subgroep of individuele
|
||||
leerling, met begeleiders en locatie.
|
||||
Flexibiliteit & uitzonderingen (individuele afwijkingen, afwezigheid/vervanging,
|
||||
vrije dagen die blokken automatisch uitschakelen) en conflictdetectie
|
||||
(dubbele inzet van personen/leerlingen, locatiebotsingen).
|
||||
|
|
|
|||
138
SECURITY-AUDIT.md
Normal file
138
SECURITY-AUDIT.md
Normal 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.
|
||||
|
|
@ -21,6 +21,13 @@ COPY . .
|
|||
# entrypoint wacht op de database, migreert en verzamelt statics.
|
||||
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
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
# 3 workers is ruim voldoende voor de kleine aantallen in het SO.
|
||||
|
|
|
|||
|
|
@ -19,12 +19,23 @@ def _env_bool(name, default):
|
|||
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.
|
||||
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".
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.environ.get(
|
||||
"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"
|
||||
|
||||
# --- API ------------------------------------------------------------------
|
||||
# Veiligheid eerst: alles vereist standaard een ingelogde gebruiker.
|
||||
# Uitzonderingen (zoals het login-endpoint) krijgen expliciet AllowAny.
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||
"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.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.
|
||||
|
|
@ -151,3 +184,39 @@ CORS_ALLOWED_ORIGINS = [
|
|||
"http://localhost: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"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
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)
|
||||
|
|
@ -13,9 +23,10 @@ class GroepAdmin(admin.ModelAdmin):
|
|||
@admin.register(Persoon)
|
||||
class PersoonAdmin(admin.ModelAdmin):
|
||||
list_display = ("achternaam", "voornaam", "rol", "groep", "actief")
|
||||
list_filter = ("rol", "actief", "groep")
|
||||
list_filter = ("rol", "actief", "groep", "functies")
|
||||
search_fields = ("voornaam", "achternaam")
|
||||
autocomplete_fields = ("groep",)
|
||||
filter_horizontal = ("functies",)
|
||||
|
||||
|
||||
@admin.register(Subgroep)
|
||||
|
|
@ -24,3 +35,44 @@ class SubgroepAdmin(admin.ModelAdmin):
|
|||
list_filter = ("actief",)
|
||||
search_fields = ("naam",)
|
||||
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",)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ class CoreConfig(AppConfig):
|
|||
menu_items=(
|
||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
||||
MenuItem("Personen", "/personen", icon="users", order=20),
|
||||
MenuItem("Functies", "/functies", icon="badge", order=25),
|
||||
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
||||
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
||||
MenuItem("Activiteiten", "/activiteiten", icon="book", order=50),
|
||||
MenuItem("Locaties", "/locaties", icon="pin", order=55),
|
||||
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
|
||||
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
"""
|
||||
Kerndomein van Roosterwijs (Fase 1).
|
||||
Kerndomein van Roosterwijs.
|
||||
|
||||
Bewust klein gehouden, met veel flexibiliteit voor het speciaal onderwijs:
|
||||
- één Persoon-tabel met een rol (leerling, leerkracht, ondersteuner);
|
||||
- leerlingen horen bij een groep;
|
||||
- subgroepen zijn vrije, groep-overstijgende verzamelingen leerlingen.
|
||||
Fase 1: Persoon, Groep, Subgroep, Functie (catalogus).
|
||||
Fase 2: Schooljaar + KalenderDag (jaaroverzicht), Tijdslot, Activiteit,
|
||||
Locatie en Roosterblok (de spil van het rooster).
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -18,6 +22,27 @@ class Rol(models.TextChoices):
|
|||
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):
|
||||
"""Een klas/groep waar leerlingen bij horen."""
|
||||
|
||||
|
|
@ -36,7 +61,11 @@ class Groep(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)
|
||||
achternaam = models.CharField("achternaam", max_length=120)
|
||||
|
|
@ -50,6 +79,13 @@ class Persoon(models.Model):
|
|||
related_name="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)
|
||||
opmerkingen = models.TextField("opmerkingen", blank=True)
|
||||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||
|
|
@ -75,6 +111,14 @@ class Persoon(models.Model):
|
|||
if self.rol != Rol.LEERLING:
|
||||
self.groep = None
|
||||
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):
|
||||
|
|
@ -102,3 +146,191 @@ class Subgroep(models.Model):
|
|||
|
||||
def __str__(self) -> str:
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -1,42 +1,62 @@
|
|||
"""DRF-serializers voor het kerndomein."""
|
||||
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):
|
||||
aantal_leerlingen = serializers.IntegerField(
|
||||
source="leerlingen.count", read_only=True
|
||||
)
|
||||
aantal_leerlingen = serializers.IntegerField(source="leerlingen.count", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Groep
|
||||
fields = [
|
||||
"id", "naam", "omschrijving", "actief",
|
||||
"aantal_leerlingen", "aangemaakt_op",
|
||||
]
|
||||
fields = ["id", "naam", "omschrijving", "actief", "aantal_leerlingen", "aangemaakt_op"]
|
||||
read_only_fields = ["aangemaakt_op"]
|
||||
|
||||
|
||||
class PersoonSerializer(serializers.ModelSerializer):
|
||||
volledige_naam = serializers.CharField(read_only=True)
|
||||
rol_label = serializers.CharField(source="get_rol_display", read_only=True)
|
||||
groep_naam = serializers.CharField(
|
||||
source="groep.naam", read_only=True, default=None
|
||||
groep_naam = serializers.CharField(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:
|
||||
model = Persoon
|
||||
fields = [
|
||||
"id", "voornaam", "achternaam", "volledige_naam",
|
||||
"rol", "rol_label", "groep", "groep_naam",
|
||||
"functies", "functies_namen",
|
||||
"actief", "opmerkingen", "aangemaakt_op",
|
||||
]
|
||||
read_only_fields = ["aangemaakt_op"]
|
||||
|
||||
def get_functies_namen(self, obj):
|
||||
return [{"id": f.id, "naam": f.naam} for f in obj.functies.all()]
|
||||
|
||||
def 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):
|
||||
# Lezen: namen tonen. Schrijven: lijst met leerling-id's.
|
||||
leden = serializers.PrimaryKeyRelatedField(
|
||||
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
|
||||
)
|
||||
|
|
@ -52,10 +72,88 @@ class SubgroepSerializer(serializers.ModelSerializer):
|
|||
read_only_fields = ["aangemaakt_op"]
|
||||
|
||||
def get_leden_namen(self, obj):
|
||||
return [
|
||||
{"id": p.id, "naam": p.volledige_naam}
|
||||
for p in obj.leden.all()
|
||||
]
|
||||
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leden.all()]
|
||||
|
||||
def get_aantal_leden(self, obj):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,8 +7,19 @@ router = DefaultRouter()
|
|||
router.register("personen", views.PersoonViewSet, basename="persoon")
|
||||
router.register("groepen", views.GroepViewSet, basename="groep")
|
||||
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 = [
|
||||
path("health/", views.health, name="health"),
|
||||
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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import api_view
|
||||
import logging
|
||||
|
||||
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 plugins import services
|
||||
|
||||
from .models import Groep, Persoon, Subgroep
|
||||
from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer
|
||||
from .models import (
|
||||
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"])
|
||||
def info(request):
|
||||
"""Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck."""
|
||||
"""Systeeminfo + welke modules actief zijn (alleen voor ingelogde gebruikers)."""
|
||||
services.sync_states()
|
||||
modules = services.describe_all()
|
||||
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):
|
||||
queryset = Groep.objects.all()
|
||||
serializer_class = GroepSerializer
|
||||
|
|
@ -32,7 +105,7 @@ class PersoonViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = PersoonSerializer
|
||||
|
||||
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")
|
||||
if rol:
|
||||
qs = qs.filter(rol=rol)
|
||||
|
|
@ -42,3 +115,58 @@ class PersoonViewSet(viewsets.ModelViewSet):
|
|||
class SubgroepViewSet(viewsets.ModelViewSet):
|
||||
queryset = Subgroep.objects.prefetch_related("leden").all()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,13 +27,19 @@ def is_enabled(key: str) -> bool:
|
|||
|
||||
|
||||
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))
|
||||
for spec in registry.all():
|
||||
if spec.core:
|
||||
continue
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
|
||||
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 . import services
|
||||
|
|
@ -14,9 +15,16 @@ def module_list(request):
|
|||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAdminUser])
|
||||
def module_set_state(request, key: str):
|
||||
"""Zet één module aan of uit. Body: {"enabled": true|false}."""
|
||||
enabled = bool(request.data.get("enabled", False))
|
||||
"""Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|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:
|
||||
services.set_enabled(key, enabled)
|
||||
except services.ModuleError as exc:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ services:
|
|||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
||||
DJANGO_SECURE: ${DJANGO_SECURE:-0}
|
||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
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 . .
|
||||
RUN npm run build
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ server {
|
|||
# Upload-/postlimiet wat ruimer (bv. importbestanden later).
|
||||
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).
|
||||
# Deze map komt uit het gedeelde volume 'static_volume'.
|
||||
location /static/ {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,104 @@
|
|||
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 { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx";
|
||||
import {
|
||||
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
||||
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
|
||||
} from "./pages.jsx";
|
||||
|
||||
const ICONS = {
|
||||
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
||||
key: "🔑", puzzle: "🧩",
|
||||
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
|
||||
};
|
||||
|
||||
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
|
||||
// placeholder (worden in een latere fase ingevuld).
|
||||
const PAGES = {
|
||||
"/rooster": RoosterPage,
|
||||
"/personen": PersonenPage,
|
||||
"/functies": FunctiesPage,
|
||||
"/groepen": GroepenPage,
|
||||
"/subgroepen": SubgroepenPage,
|
||||
"/activiteiten": ActiviteitenPage,
|
||||
"/locaties": LocatiesPage,
|
||||
"/tijdsloten": TijdslotenPage,
|
||||
"/schooljaar": SchooljaarPage,
|
||||
};
|
||||
|
||||
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 [active, setActive] = useState("/personen");
|
||||
const [error, setError] = useState(null);
|
||||
|
|
@ -36,6 +118,14 @@ export default function App() {
|
|||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
onLogout();
|
||||
}
|
||||
}
|
||||
|
||||
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
|
||||
const menu = useMemo(() => {
|
||||
const items = [];
|
||||
|
|
@ -43,9 +133,12 @@ export default function App() {
|
|||
if (!m.enabled) continue;
|
||||
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
||||
}
|
||||
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
|
||||
// Modulebeheer is een beheerdersactie.
|
||||
if (user.is_staff) {
|
||||
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
|
||||
}
|
||||
return items.sort((a, b) => a.order - b.order);
|
||||
}, [modules]);
|
||||
}, [modules, user.is_staff]);
|
||||
|
||||
const Page = PAGES[active];
|
||||
|
||||
|
|
@ -68,6 +161,10 @@ export default function App() {
|
|||
</button>
|
||||
))}
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
|
|
@ -75,7 +172,7 @@ export default function App() {
|
|||
{error && <div className="banner error">⚠ {error}</div>}
|
||||
{loading ? (
|
||||
<p>Laden…</p>
|
||||
) : active === "/modules" ? (
|
||||
) : active === "/modules" && user.is_staff ? (
|
||||
<ModuleManager modules={modules} onChange={load} />
|
||||
) : Page ? (
|
||||
<Page />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
// Dunne API-laag richting de Django-backend.
|
||||
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) {
|
||||
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) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
|
|
@ -16,13 +27,22 @@ async function request(method, url, body) {
|
|||
data = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis";
|
||||
throw new Error(msg);
|
||||
const err = new Error(
|
||||
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis"
|
||||
);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
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 -----------------------------------------------------
|
||||
export const getModules = () => request("GET", "/modules/");
|
||||
export const setModuleState = (key, enabled) =>
|
||||
|
|
|
|||
|
|
@ -13,16 +13,28 @@ const ROLLEN = [
|
|||
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
|
||||
];
|
||||
|
||||
// Kleine helper voor laden + foutmelding.
|
||||
function useList(resource) {
|
||||
const WEEKDAGEN = [
|
||||
{ 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 [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems(await listResource(resource));
|
||||
setItems(await listResource(resource, query));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
|
|
@ -32,7 +44,7 @@ function useList(resource) {
|
|||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [resource]);
|
||||
}, [resource, query]);
|
||||
return { items, error, loading, reload, setError };
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +53,159 @@ function Foutmelding({ msg }) {
|
|||
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 -------------------------------- */
|
||||
export function GroepenPage() {
|
||||
const { items, error, loading, reload, setError } = useList("groepen");
|
||||
|
|
@ -75,48 +240,15 @@ export function GroepenPage() {
|
|||
<h1>Groepen</h1>
|
||||
<p className="muted">Klassen waar leerlingen bij horen.</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 })}
|
||||
/>
|
||||
<button className="btn primary" type="submit">
|
||||
{editId ? "Opslaan" : "Toevoegen"}
|
||||
</button>
|
||||
{editId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditId(null);
|
||||
setForm(leeg);
|
||||
}}
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
)}
|
||||
<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 })} />
|
||||
<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>
|
||||
) : (
|
||||
{loading ? <p>Laden…</p> : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Omschrijving</th>
|
||||
<th>Leerlingen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>Naam</th><th>Omschrijving</th><th>Leerlingen</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((g) => (
|
||||
<tr key={g.id}>
|
||||
|
|
@ -124,28 +256,12 @@ export function GroepenPage() {
|
|||
<td className="muted">{g.omschrijving || "—"}</td>
|
||||
<td>{g.aantal_leerlingen}</td>
|
||||
<td className="row-actions">
|
||||
<button
|
||||
className="link"
|
||||
onClick={() => {
|
||||
setEditId(g.id);
|
||||
setForm({ naam: g.naam, omschrijving: g.omschrijving });
|
||||
}}
|
||||
>
|
||||
bewerken
|
||||
</button>
|
||||
<button className="link danger" onClick={() => verwijder(g.id)}>
|
||||
verwijderen
|
||||
</button>
|
||||
<button className="link" onClick={() => { setEditId(g.id); setForm({ naam: g.naam, omschrijving: g.omschrijving }); }}>bewerken</button>
|
||||
<button className="link danger" onClick={() => verwijder(g.id)}>verwijderen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="4" className="muted">
|
||||
Nog geen groepen.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen groepen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
|
@ -157,18 +273,36 @@ export function GroepenPage() {
|
|||
export function PersonenPage() {
|
||||
const { items, error, loading, reload, setError } = useList("personen");
|
||||
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 [editId, setEditId] = useState(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
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 {
|
||||
if (editId) await updateResource("personen", editId, payload);
|
||||
else await createResource("personen", payload);
|
||||
|
|
@ -194,96 +328,63 @@ export function PersonenPage() {
|
|||
return (
|
||||
<section>
|
||||
<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} />
|
||||
|
||||
<form className="form-row" onSubmit={opslaan}>
|
||||
<input
|
||||
placeholder="Voornaam"
|
||||
value={form.voornaam}
|
||||
required
|
||||
onChange={(e) => setForm({ ...form, voornaam: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Achternaam"
|
||||
value={form.achternaam}
|
||||
required
|
||||
onChange={(e) => setForm({ ...form, achternaam: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
value={form.rol}
|
||||
onChange={(e) => setForm({ ...form, rol: e.target.value })}
|
||||
>
|
||||
{ROLLEN.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{form.rol === "leerling" && (
|
||||
<select
|
||||
value={form.groep || ""}
|
||||
onChange={(e) => setForm({ ...form, groep: e.target.value })}
|
||||
>
|
||||
<option value="">— geen groep —</option>
|
||||
{groepen.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.naam}
|
||||
</option>
|
||||
))}
|
||||
<form className="form-card" onSubmit={opslaan}>
|
||||
<div className="form-row">
|
||||
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
|
||||
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
|
||||
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
|
||||
{ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
{isLeerling && (
|
||||
<select value={form.groep || ""} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
|
||||
<option value="">— geen groep —</option>
|
||||
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{!isLeerling && (
|
||||
<div className="leden-keuze">
|
||||
<div className="muted small">Functies (meerdere mogelijk):</div>
|
||||
<div className="chips">
|
||||
{functies.map((f) => (
|
||||
<button type="button" key={f.id} className={form.functies.includes(f.id) ? "chip on" : "chip"} onClick={() => toggleFunctie(f.id)}>
|
||||
{f.naam}
|
||||
</button>
|
||||
))}
|
||||
{functies.length === 0 && <span className="muted small">Nog geen functies — voeg ze toe bij Functies.</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn primary" type="submit">
|
||||
{editId ? "Opslaan" : "Toevoegen"}
|
||||
</button>
|
||||
{editId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditId(null);
|
||||
setForm(leeg);
|
||||
}}
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<div className="filterbar">
|
||||
<span className="muted">Filter:</span>
|
||||
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>
|
||||
Alle
|
||||
</button>
|
||||
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
|
||||
{ROLLEN.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
className={filter === r.value ? "chip on" : "chip"}
|
||||
onClick={() => setFilter(r.value)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
<button key={r.value} className={filter === r.value ? "chip on" : "chip"} onClick={() => setFilter(r.value)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p>Laden…</p>
|
||||
) : (
|
||||
{loading ? <p>Laden…</p> : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Rol</th>
|
||||
<th>Groep</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>Naam</th><th>Rol</th><th>Groep / Functies</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{zichtbaar.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.volledige_naam}</td>
|
||||
<td>{p.rol_label}</td>
|
||||
<td className="muted">{p.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">
|
||||
<button
|
||||
className="link"
|
||||
|
|
@ -294,24 +395,17 @@ export function PersonenPage() {
|
|||
achternaam: p.achternaam,
|
||||
rol: p.rol,
|
||||
groep: p.groep || "",
|
||||
functies: p.functies_namen.map((x) => x.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
bewerken
|
||||
</button>
|
||||
<button className="link danger" onClick={() => verwijder(p.id)}>
|
||||
verwijderen
|
||||
</button>
|
||||
<button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{zichtbaar.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="4" className="muted">
|
||||
Geen personen.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{zichtbaar.length === 0 && <tr><td colSpan="4" className="muted">Geen personen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
|
@ -364,118 +458,421 @@ export function SubgroepenPage() {
|
|||
return (
|
||||
<section>
|
||||
<h1>Subgroepen</h1>
|
||||
<p className="muted">
|
||||
Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).
|
||||
</p>
|
||||
<p className="muted">Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).</p>
|
||||
<Foutmelding msg={error} />
|
||||
|
||||
<form className="form-card" onSubmit={opslaan}>
|
||||
<div className="form-row">
|
||||
<input
|
||||
placeholder="Naam"
|
||||
value={form.naam}
|
||||
required
|
||||
onChange={(e) => setForm({ ...form, naam: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Omschrijving (optioneel)"
|
||||
value={form.omschrijving}
|
||||
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
|
||||
/>
|
||||
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
|
||||
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
|
||||
</div>
|
||||
<div className="leden-keuze">
|
||||
<div className="muted small">Leden (leerlingen):</div>
|
||||
<div className="chips">
|
||||
{leerlingen.map((l) => (
|
||||
<button
|
||||
type="button"
|
||||
key={l.id}
|
||||
className={form.leden.includes(l.id) ? "chip on" : "chip"}
|
||||
onClick={() => toggleLid(l.id)}
|
||||
>
|
||||
{l.volledige_naam}
|
||||
</button>
|
||||
<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 && (
|
||||
<span className="muted small">Nog geen leerlingen ingevoerd.</span>
|
||||
)}
|
||||
{leerlingen.length === 0 && <span className="muted small">Nog geen leerlingen ingevoerd.</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>
|
||||
)}
|
||||
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
|
||||
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<p>Laden…</p>
|
||||
) : (
|
||||
{loading ? <p>Laden…</p> : (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Leden</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>Naam</th><th>Leden</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
{s.naam}
|
||||
{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>{s.naam}{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">
|
||||
<button
|
||||
className="link"
|
||||
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>
|
||||
<button className="link" 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>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="3" className="muted">
|
||||
Nog geen subgroepen.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen subgroepen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</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:30–10: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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,3 +99,43 @@ input:focus, select:focus { outline: 2px solid var(--brand-soft); border-color:
|
|||
.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; }
|
||||
|
||||
/* --- 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue