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.
|
# Volledige origin(s) inclusief http(s):// voor CSRF achter nginx.
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080
|
||||||
|
|
||||||
|
# Zet op 1 zodra de server achter HTTPS draait (TLS-certificaat aanwezig).
|
||||||
|
# Activeert o.a. veilige cookies, HSTS en een redirect naar https.
|
||||||
|
DJANGO_SECURE=0
|
||||||
|
|
||||||
# --- PostgreSQL -----------------------------------------------------------
|
# --- PostgreSQL -----------------------------------------------------------
|
||||||
POSTGRES_DB=rooster
|
POSTGRES_DB=rooster
|
||||||
POSTGRES_USER=rooster
|
POSTGRES_USER=rooster
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,12 @@ Migraties draaien automatisch mee bij het opstarten van de backend.
|
||||||
- **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie
|
- **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie
|
||||||
zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of
|
zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of
|
||||||
je breidt de nginx-config uit met certificaten. De nginx-container is daar al
|
je breidt de nginx-config uit met certificaten. De nginx-container is daar al
|
||||||
het logische punt voor.
|
het logische punt voor. **Zet daarna `DJANGO_SECURE=1` in `.env`** — dat
|
||||||
|
activeert veilige cookies, HSTS en een https-redirect in Django.
|
||||||
|
- **Inloggen verplicht:** de hele API vereist een ingelogde gebruiker; de
|
||||||
|
frontend toont eerst een inlogscherm. Maak gebruikers aan via
|
||||||
|
`createsuperuser` (zie boven) of in de Django-admin. Modulebeheer is
|
||||||
|
voorbehouden aan beheerders (`is_staff`).
|
||||||
- **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups,
|
- **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups,
|
||||||
bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`.
|
bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`.
|
||||||
- **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie.
|
- **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie.
|
||||||
|
|
|
||||||
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
|
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
|
||||||
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
|
||||||
|
|
||||||
Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein:
|
Status: **Fase 0** (fundament + plugin-framework), **Fase 1** (kerndomein:
|
||||||
personen, groepen, subgroepen) zijn klaar.
|
personen, groepen, subgroepen, functies) en **Fase 2** (schooljaarkalender +
|
||||||
|
roosterblokken) zijn klaar. Medewerkers kunnen meerdere functies hebben uit een
|
||||||
|
beheerbare functie-catalogus.
|
||||||
|
|
||||||
## Wat er nu staat
|
## Wat er nu staat
|
||||||
|
|
||||||
|
|
@ -50,8 +52,12 @@ API-eindpunten:
|
||||||
- `GET /api/modules/` — alle modules met status.
|
- `GET /api/modules/` — alle modules met status.
|
||||||
- `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`.
|
- `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`.
|
||||||
- `GET /api/info/` — korte systeem-/gezondheidsinfo.
|
- `GET /api/info/` — korte systeem-/gezondheidsinfo.
|
||||||
- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD
|
- CRUD-endpoints (telkens met `PATCH`/`DELETE` op `/<id>/`):
|
||||||
(plus `PATCH`/`DELETE` op `/<id>/`). Personen filteren kan met `?rol=leerling`.
|
`/api/personen/` (filter `?rol=leerling`), `/api/groepen/`,
|
||||||
|
`/api/subgroepen/`, `/api/functies/`, `/api/schooljaren/`,
|
||||||
|
`/api/kalenderdagen/` (filter `?schooljaar=<id>`), `/api/tijdsloten/`,
|
||||||
|
`/api/activiteiten/`, `/api/locaties/`, `/api/roosterblokken/`
|
||||||
|
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`).
|
||||||
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
|
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
|
||||||
|
|
||||||
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
|
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
|
||||||
|
|
@ -69,8 +75,8 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
|
||||||
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
|
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
|
||||||
aan/uit te zetten.
|
aan/uit te zetten.
|
||||||
|
|
||||||
## Volgende stap (Fase 2)
|
## Volgende stap (Fase 3 & 4)
|
||||||
|
|
||||||
Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties)
|
Flexibiliteit & uitzonderingen (individuele afwijkingen, afwezigheid/vervanging,
|
||||||
en roosterblokken die je toewijst aan een groep, subgroep of individuele
|
vrije dagen die blokken automatisch uitschakelen) en conflictdetectie
|
||||||
leerling, met begeleiders en locatie.
|
(dubbele inzet van personen/leerlingen, locatiebotsingen).
|
||||||
|
|
|
||||||
138
SECURITY-AUDIT.md
Normal file
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.
|
# entrypoint wacht op de database, migreert en verzamelt statics.
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Draai niet als root: een eventueel lek in de app geeft dan geen
|
||||||
|
# root-rechten in de container.
|
||||||
|
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
|
||||||
|
&& mkdir -p /app/staticfiles \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
# 3 workers is ruim voldoende voor de kleine aantallen in het SO.
|
# 3 workers is ruim voldoende voor de kleine aantallen in het SO.
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,23 @@ def _env_bool(name, default):
|
||||||
return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
|
return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
# Dev-default alleen voor lokaal werken; ZET DJANGO_SECRET_KEY in productie.
|
|
||||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me-in-productie")
|
|
||||||
|
|
||||||
# Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1.
|
# Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1.
|
||||||
DEBUG = _env_bool("DJANGO_DEBUG", False)
|
DEBUG = _env_bool("DJANGO_DEBUG", False)
|
||||||
|
|
||||||
|
# Geheime sleutel: VERPLICHT via omgeving zodra DEBUG uit staat.
|
||||||
|
# Een stille terugval op een bekende dev-sleutel zou sessies en tokens
|
||||||
|
# vervalsbaar maken; daarom weigert de app dan te starten.
|
||||||
|
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
if DEBUG:
|
||||||
|
SECRET_KEY = "dev-only-change-me-in-productie"
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DJANGO_SECRET_KEY ontbreekt. Genereer er een met "
|
||||||
|
"'python -c \"import secrets; print(secrets.token_urlsafe(50))\"' "
|
||||||
|
"en zet hem in het .env-bestand."
|
||||||
|
)
|
||||||
|
|
||||||
# Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost".
|
# Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost".
|
||||||
ALLOWED_HOSTS = [h.strip() for h in os.environ.get(
|
ALLOWED_HOSTS = [h.strip() for h in os.environ.get(
|
||||||
"DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1"
|
"DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1"
|
||||||
|
|
@ -139,11 +150,33 @@ STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# --- API ------------------------------------------------------------------
|
# --- API ------------------------------------------------------------------
|
||||||
|
# Veiligheid eerst: alles vereist standaard een ingelogde gebruiker.
|
||||||
|
# Uitzonderingen (zoals het login-endpoint) krijgen expliciet AllowAny.
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_RENDERER_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.renderers.JSONRenderer",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
||||||
],
|
],
|
||||||
|
"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.
|
# Frontend (React dev-server) mag de API benaderen.
|
||||||
|
|
@ -151,3 +184,39 @@ CORS_ALLOWED_ORIGINS = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
]
|
]
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
# --- HTTPS-hardening --------------------------------------------------------
|
||||||
|
# Zet DJANGO_SECURE=1 zodra de server achter TLS draait (zie DEPLOY.md).
|
||||||
|
# Staat los van DEBUG omdat de test-server nu nog op http://...:8080 draait.
|
||||||
|
if _env_bool("DJANGO_SECURE", False):
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_HSTS_SECONDS = 60 * 60 * 24 * 365
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
|
||||||
|
# Sessie-cookies nooit leesbaar voor JavaScript; de CSRF-cookie moet juist
|
||||||
|
# wél leesbaar zijn (de frontend stuurt hem terug in de X-CSRFToken-header).
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
CSRF_COOKIE_HTTPONLY = False
|
||||||
|
|
||||||
|
# --- Logging ----------------------------------------------------------------
|
||||||
|
# Console-logging zodat 'docker compose logs' een bruikbaar spoor geeft,
|
||||||
|
# o.a. van mislukte logins en serverfouten.
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"simple": {"format": "{levelname} {asctime} {name} {message}", "style": "{"},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {"class": "logging.StreamHandler", "formatter": "simple"},
|
||||||
|
},
|
||||||
|
"root": {"handlers": ["console"], "level": "INFO"},
|
||||||
|
"loggers": {
|
||||||
|
"django.request": {"level": "WARNING"},
|
||||||
|
"roosterwijs": {"level": "INFO"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Groep, Persoon, Subgroep
|
from .models import (
|
||||||
|
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
|
||||||
|
Roosterblok, Schooljaar, Subgroep, Tijdslot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Functie)
|
||||||
|
class FunctieAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "omschrijving", "actief")
|
||||||
|
list_filter = ("actief",)
|
||||||
|
search_fields = ("naam",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Groep)
|
@admin.register(Groep)
|
||||||
|
|
@ -13,9 +23,10 @@ class GroepAdmin(admin.ModelAdmin):
|
||||||
@admin.register(Persoon)
|
@admin.register(Persoon)
|
||||||
class PersoonAdmin(admin.ModelAdmin):
|
class PersoonAdmin(admin.ModelAdmin):
|
||||||
list_display = ("achternaam", "voornaam", "rol", "groep", "actief")
|
list_display = ("achternaam", "voornaam", "rol", "groep", "actief")
|
||||||
list_filter = ("rol", "actief", "groep")
|
list_filter = ("rol", "actief", "groep", "functies")
|
||||||
search_fields = ("voornaam", "achternaam")
|
search_fields = ("voornaam", "achternaam")
|
||||||
autocomplete_fields = ("groep",)
|
autocomplete_fields = ("groep",)
|
||||||
|
filter_horizontal = ("functies",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Subgroep)
|
@admin.register(Subgroep)
|
||||||
|
|
@ -24,3 +35,44 @@ class SubgroepAdmin(admin.ModelAdmin):
|
||||||
list_filter = ("actief",)
|
list_filter = ("actief",)
|
||||||
search_fields = ("naam",)
|
search_fields = ("naam",)
|
||||||
filter_horizontal = ("leden",)
|
filter_horizontal = ("leden",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Schooljaar)
|
||||||
|
class SchooljaarAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "start_datum", "eind_datum", "actief")
|
||||||
|
list_filter = ("actief",)
|
||||||
|
search_fields = ("naam",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(KalenderDag)
|
||||||
|
class KalenderDagAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("datum", "type", "omschrijving", "schooljaar")
|
||||||
|
list_filter = ("type", "schooljaar")
|
||||||
|
date_hierarchy = "datum"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Tijdslot)
|
||||||
|
class TijdslotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "dag", "start_tijd", "eind_tijd", "volgorde", "actief")
|
||||||
|
list_filter = ("dag", "actief")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Activiteit)
|
||||||
|
class ActiviteitAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "kleur", "actief")
|
||||||
|
list_filter = ("actief",)
|
||||||
|
search_fields = ("naam",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Locatie)
|
||||||
|
class LocatieAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("naam", "omschrijving", "actief")
|
||||||
|
list_filter = ("actief",)
|
||||||
|
search_fields = ("naam",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Roosterblok)
|
||||||
|
class RoosterblokAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("activiteit", "tijdslot", "doel_type", "locatie", "schooljaar")
|
||||||
|
list_filter = ("schooljaar", "tijdslot__dag", "activiteit")
|
||||||
|
filter_horizontal = ("begeleiders",)
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,13 @@ class CoreConfig(AppConfig):
|
||||||
menu_items=(
|
menu_items=(
|
||||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
||||||
MenuItem("Personen", "/personen", icon="users", order=20),
|
MenuItem("Personen", "/personen", icon="users", order=20),
|
||||||
|
MenuItem("Functies", "/functies", icon="badge", order=25),
|
||||||
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
||||||
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
||||||
|
MenuItem("Activiteiten", "/activiteiten", icon="book", order=50),
|
||||||
|
MenuItem("Locaties", "/locaties", icon="pin", order=55),
|
||||||
|
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
|
||||||
|
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Fase 1: Persoon, Groep, Subgroep, Functie (catalogus).
|
||||||
- één Persoon-tabel met een rol (leerling, leerkracht, ondersteuner);
|
Fase 2: Schooljaar + KalenderDag (jaaroverzicht), Tijdslot, Activiteit,
|
||||||
- leerlingen horen bij een groep;
|
Locatie en Roosterblok (de spil van het rooster).
|
||||||
- subgroepen zijn vrije, groep-overstijgende verzamelingen leerlingen.
|
|
||||||
|
|
||||||
Roosterblokken, tijdslots en de kalender volgen in Fase 2.
|
Veel flexibiliteit voor het speciaal onderwijs:
|
||||||
|
- één Persoon-tabel met een rol (leerling of medewerker-rol);
|
||||||
|
- medewerkers kunnen meerdere FUNCTIES hebben uit een beheerbare catalogus;
|
||||||
|
- roosterblokken zijn toewijsbaar aan een groep, subgroep OF een individuele
|
||||||
|
leerling, met één of meer begeleiders.
|
||||||
"""
|
"""
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +22,27 @@ class Rol(models.TextChoices):
|
||||||
KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner"
|
KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner"
|
||||||
|
|
||||||
|
|
||||||
|
class Functie(models.Model):
|
||||||
|
"""Een beheerbare functie/rol voor medewerkers (bv. Directie, Logopedist).
|
||||||
|
|
||||||
|
Dit is een vrije catalogus: een beheerder maakt functies aan en verwijdert
|
||||||
|
ze. Een medewerker kan meerdere functies hebben (zie Persoon.functies).
|
||||||
|
"""
|
||||||
|
|
||||||
|
naam = models.CharField("naam", max_length=80, unique=True)
|
||||||
|
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "functie"
|
||||||
|
verbose_name_plural = "functies"
|
||||||
|
ordering = ["naam"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
class Groep(models.Model):
|
class Groep(models.Model):
|
||||||
"""Een klas/groep waar leerlingen bij horen."""
|
"""Een klas/groep waar leerlingen bij horen."""
|
||||||
|
|
||||||
|
|
@ -36,7 +61,11 @@ class Groep(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Persoon(models.Model):
|
class Persoon(models.Model):
|
||||||
"""Leerling, leerkracht of ondersteuner - een tabel met een rol."""
|
"""Leerling of medewerker - een tabel met een rol.
|
||||||
|
|
||||||
|
Leerlingen horen bij een groep. Medewerkers (niet-leerlingen) kunnen
|
||||||
|
meerdere functies hebben uit de Functie-catalogus.
|
||||||
|
"""
|
||||||
|
|
||||||
voornaam = models.CharField("voornaam", max_length=80)
|
voornaam = models.CharField("voornaam", max_length=80)
|
||||||
achternaam = models.CharField("achternaam", max_length=120)
|
achternaam = models.CharField("achternaam", max_length=120)
|
||||||
|
|
@ -50,6 +79,13 @@ class Persoon(models.Model):
|
||||||
related_name="leerlingen",
|
related_name="leerlingen",
|
||||||
help_text="Alleen van toepassing op leerlingen.",
|
help_text="Alleen van toepassing op leerlingen.",
|
||||||
)
|
)
|
||||||
|
functies = models.ManyToManyField(
|
||||||
|
Functie,
|
||||||
|
verbose_name="functies",
|
||||||
|
related_name="personen",
|
||||||
|
blank=True,
|
||||||
|
help_text="Alleen van toepassing op medewerkers.",
|
||||||
|
)
|
||||||
actief = models.BooleanField("actief", default=True)
|
actief = models.BooleanField("actief", default=True)
|
||||||
opmerkingen = models.TextField("opmerkingen", blank=True)
|
opmerkingen = models.TextField("opmerkingen", blank=True)
|
||||||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
@ -75,6 +111,14 @@ class Persoon(models.Model):
|
||||||
if self.rol != Rol.LEERLING:
|
if self.rol != Rol.LEERLING:
|
||||||
self.groep = None
|
self.groep = None
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
if self.pk:
|
||||||
|
# Wie geen leerling (meer) is, hoort niet in subgroepen thuis.
|
||||||
|
# (limit_choices_to geldt alleen voor formulieren, niet voor de API.)
|
||||||
|
if self.rol != Rol.LEERLING:
|
||||||
|
self.subgroepen.clear()
|
||||||
|
else:
|
||||||
|
# Een leerling heeft geen medewerkersfuncties.
|
||||||
|
self.functies.clear()
|
||||||
|
|
||||||
|
|
||||||
class Subgroep(models.Model):
|
class Subgroep(models.Model):
|
||||||
|
|
@ -102,3 +146,191 @@ class Subgroep(models.Model):
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.naam
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Fase 2: schooljaar/kalender en roosterblokken
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Schooljaar(models.Model):
|
||||||
|
"""Een schooljaar met begin- en einddatum; bevat de kalender."""
|
||||||
|
|
||||||
|
naam = models.CharField("naam", max_length=40, unique=True, help_text="bv. 2026-2027")
|
||||||
|
start_datum = models.DateField("startdatum")
|
||||||
|
eind_datum = models.DateField("einddatum")
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "schooljaar"
|
||||||
|
verbose_name_plural = "schooljaren"
|
||||||
|
ordering = ["-start_datum"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
|
class DagType(models.TextChoices):
|
||||||
|
STUDIEDAG = "studiedag", "Studiedag"
|
||||||
|
VRIJE_DAG = "vrije_dag", "Vrije dag"
|
||||||
|
VAKANTIE = "vakantie", "Vakantie"
|
||||||
|
|
||||||
|
|
||||||
|
class KalenderDag(models.Model):
|
||||||
|
"""Een bijzondere dag in het schooljaar: studiedag, vrije dag of vakantie.
|
||||||
|
|
||||||
|
Op zulke dagen vervallen de roosterblokken (geen les).
|
||||||
|
"""
|
||||||
|
|
||||||
|
schooljaar = models.ForeignKey(
|
||||||
|
Schooljaar, on_delete=models.CASCADE, related_name="kalenderdagen"
|
||||||
|
)
|
||||||
|
datum = models.DateField("datum")
|
||||||
|
type = models.CharField("type", max_length=16, choices=DagType.choices)
|
||||||
|
omschrijving = models.CharField("omschrijving", max_length=120, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "kalenderdag"
|
||||||
|
verbose_name_plural = "kalenderdagen"
|
||||||
|
ordering = ["datum"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["schooljaar", "datum"], name="uniek_schooljaar_datum"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.datum} ({self.get_type_display()})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_lesdag(self) -> bool:
|
||||||
|
# Alle drie de types betekenen 'geen les'.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Weekdag(models.IntegerChoices):
|
||||||
|
MAANDAG = 0, "Maandag"
|
||||||
|
DINSDAG = 1, "Dinsdag"
|
||||||
|
WOENSDAG = 2, "Woensdag"
|
||||||
|
DONDERDAG = 3, "Donderdag"
|
||||||
|
VRIJDAG = 4, "Vrijdag"
|
||||||
|
|
||||||
|
|
||||||
|
class Tijdslot(models.Model):
|
||||||
|
"""Een vast tijdblok op een vaste schooldag (bv. 'Blok 1' ma 08:30-10:00)."""
|
||||||
|
|
||||||
|
naam = models.CharField("naam", max_length=60)
|
||||||
|
dag = models.IntegerField("weekdag", choices=Weekdag.choices)
|
||||||
|
start_tijd = models.TimeField("starttijd")
|
||||||
|
eind_tijd = models.TimeField("eindtijd")
|
||||||
|
volgorde = models.PositiveSmallIntegerField("volgorde", default=0)
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "tijdslot"
|
||||||
|
verbose_name_plural = "tijdsloten"
|
||||||
|
ordering = ["dag", "volgorde", "start_tijd"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.get_dag_display()} {self.start_tijd:%H:%M}-{self.eind_tijd:%H:%M} {self.naam}"
|
||||||
|
|
||||||
|
|
||||||
|
class Activiteit(models.Model):
|
||||||
|
"""Wat er gedaan wordt: rekenen, gym, therapie, pauze ..."""
|
||||||
|
|
||||||
|
naam = models.CharField("naam", max_length=80, unique=True)
|
||||||
|
kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb")
|
||||||
|
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "activiteit"
|
||||||
|
verbose_name_plural = "activiteiten"
|
||||||
|
ordering = ["naam"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
|
class Locatie(models.Model):
|
||||||
|
"""Een lokaal/ruimte (handig voor conflictdetectie in fase 4)."""
|
||||||
|
|
||||||
|
naam = models.CharField("naam", max_length=80, unique=True)
|
||||||
|
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||||||
|
actief = models.BooleanField("actief", default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "locatie"
|
||||||
|
verbose_name_plural = "locaties"
|
||||||
|
ordering = ["naam"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.naam
|
||||||
|
|
||||||
|
|
||||||
|
class Roosterblok(models.Model):
|
||||||
|
"""De spil: wie doet wat, wanneer, waar, met wie.
|
||||||
|
|
||||||
|
De doelgroep is precies één van: groep, subgroep of individuele leerling.
|
||||||
|
Zo kun je een groepsrooster maken én er individueel van afwijken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schooljaar = models.ForeignKey(
|
||||||
|
Schooljaar, on_delete=models.CASCADE, related_name="roosterblokken"
|
||||||
|
)
|
||||||
|
tijdslot = models.ForeignKey(
|
||||||
|
Tijdslot, on_delete=models.PROTECT, related_name="roosterblokken"
|
||||||
|
)
|
||||||
|
activiteit = models.ForeignKey(
|
||||||
|
Activiteit, on_delete=models.PROTECT, related_name="roosterblokken"
|
||||||
|
)
|
||||||
|
locatie = models.ForeignKey(
|
||||||
|
Locatie, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
|
related_name="roosterblokken",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Doelgroep: precies één van de drie is gevuld.
|
||||||
|
groep = models.ForeignKey(
|
||||||
|
Groep, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name="roosterblokken",
|
||||||
|
)
|
||||||
|
subgroep = models.ForeignKey(
|
||||||
|
Subgroep, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name="roosterblokken",
|
||||||
|
)
|
||||||
|
leerling = models.ForeignKey(
|
||||||
|
Persoon, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
related_name="leerling_roosterblokken",
|
||||||
|
limit_choices_to={"rol": Rol.LEERLING},
|
||||||
|
)
|
||||||
|
|
||||||
|
begeleiders = models.ManyToManyField(
|
||||||
|
Persoon, related_name="begeleider_roosterblokken", blank=True,
|
||||||
|
)
|
||||||
|
opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True)
|
||||||
|
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "roosterblok"
|
||||||
|
verbose_name_plural = "roosterblokken"
|
||||||
|
ordering = ["tijdslot__dag", "tijdslot__volgorde"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
doelen = [self.groep_id, self.subgroep_id, self.leerling_id]
|
||||||
|
gevuld = [d for d in doelen if d]
|
||||||
|
if len(gevuld) != 1:
|
||||||
|
raise ValidationError(
|
||||||
|
"Kies precies één doelgroep: groep, subgroep of leerling."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def doel_type(self) -> str:
|
||||||
|
if self.groep_id:
|
||||||
|
return "groep"
|
||||||
|
if self.subgroep_id:
|
||||||
|
return "subgroep"
|
||||||
|
if self.leerling_id:
|
||||||
|
return "leerling"
|
||||||
|
return "onbekend"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.activiteit} @ {self.tijdslot}"
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,62 @@
|
||||||
"""DRF-serializers voor het kerndomein."""
|
"""DRF-serializers voor het kerndomein."""
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Groep, Persoon, Subgroep
|
from .models import (
|
||||||
|
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
|
||||||
|
Roosterblok, Schooljaar, Subgroep, Tijdslot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctieSerializer(serializers.ModelSerializer):
|
||||||
|
aantal_personen = serializers.IntegerField(source="personen.count", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Functie
|
||||||
|
fields = ["id", "naam", "omschrijving", "actief", "aantal_personen", "aangemaakt_op"]
|
||||||
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
|
|
||||||
class GroepSerializer(serializers.ModelSerializer):
|
class GroepSerializer(serializers.ModelSerializer):
|
||||||
aantal_leerlingen = serializers.IntegerField(
|
aantal_leerlingen = serializers.IntegerField(source="leerlingen.count", read_only=True)
|
||||||
source="leerlingen.count", read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Groep
|
model = Groep
|
||||||
fields = [
|
fields = ["id", "naam", "omschrijving", "actief", "aantal_leerlingen", "aangemaakt_op"]
|
||||||
"id", "naam", "omschrijving", "actief",
|
|
||||||
"aantal_leerlingen", "aangemaakt_op",
|
|
||||||
]
|
|
||||||
read_only_fields = ["aangemaakt_op"]
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
|
|
||||||
class PersoonSerializer(serializers.ModelSerializer):
|
class PersoonSerializer(serializers.ModelSerializer):
|
||||||
volledige_naam = serializers.CharField(read_only=True)
|
volledige_naam = serializers.CharField(read_only=True)
|
||||||
rol_label = serializers.CharField(source="get_rol_display", read_only=True)
|
rol_label = serializers.CharField(source="get_rol_display", read_only=True)
|
||||||
groep_naam = serializers.CharField(
|
groep_naam = serializers.CharField(source="groep.naam", read_only=True, default=None)
|
||||||
source="groep.naam", read_only=True, default=None
|
functies = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Functie.objects.all(), required=False
|
||||||
)
|
)
|
||||||
|
functies_namen = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Persoon
|
model = Persoon
|
||||||
fields = [
|
fields = [
|
||||||
"id", "voornaam", "achternaam", "volledige_naam",
|
"id", "voornaam", "achternaam", "volledige_naam",
|
||||||
"rol", "rol_label", "groep", "groep_naam",
|
"rol", "rol_label", "groep", "groep_naam",
|
||||||
|
"functies", "functies_namen",
|
||||||
"actief", "opmerkingen", "aangemaakt_op",
|
"actief", "opmerkingen", "aangemaakt_op",
|
||||||
]
|
]
|
||||||
read_only_fields = ["aangemaakt_op"]
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
|
def get_functies_namen(self, obj):
|
||||||
|
return [{"id": f.id, "naam": f.naam} for f in obj.functies.all()]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
# Functies horen alleen bij medewerkers; een leerling krijgt er geen.
|
||||||
|
# (De M2M wordt door DRF ná save() gezet, dus hier afdwingen.)
|
||||||
|
rol = data.get("rol", getattr(self.instance, "rol", None))
|
||||||
|
if rol == "leerling":
|
||||||
|
data["functies"] = []
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class SubgroepSerializer(serializers.ModelSerializer):
|
class SubgroepSerializer(serializers.ModelSerializer):
|
||||||
# Lezen: namen tonen. Schrijven: lijst met leerling-id's.
|
|
||||||
leden = serializers.PrimaryKeyRelatedField(
|
leden = serializers.PrimaryKeyRelatedField(
|
||||||
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
|
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
|
||||||
)
|
)
|
||||||
|
|
@ -52,10 +72,88 @@ class SubgroepSerializer(serializers.ModelSerializer):
|
||||||
read_only_fields = ["aangemaakt_op"]
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
def get_leden_namen(self, obj):
|
def get_leden_namen(self, obj):
|
||||||
return [
|
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.leden.all()]
|
||||||
{"id": p.id, "naam": p.volledige_naam}
|
|
||||||
for p in obj.leden.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_aantal_leden(self, obj):
|
def get_aantal_leden(self, obj):
|
||||||
return obj.leden.count()
|
return obj.leden.count()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fase 2 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
class SchooljaarSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Schooljaar
|
||||||
|
fields = ["id", "naam", "start_datum", "eind_datum", "actief"]
|
||||||
|
|
||||||
|
|
||||||
|
class KalenderDagSerializer(serializers.ModelSerializer):
|
||||||
|
type_label = serializers.CharField(source="get_type_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = KalenderDag
|
||||||
|
fields = ["id", "schooljaar", "datum", "type", "type_label", "omschrijving"]
|
||||||
|
|
||||||
|
|
||||||
|
class TijdslotSerializer(serializers.ModelSerializer):
|
||||||
|
dag_label = serializers.CharField(source="get_dag_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Tijdslot
|
||||||
|
fields = ["id", "naam", "dag", "dag_label", "start_tijd", "eind_tijd", "volgorde", "actief"]
|
||||||
|
|
||||||
|
|
||||||
|
class ActiviteitSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Activiteit
|
||||||
|
fields = ["id", "naam", "kleur", "omschrijving", "actief"]
|
||||||
|
|
||||||
|
|
||||||
|
class LocatieSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Locatie
|
||||||
|
fields = ["id", "naam", "omschrijving", "actief"]
|
||||||
|
|
||||||
|
|
||||||
|
class RoosterblokSerializer(serializers.ModelSerializer):
|
||||||
|
activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True)
|
||||||
|
activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True)
|
||||||
|
tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True)
|
||||||
|
locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None)
|
||||||
|
doel_type = serializers.CharField(read_only=True)
|
||||||
|
doel_naam = serializers.SerializerMethodField()
|
||||||
|
begeleiders_namen = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Roosterblok
|
||||||
|
fields = [
|
||||||
|
"id", "schooljaar", "tijdslot", "tijdslot_label",
|
||||||
|
"activiteit", "activiteit_naam", "activiteit_kleur",
|
||||||
|
"locatie", "locatie_naam",
|
||||||
|
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
|
||||||
|
"begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op",
|
||||||
|
]
|
||||||
|
read_only_fields = ["aangemaakt_op"]
|
||||||
|
|
||||||
|
def get_doel_naam(self, obj):
|
||||||
|
if obj.groep_id:
|
||||||
|
return obj.groep.naam
|
||||||
|
if obj.subgroep_id:
|
||||||
|
return obj.subgroep.naam
|
||||||
|
if obj.leerling_id:
|
||||||
|
return obj.leerling.volledige_naam
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_begeleiders_namen(self, obj):
|
||||||
|
return [{"id": p.id, "naam": p.volledige_naam} for p in obj.begeleiders.all()]
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
# Precies één doelgroep (groep / subgroep / leerling).
|
||||||
|
groep = data.get("groep", getattr(self.instance, "groep", None))
|
||||||
|
subgroep = data.get("subgroep", getattr(self.instance, "subgroep", None))
|
||||||
|
leerling = data.get("leerling", getattr(self.instance, "leerling", None))
|
||||||
|
gevuld = [x for x in (groep, subgroep, leerling) if x]
|
||||||
|
if len(gevuld) != 1:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Kies precies één doelgroep: groep, subgroep of leerling."
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,19 @@ router = DefaultRouter()
|
||||||
router.register("personen", views.PersoonViewSet, basename="persoon")
|
router.register("personen", views.PersoonViewSet, basename="persoon")
|
||||||
router.register("groepen", views.GroepViewSet, basename="groep")
|
router.register("groepen", views.GroepViewSet, basename="groep")
|
||||||
router.register("subgroepen", views.SubgroepViewSet, basename="subgroep")
|
router.register("subgroepen", views.SubgroepViewSet, basename="subgroep")
|
||||||
|
router.register("functies", views.FunctieViewSet, basename="functie")
|
||||||
|
router.register("schooljaren", views.SchooljaarViewSet, basename="schooljaar")
|
||||||
|
router.register("kalenderdagen", views.KalenderDagViewSet, basename="kalenderdag")
|
||||||
|
router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot")
|
||||||
|
router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit")
|
||||||
|
router.register("locaties", views.LocatieViewSet, basename="locatie")
|
||||||
|
router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("health/", views.health, name="health"),
|
||||||
path("info/", views.info, name="info"),
|
path("info/", views.info, name="info"),
|
||||||
|
path("auth/me/", views.auth_me, name="auth-me"),
|
||||||
|
path("auth/login/", views.auth_login_view, name="auth-login"),
|
||||||
|
path("auth/logout/", views.auth_logout_view, name="auth-logout"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,38 @@
|
||||||
from rest_framework import viewsets
|
import logging
|
||||||
from rest_framework.decorators import api_view
|
|
||||||
|
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
|
||||||
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plugins import services
|
from plugins import services
|
||||||
|
|
||||||
from .models import Groep, Persoon, Subgroep
|
from .models import (
|
||||||
from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer
|
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon,
|
||||||
|
Roosterblok, Schooljaar, Subgroep, Tijdslot,
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
ActiviteitSerializer, FunctieSerializer, GroepSerializer,
|
||||||
|
KalenderDagSerializer, LocatieSerializer, PersoonSerializer,
|
||||||
|
RoosterblokSerializer, SchooljaarSerializer, SubgroepSerializer,
|
||||||
|
TijdslotSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("roosterwijs.auth")
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def health(request):
|
||||||
|
"""Anonieme gezondheidscheck: bewust zonder inhoudelijke informatie."""
|
||||||
|
return Response({"naam": "Roosterwijs", "status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def info(request):
|
def info(request):
|
||||||
"""Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck."""
|
"""Systeeminfo + welke modules actief zijn (alleen voor ingelogde gebruikers)."""
|
||||||
services.sync_states()
|
services.sync_states()
|
||||||
modules = services.describe_all()
|
modules = services.describe_all()
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -23,6 +45,57 @@ def info(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Authenticatie ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _user_payload(user):
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"username": user.get_username(),
|
||||||
|
"is_staff": user.is_staff,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def auth_me(request):
|
||||||
|
"""Wie ben ik? Zet tegelijk de CSRF-cookie voor de frontend."""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return Response(_user_payload(request.user))
|
||||||
|
return Response({"authenticated": False})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def auth_login_view(request):
|
||||||
|
"""Inloggen met gebruikersnaam + wachtwoord (sessie-cookie)."""
|
||||||
|
username = str(request.data.get("username", "")).strip()
|
||||||
|
password = str(request.data.get("password", ""))
|
||||||
|
if not username or not password:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Vul gebruikersnaam en wachtwoord in."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
logger.warning("Mislukte inlogpoging voor gebruiker %r", username)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Onjuiste gebruikersnaam of wachtwoord."},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
auth_login(request, user)
|
||||||
|
logger.info("Gebruiker %r ingelogd", username)
|
||||||
|
return Response(_user_payload(user))
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
def auth_logout_view(request):
|
||||||
|
"""Uitloggen: sessie beëindigen."""
|
||||||
|
logger.info("Gebruiker %r uitgelogd", request.user.get_username())
|
||||||
|
auth_logout(request)
|
||||||
|
return Response({"authenticated": False})
|
||||||
|
|
||||||
|
|
||||||
class GroepViewSet(viewsets.ModelViewSet):
|
class GroepViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Groep.objects.all()
|
queryset = Groep.objects.all()
|
||||||
serializer_class = GroepSerializer
|
serializer_class = GroepSerializer
|
||||||
|
|
@ -32,7 +105,7 @@ class PersoonViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = PersoonSerializer
|
serializer_class = PersoonSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Persoon.objects.select_related("groep").all()
|
qs = Persoon.objects.select_related("groep").prefetch_related("functies").all()
|
||||||
rol = self.request.query_params.get("rol")
|
rol = self.request.query_params.get("rol")
|
||||||
if rol:
|
if rol:
|
||||||
qs = qs.filter(rol=rol)
|
qs = qs.filter(rol=rol)
|
||||||
|
|
@ -42,3 +115,58 @@ class PersoonViewSet(viewsets.ModelViewSet):
|
||||||
class SubgroepViewSet(viewsets.ModelViewSet):
|
class SubgroepViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Subgroep.objects.prefetch_related("leden").all()
|
queryset = Subgroep.objects.prefetch_related("leden").all()
|
||||||
serializer_class = SubgroepSerializer
|
serializer_class = SubgroepSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FunctieViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Functie.objects.all()
|
||||||
|
serializer_class = FunctieSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fase 2 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
class SchooljaarViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Schooljaar.objects.all()
|
||||||
|
serializer_class = SchooljaarSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class KalenderDagViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = KalenderDagSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = KalenderDag.objects.all()
|
||||||
|
schooljaar = self.request.query_params.get("schooljaar")
|
||||||
|
if schooljaar:
|
||||||
|
qs = qs.filter(schooljaar_id=schooljaar)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class TijdslotViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Tijdslot.objects.all()
|
||||||
|
serializer_class = TijdslotSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ActiviteitViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Activiteit.objects.all()
|
||||||
|
serializer_class = ActiviteitSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LocatieViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Locatie.objects.all()
|
||||||
|
serializer_class = LocatieSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class RoosterblokViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = RoosterblokSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = (
|
||||||
|
Roosterblok.objects
|
||||||
|
.select_related("tijdslot", "activiteit", "locatie", "groep", "subgroep", "leerling")
|
||||||
|
.prefetch_related("begeleiders")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for veld in ("schooljaar", "groep", "subgroep", "leerling"):
|
||||||
|
waarde = self.request.query_params.get(veld)
|
||||||
|
if waarde:
|
||||||
|
qs = qs.filter(**{f"{veld}_id": waarde})
|
||||||
|
return qs
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,19 @@ def is_enabled(key: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def sync_states() -> None:
|
def sync_states() -> None:
|
||||||
"""Zorg dat elke geregistreerde module een rij in de database heeft."""
|
"""Zorg dat elke geregistreerde module een rij in de database heeft.
|
||||||
|
|
||||||
|
get_or_create is race-bestendig: meerdere gunicorn-workers kunnen dit
|
||||||
|
tegelijk aanroepen zonder een unique-constraint-fout te veroorzaken.
|
||||||
|
"""
|
||||||
existing = set(ModuleState.objects.values_list("key", flat=True))
|
existing = set(ModuleState.objects.values_list("key", flat=True))
|
||||||
for spec in registry.all():
|
for spec in registry.all():
|
||||||
if spec.core:
|
if spec.core:
|
||||||
continue
|
continue
|
||||||
if spec.key not in existing:
|
if spec.key not in existing:
|
||||||
ModuleState.objects.create(key=spec.key, enabled=spec.default_enabled)
|
ModuleState.objects.get_or_create(
|
||||||
|
key=spec.key, defaults={"enabled": spec.default_enabled}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def describe_all() -> list[dict]:
|
def describe_all() -> list[dict]:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
|
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
|
|
@ -14,9 +15,16 @@ def module_list(request):
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAdminUser])
|
||||||
def module_set_state(request, key: str):
|
def module_set_state(request, key: str):
|
||||||
"""Zet één module aan of uit. Body: {"enabled": true|false}."""
|
"""Zet één module aan of uit (alleen beheerders). Body: {"enabled": true|false}."""
|
||||||
enabled = bool(request.data.get("enabled", False))
|
enabled = request.data.get("enabled")
|
||||||
|
# Expliciet een echte boolean eisen: bool("false") zou True zijn.
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Veld 'enabled' moet true of false zijn."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
services.set_enabled(key, enabled)
|
services.set_enabled(key, enabled)
|
||||||
except services.ModuleError as exc:
|
except services.ModuleError as exc:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
|
||||||
|
DJANGO_SECURE: ${DJANGO_SECURE:-0}
|
||||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
|
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
# npm ci (reproduceerbaar) zodra er een package-lock.json in git staat;
|
||||||
|
# genereer die eenmalig lokaal met `npm install` en commit hem.
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ server {
|
||||||
# Upload-/postlimiet wat ruimer (bv. importbestanden later).
|
# Upload-/postlimiet wat ruimer (bv. importbestanden later).
|
||||||
client_max_body_size 20M;
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
# Beveiligingsheaders voor alles wat nginx zelf serveert (de SPA).
|
||||||
|
# Django zet vergelijkbare headers al op /api en /admin.
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Referrer-Policy same-origin always;
|
||||||
|
|
||||||
# Door Django verzamelde statische bestanden (o.a. admin-CSS).
|
# Door Django verzamelde statische bestanden (o.a. admin-CSS).
|
||||||
# Deze map komt uit het gedeelde volume 'static_volume'.
|
# Deze map komt uit het gedeelde volume 'static_volume'.
|
||||||
location /static/ {
|
location /static/ {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,104 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { getModules, setModuleState } from "./api.js";
|
import { getMe, login, logout, getModules, setModuleState } from "./api.js";
|
||||||
import Logo from "./Logo.jsx";
|
import Logo from "./Logo.jsx";
|
||||||
import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx";
|
import {
|
||||||
|
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
||||||
|
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
|
||||||
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
||||||
key: "🔑", puzzle: "🧩",
|
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
|
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
|
||||||
// placeholder (worden in een latere fase ingevuld).
|
// placeholder (worden in een latere fase ingevuld).
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
|
"/rooster": RoosterPage,
|
||||||
"/personen": PersonenPage,
|
"/personen": PersonenPage,
|
||||||
|
"/functies": FunctiesPage,
|
||||||
"/groepen": GroepenPage,
|
"/groepen": GroepenPage,
|
||||||
"/subgroepen": SubgroepenPage,
|
"/subgroepen": SubgroepenPage,
|
||||||
|
"/activiteiten": ActiviteitenPage,
|
||||||
|
"/locaties": LocatiesPage,
|
||||||
|
"/tijdsloten": TijdslotenPage,
|
||||||
|
"/schooljaar": SchooljaarPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// null = nog aan het controleren; {authenticated:false} = login tonen.
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMe()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser({ authenticated: false }))
|
||||||
|
.finally(() => setChecking(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (checking) return <p className="center-msg">Laden…</p>;
|
||||||
|
if (!user || !user.authenticated) {
|
||||||
|
return <LoginPage onLogin={setUser} />;
|
||||||
|
}
|
||||||
|
return <Shell user={user} onLogout={() => setUser({ authenticated: false })} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------- Login ---------------------------------- */
|
||||||
|
function LoginPage({ onLogin }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onLogin(await login(username, password));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<form className="login-card" onSubmit={submit}>
|
||||||
|
<div className="brand login-brand">
|
||||||
|
<span className="brand-logo"><Logo size={36} /></span>
|
||||||
|
<span className="brand-name">Roosterwijs</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Log in om verder te gaan.</p>
|
||||||
|
{error && <div className="banner error">⚠ {error}</div>}
|
||||||
|
<input
|
||||||
|
placeholder="Gebruikersnaam"
|
||||||
|
value={username}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Wachtwoord"
|
||||||
|
value={password}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Bezig…" : "Inloggen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------- Shell --------------------------------- */
|
||||||
|
function Shell({ user, onLogout }) {
|
||||||
const [modules, setModules] = useState([]);
|
const [modules, setModules] = useState([]);
|
||||||
const [active, setActive] = useState("/personen");
|
const [active, setActive] = useState("/personen");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
@ -36,6 +118,14 @@ export default function App() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} finally {
|
||||||
|
onLogout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
|
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
|
||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
@ -43,9 +133,12 @@ export default function App() {
|
||||||
if (!m.enabled) continue;
|
if (!m.enabled) continue;
|
||||||
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
for (const it of m.menu_items) items.push({ ...it, module: m.key });
|
||||||
}
|
}
|
||||||
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);
|
return items.sort((a, b) => a.order - b.order);
|
||||||
}, [modules]);
|
}, [modules, user.is_staff]);
|
||||||
|
|
||||||
const Page = PAGES[active];
|
const Page = PAGES[active];
|
||||||
|
|
||||||
|
|
@ -68,6 +161,10 @@ export default function App() {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="sidebar-user">
|
||||||
|
<span className="muted small">👤 {user.username}</span>
|
||||||
|
<button className="link" onClick={doLogout}>uitloggen</button>
|
||||||
|
</div>
|
||||||
<div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div>
|
<div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -75,7 +172,7 @@ export default function App() {
|
||||||
{error && <div className="banner error">⚠ {error}</div>}
|
{error && <div className="banner error">⚠ {error}</div>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Laden…</p>
|
<p>Laden…</p>
|
||||||
) : active === "/modules" ? (
|
) : active === "/modules" && user.is_staff ? (
|
||||||
<ModuleManager modules={modules} onChange={load} />
|
<ModuleManager modules={modules} onChange={load} />
|
||||||
) : Page ? (
|
) : Page ? (
|
||||||
<Page />
|
<Page />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
// Dunne API-laag richting de Django-backend.
|
// Dunne API-laag richting de Django-backend.
|
||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
|
|
||||||
|
// Lees een cookie (nodig voor het CSRF-token dat Django zet).
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function request(method, url, body) {
|
async function request(method, url, body) {
|
||||||
const opts = { method, headers: {} };
|
const opts = { method, headers: {}, credentials: "same-origin" };
|
||||||
|
// Schrijfacties beveiligt Django met een CSRF-token; stuur het mee.
|
||||||
|
if (method !== "GET") {
|
||||||
|
const token = getCookie("csrftoken");
|
||||||
|
if (token) opts.headers["X-CSRFToken"] = token;
|
||||||
|
}
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
opts.headers["Content-Type"] = "application/json";
|
opts.headers["Content-Type"] = "application/json";
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
|
|
@ -16,13 +27,22 @@ async function request(method, url, body) {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg =
|
const err = new Error(
|
||||||
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis";
|
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis"
|
||||||
throw new Error(msg);
|
);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Authenticatie ----------------------------------------------------------
|
||||||
|
// getMe zet meteen de CSRF-cookie; daarom altijd eerst aanroepen.
|
||||||
|
export const getMe = () => request("GET", "/auth/me/");
|
||||||
|
export const login = (username, password) =>
|
||||||
|
request("POST", "/auth/login/", { username, password });
|
||||||
|
export const logout = () => request("POST", "/auth/logout/");
|
||||||
|
|
||||||
// --- Plugin-framework -----------------------------------------------------
|
// --- Plugin-framework -----------------------------------------------------
|
||||||
export const getModules = () => request("GET", "/modules/");
|
export const getModules = () => request("GET", "/modules/");
|
||||||
export const setModuleState = (key, enabled) =>
|
export const setModuleState = (key, enabled) =>
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,28 @@ const ROLLEN = [
|
||||||
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
|
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Kleine helper voor laden + foutmelding.
|
const WEEKDAGEN = [
|
||||||
function useList(resource) {
|
{ value: 0, label: "Maandag" },
|
||||||
|
{ value: 1, label: "Dinsdag" },
|
||||||
|
{ value: 2, label: "Woensdag" },
|
||||||
|
{ value: 3, label: "Donderdag" },
|
||||||
|
{ value: 4, label: "Vrijdag" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAGTYPES = [
|
||||||
|
{ value: "studiedag", label: "Studiedag" },
|
||||||
|
{ value: "vrije_dag", label: "Vrije dag" },
|
||||||
|
{ value: "vakantie", label: "Vakantie" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function useList(resource, query = "") {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setItems(await listResource(resource));
|
setItems(await listResource(resource, query));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
|
@ -32,7 +44,7 @@ function useList(resource) {
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
}, [resource]);
|
}, [resource, query]);
|
||||||
return { items, error, loading, reload, setError };
|
return { items, error, loading, reload, setError };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +53,159 @@ function Foutmelding({ msg }) {
|
||||||
return <div className="banner error">⚠ {msg}</div>;
|
return <div className="banner error">⚠ {msg}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
|
||||||
|
// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties).
|
||||||
|
function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom }) {
|
||||||
|
const { items, error, loading, reload, setError } = useList(resource);
|
||||||
|
const leeg = kleur
|
||||||
|
? { naam: "", omschrijving: "", kleur: "#2563eb" }
|
||||||
|
: { naam: "", omschrijving: "" };
|
||||||
|
const [form, setForm] = useState(leeg);
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
|
||||||
|
async function opslaan(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editId) await updateResource(resource, editId, form);
|
||||||
|
else await createResource(resource, form);
|
||||||
|
setForm(leeg);
|
||||||
|
setEditId(null);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function verwijder(id) {
|
||||||
|
if (!confirm("Verwijderen?")) return;
|
||||||
|
try {
|
||||||
|
await deleteResource(resource, id);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>{titel}</h1>
|
||||||
|
{uitleg && <p className="muted">{uitleg}</p>}
|
||||||
|
<Foutmelding msg={error} />
|
||||||
|
<form className="form-row" onSubmit={opslaan}>
|
||||||
|
<input
|
||||||
|
placeholder="Naam"
|
||||||
|
value={form.naam}
|
||||||
|
required
|
||||||
|
onChange={(e) => setForm({ ...form, naam: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Omschrijving (optioneel)"
|
||||||
|
value={form.omschrijving}
|
||||||
|
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
|
||||||
|
/>
|
||||||
|
{kleur && (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.kleur}
|
||||||
|
title="Kleur"
|
||||||
|
onChange={(e) => setForm({ ...form, kleur: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button className="btn primary" type="submit">
|
||||||
|
{editId ? "Opslaan" : "Toevoegen"}
|
||||||
|
</button>
|
||||||
|
{editId && (
|
||||||
|
<button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>
|
||||||
|
Annuleren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{loading ? (
|
||||||
|
<p>Laden…</p>
|
||||||
|
) : (
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Naam</th>
|
||||||
|
<th>Omschrijving</th>
|
||||||
|
{telKolom && <th>{telKolom}</th>}
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((it) => (
|
||||||
|
<tr key={it.id}>
|
||||||
|
<td>
|
||||||
|
{kleur && (
|
||||||
|
<span className="kleur-stip" style={{ background: it.kleur }} />
|
||||||
|
)}
|
||||||
|
{it.naam}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{it.omschrijving || "—"}</td>
|
||||||
|
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
className="link"
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(it.id);
|
||||||
|
setForm(
|
||||||
|
kleur
|
||||||
|
? { naam: it.naam, omschrijving: it.omschrijving, kleur: it.kleur }
|
||||||
|
: { naam: it.naam, omschrijving: it.omschrijving }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
bewerken
|
||||||
|
</button>
|
||||||
|
<button className="link danger" onClick={() => verwijder(it.id)}>
|
||||||
|
verwijderen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={telKolom ? 4 : 3} className="muted">Nog niets.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunctiesPage() {
|
||||||
|
return (
|
||||||
|
<CatalogusPage
|
||||||
|
titel="Functies"
|
||||||
|
uitleg="Beheer de functies die medewerkers kunnen hebben (bv. Directie, Logopedist). Medewerkers kunnen meerdere functies hebben."
|
||||||
|
resource="functies"
|
||||||
|
telKolom="Medewerkers"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiviteitenPage() {
|
||||||
|
return (
|
||||||
|
<CatalogusPage
|
||||||
|
titel="Activiteiten"
|
||||||
|
uitleg="Wat er gedaan wordt: rekenen, gym, therapie, pauze … Elke activiteit heeft een kleur voor in het rooster."
|
||||||
|
resource="activiteiten"
|
||||||
|
kleur
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocatiesPage() {
|
||||||
|
return (
|
||||||
|
<CatalogusPage
|
||||||
|
titel="Locaties"
|
||||||
|
uitleg="Lokalen en ruimtes. Worden gebruikt voor conflictdetectie."
|
||||||
|
resource="locaties"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------- Groepen -------------------------------- */
|
/* --------------------------------- Groepen -------------------------------- */
|
||||||
export function GroepenPage() {
|
export function GroepenPage() {
|
||||||
const { items, error, loading, reload, setError } = useList("groepen");
|
const { items, error, loading, reload, setError } = useList("groepen");
|
||||||
|
|
@ -75,48 +240,15 @@ export function GroepenPage() {
|
||||||
<h1>Groepen</h1>
|
<h1>Groepen</h1>
|
||||||
<p className="muted">Klassen waar leerlingen bij horen.</p>
|
<p className="muted">Klassen waar leerlingen bij horen.</p>
|
||||||
<Foutmelding msg={error} />
|
<Foutmelding msg={error} />
|
||||||
|
|
||||||
<form className="form-row" onSubmit={opslaan}>
|
<form className="form-row" onSubmit={opslaan}>
|
||||||
<input
|
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
|
||||||
placeholder="Naam"
|
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
|
||||||
value={form.naam}
|
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
|
||||||
required
|
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
|
||||||
onChange={(e) => setForm({ ...form, naam: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="Omschrijving (optioneel)"
|
|
||||||
value={form.omschrijving}
|
|
||||||
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button className="btn primary" type="submit">
|
|
||||||
{editId ? "Opslaan" : "Toevoegen"}
|
|
||||||
</button>
|
|
||||||
{editId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
onClick={() => {
|
|
||||||
setEditId(null);
|
|
||||||
setForm(leeg);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuleren
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
{loading ? <p>Laden…</p> : (
|
||||||
{loading ? (
|
|
||||||
<p>Laden…</p>
|
|
||||||
) : (
|
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<thead>
|
<thead><tr><th>Naam</th><th>Omschrijving</th><th>Leerlingen</th><th></th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Naam</th>
|
|
||||||
<th>Omschrijving</th>
|
|
||||||
<th>Leerlingen</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((g) => (
|
{items.map((g) => (
|
||||||
<tr key={g.id}>
|
<tr key={g.id}>
|
||||||
|
|
@ -124,28 +256,12 @@ export function GroepenPage() {
|
||||||
<td className="muted">{g.omschrijving || "—"}</td>
|
<td className="muted">{g.omschrijving || "—"}</td>
|
||||||
<td>{g.aantal_leerlingen}</td>
|
<td>{g.aantal_leerlingen}</td>
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
<button
|
<button className="link" onClick={() => { setEditId(g.id); setForm({ naam: g.naam, omschrijving: g.omschrijving }); }}>bewerken</button>
|
||||||
className="link"
|
<button className="link danger" onClick={() => verwijder(g.id)}>verwijderen</button>
|
||||||
onClick={() => {
|
|
||||||
setEditId(g.id);
|
|
||||||
setForm({ naam: g.naam, omschrijving: g.omschrijving });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
bewerken
|
|
||||||
</button>
|
|
||||||
<button className="link danger" onClick={() => verwijder(g.id)}>
|
|
||||||
verwijderen
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <tr><td colSpan="4" className="muted">Nog geen groepen.</td></tr>}
|
||||||
<tr>
|
|
||||||
<td colSpan="4" className="muted">
|
|
||||||
Nog geen groepen.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
@ -157,18 +273,36 @@ export function GroepenPage() {
|
||||||
export function PersonenPage() {
|
export function PersonenPage() {
|
||||||
const { items, error, loading, reload, setError } = useList("personen");
|
const { items, error, loading, reload, setError } = useList("personen");
|
||||||
const [groepen, setGroepen] = useState([]);
|
const [groepen, setGroepen] = useState([]);
|
||||||
const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "" };
|
const [functies, setFuncties] = useState([]);
|
||||||
|
const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "", functies: [] };
|
||||||
const [form, setForm] = useState(leeg);
|
const [form, setForm] = useState(leeg);
|
||||||
const [editId, setEditId] = useState(null);
|
const [editId, setEditId] = useState(null);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listResource("groepen").then(setGroepen).catch(() => {});
|
listResource("groepen").then(setGroepen).catch(() => {});
|
||||||
|
listResource("functies").then(setFuncties).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isLeerling = form.rol === "leerling";
|
||||||
|
|
||||||
|
function toggleFunctie(id) {
|
||||||
|
setForm((f) =>
|
||||||
|
f.functies.includes(id)
|
||||||
|
? { ...f, functies: f.functies.filter((x) => x !== id) }
|
||||||
|
: { ...f, functies: [...f.functies, id] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function opslaan(e) {
|
async function opslaan(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = { ...form, groep: form.rol === "leerling" && form.groep ? form.groep : null };
|
const payload = {
|
||||||
|
voornaam: form.voornaam,
|
||||||
|
achternaam: form.achternaam,
|
||||||
|
rol: form.rol,
|
||||||
|
groep: isLeerling && form.groep ? form.groep : null,
|
||||||
|
functies: isLeerling ? [] : form.functies,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (editId) await updateResource("personen", editId, payload);
|
if (editId) await updateResource("personen", editId, payload);
|
||||||
else await createResource("personen", payload);
|
else await createResource("personen", payload);
|
||||||
|
|
@ -194,96 +328,63 @@ export function PersonenPage() {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1>Personen</h1>
|
<h1>Personen</h1>
|
||||||
<p className="muted">Leerlingen, leerkrachten en ondersteuners.</p>
|
<p className="muted">Leerlingen en medewerkers. Medewerkers kunnen meerdere functies hebben.</p>
|
||||||
<Foutmelding msg={error} />
|
<Foutmelding msg={error} />
|
||||||
|
|
||||||
<form className="form-row" onSubmit={opslaan}>
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
<input
|
<div className="form-row">
|
||||||
placeholder="Voornaam"
|
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
|
||||||
value={form.voornaam}
|
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
|
||||||
required
|
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
|
||||||
onChange={(e) => setForm({ ...form, voornaam: e.target.value })}
|
{ROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="Achternaam"
|
|
||||||
value={form.achternaam}
|
|
||||||
required
|
|
||||||
onChange={(e) => setForm({ ...form, achternaam: e.target.value })}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={form.rol}
|
|
||||||
onChange={(e) => setForm({ ...form, rol: e.target.value })}
|
|
||||||
>
|
|
||||||
{ROLLEN.map((r) => (
|
|
||||||
<option key={r.value} value={r.value}>
|
|
||||||
{r.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</select>
|
</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">
|
<div className="form-row">
|
||||||
{editId ? "Opslaan" : "Toevoegen"}
|
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
|
||||||
</button>
|
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
|
||||||
{editId && (
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
onClick={() => {
|
|
||||||
setEditId(null);
|
|
||||||
setForm(leeg);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuleren
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="filterbar">
|
<div className="filterbar">
|
||||||
<span className="muted">Filter:</span>
|
<span className="muted">Filter:</span>
|
||||||
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>
|
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>Alle</button>
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
{ROLLEN.map((r) => (
|
{ROLLEN.map((r) => (
|
||||||
<button
|
<button key={r.value} className={filter === r.value ? "chip on" : "chip"} onClick={() => setFilter(r.value)}>{r.label}</button>
|
||||||
key={r.value}
|
|
||||||
className={filter === r.value ? "chip on" : "chip"}
|
|
||||||
onClick={() => setFilter(r.value)}
|
|
||||||
>
|
|
||||||
{r.label}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? <p>Laden…</p> : (
|
||||||
<p>Laden…</p>
|
|
||||||
) : (
|
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<thead>
|
<thead><tr><th>Naam</th><th>Rol</th><th>Groep / Functies</th><th></th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Naam</th>
|
|
||||||
<th>Rol</th>
|
|
||||||
<th>Groep</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{zichtbaar.map((p) => (
|
{zichtbaar.map((p) => (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td>{p.volledige_naam}</td>
|
<td>{p.volledige_naam}</td>
|
||||||
<td>{p.rol_label}</td>
|
<td>{p.rol_label}</td>
|
||||||
<td className="muted">{p.groep_naam || "—"}</td>
|
<td className="muted">
|
||||||
|
{p.rol === "leerling"
|
||||||
|
? (p.groep_naam || "—")
|
||||||
|
: (p.functies_namen.length ? p.functies_namen.map((x) => x.naam).join(", ") : "—")}
|
||||||
|
</td>
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
<button
|
<button
|
||||||
className="link"
|
className="link"
|
||||||
|
|
@ -294,24 +395,17 @@ export function PersonenPage() {
|
||||||
achternaam: p.achternaam,
|
achternaam: p.achternaam,
|
||||||
rol: p.rol,
|
rol: p.rol,
|
||||||
groep: p.groep || "",
|
groep: p.groep || "",
|
||||||
|
functies: p.functies_namen.map((x) => x.id),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
bewerken
|
bewerken
|
||||||
</button>
|
</button>
|
||||||
<button className="link danger" onClick={() => verwijder(p.id)}>
|
<button className="link danger" onClick={() => verwijder(p.id)}>verwijderen</button>
|
||||||
verwijderen
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{zichtbaar.length === 0 && (
|
{zichtbaar.length === 0 && <tr><td colSpan="4" className="muted">Geen personen.</td></tr>}
|
||||||
<tr>
|
|
||||||
<td colSpan="4" className="muted">
|
|
||||||
Geen personen.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
@ -364,118 +458,421 @@ export function SubgroepenPage() {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1>Subgroepen</h1>
|
<h1>Subgroepen</h1>
|
||||||
<p className="muted">
|
<p className="muted">Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).</p>
|
||||||
Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).
|
|
||||||
</p>
|
|
||||||
<Foutmelding msg={error} />
|
<Foutmelding msg={error} />
|
||||||
|
|
||||||
<form className="form-card" onSubmit={opslaan}>
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input
|
<input placeholder="Naam" value={form.naam} required onChange={(e) => setForm({ ...form, naam: e.target.value })} />
|
||||||
placeholder="Naam"
|
<input placeholder="Omschrijving (optioneel)" value={form.omschrijving} onChange={(e) => setForm({ ...form, omschrijving: e.target.value })} />
|
||||||
value={form.naam}
|
|
||||||
required
|
|
||||||
onChange={(e) => setForm({ ...form, naam: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="Omschrijving (optioneel)"
|
|
||||||
value={form.omschrijving}
|
|
||||||
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="leden-keuze">
|
<div className="leden-keuze">
|
||||||
<div className="muted small">Leden (leerlingen):</div>
|
<div className="muted small">Leden (leerlingen):</div>
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
{leerlingen.map((l) => (
|
{leerlingen.map((l) => (
|
||||||
<button
|
<button type="button" key={l.id} className={form.leden.includes(l.id) ? "chip on" : "chip"} onClick={() => toggleLid(l.id)}>{l.volledige_naam}</button>
|
||||||
type="button"
|
|
||||||
key={l.id}
|
|
||||||
className={form.leden.includes(l.id) ? "chip on" : "chip"}
|
|
||||||
onClick={() => toggleLid(l.id)}
|
|
||||||
>
|
|
||||||
{l.volledige_naam}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
{leerlingen.length === 0 && (
|
{leerlingen.length === 0 && <span className="muted small">Nog geen leerlingen ingevoerd.</span>}
|
||||||
<span className="muted small">Nog geen leerlingen ingevoerd.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<button className="btn primary" type="submit">
|
<button className="btn primary" type="submit">{editId ? "Opslaan" : "Toevoegen"}</button>
|
||||||
{editId ? "Opslaan" : "Toevoegen"}
|
{editId && <button type="button" className="btn" onClick={() => { setEditId(null); setForm(leeg); }}>Annuleren</button>}
|
||||||
</button>
|
|
||||||
{editId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
onClick={() => {
|
|
||||||
setEditId(null);
|
|
||||||
setForm(leeg);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Annuleren
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{loading ? <p>Laden…</p> : (
|
||||||
{loading ? (
|
|
||||||
<p>Laden…</p>
|
|
||||||
) : (
|
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<thead>
|
<thead><tr><th>Naam</th><th>Leden</th><th></th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Naam</th>
|
|
||||||
<th>Leden</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
<td>
|
<td>{s.naam}{s.omschrijving && <div className="muted small">{s.omschrijving}</div>}</td>
|
||||||
{s.naam}
|
<td><span className="badge">{s.aantal_leden}</span> <span className="muted small">{s.leden_namen.map((x) => x.naam).join(", ")}</span></td>
|
||||||
{s.omschrijving && (
|
|
||||||
<div className="muted small">{s.omschrijving}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge">{s.aantal_leden}</span>{" "}
|
|
||||||
<span className="muted small">
|
|
||||||
{s.leden_namen.map((x) => x.naam).join(", ")}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
<button
|
<button className="link" onClick={() => { setEditId(s.id); setForm({ naam: s.naam, omschrijving: s.omschrijving, leden: s.leden_namen.map((x) => x.id) }); }}>bewerken</button>
|
||||||
className="link"
|
<button className="link danger" onClick={() => verwijder(s.id)}>verwijderen</button>
|
||||||
onClick={() => {
|
|
||||||
setEditId(s.id);
|
|
||||||
setForm({
|
|
||||||
naam: s.naam,
|
|
||||||
omschrijving: s.omschrijving,
|
|
||||||
leden: s.leden_namen.map((x) => x.id),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
bewerken
|
|
||||||
</button>
|
|
||||||
<button className="link danger" onClick={() => verwijder(s.id)}>
|
|
||||||
verwijderen
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <tr><td colSpan="3" className="muted">Nog geen subgroepen.</td></tr>}
|
||||||
<tr>
|
|
||||||
<td colSpan="3" className="muted">
|
|
||||||
Nog geen subgroepen.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------- Tijdsloten ------------------------------- */
|
||||||
|
export function TijdslotenPage() {
|
||||||
|
const { items, error, loading, reload, setError } = useList("tijdsloten");
|
||||||
|
const leeg = { naam: "", dag: 0, start_tijd: "08:30", eind_tijd: "10:00", volgorde: 1 };
|
||||||
|
const [form, setForm] = useState(leeg);
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
|
||||||
|
async function opslaan(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editId) await updateResource("tijdsloten", editId, form);
|
||||||
|
else await createResource("tijdsloten", form);
|
||||||
|
setForm(leeg);
|
||||||
|
setEditId(null);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function verwijder(id) {
|
||||||
|
if (!confirm("Dit tijdslot verwijderen?")) return;
|
||||||
|
try {
|
||||||
|
await deleteResource("tijdsloten", id);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Tijdsloten</h1>
|
||||||
|
<p className="muted">Vaste tijdblokken per schooldag (bv. Blok 1, maandag 08: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; }
|
.leden-keuze { margin: 4px 0 12px; }
|
||||||
|
|
||||||
.badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 600; }
|
.badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* --- Fase 2 --- */
|
||||||
|
.kleur-stip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 8px; vertical-align: middle; border: 1px solid rgba(0,0,0,.1); }
|
||||||
|
.veld { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||||
|
.veld input, .veld select { min-width: auto; }
|
||||||
|
.rij-actief { background: var(--brand-soft); }
|
||||||
|
input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; }
|
||||||
|
|
||||||
|
.subpaneel { margin-top: 24px; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: #fafbff; }
|
||||||
|
.subpaneel h2 { margin: 0 0 12px; font-size: 18px; }
|
||||||
|
|
||||||
|
.tag.dag-studiedag { background: #e0e7ff; color: #3730a3; }
|
||||||
|
.tag.dag-vrije_dag { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.tag.dag-vakantie { background: #dcfce7; color: #166534; }
|
||||||
|
|
||||||
|
.rooster-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 20px; }
|
||||||
|
.rooster-kolom { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px; min-height: 80px; }
|
||||||
|
.rooster-dag { font-weight: 600; font-size: 13px; text-align: center; padding: 6px 0; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
|
||||||
|
.rooster-blok { border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: #fff; }
|
||||||
|
.rb-top { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
|
||||||
|
.link.small { font-size: 16px; line-height: 1; padding: 0 4px; }
|
||||||
|
@media (max-width: 900px) { .rooster-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* --- Login --- */
|
||||||
|
.center-msg { text-align: center; margin-top: 20vh; color: var(--muted); }
|
||||||
|
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 28px; width: 320px; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
box-shadow: 0 8px 30px rgba(31, 41, 55, .06);
|
||||||
|
}
|
||||||
|
.login-brand { margin: 0 0 4px; justify-content: center; }
|
||||||
|
.login-card input { width: 100%; }
|
||||||
|
.login-card .btn { width: 100%; }
|
||||||
|
|
||||||
|
/* --- Gebruiker in zijbalk --- */
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 8px; padding: 10px 8px 0; border-top: 1px solid var(--line); margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue