Fase 0 + Docker-stack voor test-server

This commit is contained in:
bes-r 2026-06-09 22:46:23 +02:00
commit ac634d343f
51 changed files with 1412 additions and 0 deletions

23
.env.example Normal file
View file

@ -0,0 +1,23 @@
# Kopieer dit bestand naar .env en vul echte waarden in.
# cp .env.example .env
# Het .env-bestand bevat geheimen en hoort NIET in git (staat in .gitignore).
# --- Django ---------------------------------------------------------------
# Genereer een sterke sleutel, bv.:
# python -c "import secrets; print(secrets.token_urlsafe(50))"
DJANGO_SECRET_KEY=vervang-mij-door-een-lange-willekeurige-string
# 0 = uit (zo hoort het op de test-/productieserver). 1 alleen lokaal debuggen.
DJANGO_DEBUG=0
# Hostnaam(en) waarop de server bereikbaar is, komma-gescheiden.
# Vervang door de echte naam/IP van je test-server.
DJANGO_ALLOWED_HOSTS=rooster-test.example.nl,localhost,127.0.0.1
# Volledige origin(s) inclusief http(s):// voor CSRF achter nginx.
DJANGO_CSRF_TRUSTED_ORIGINS=http://rooster-test.example.nl:8080
# --- PostgreSQL -----------------------------------------------------------
POSTGRES_DB=rooster
POSTGRES_USER=rooster
POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Geheimen / omgeving
.env
# Python
__pycache__/
*.pyc
backend/db.sqlite3
backend/staticfiles/
.venv/
venv/
# Node
frontend/node_modules/
frontend/dist/
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db

97
DEPLOY.md Normal file
View file

@ -0,0 +1,97 @@
# Draaien op de test-server (Docker)
De stack bestaat uit drie containers:
| Container | Rol |
|-----------|-----|
| `db` | PostgreSQL (data in een Docker-volume) |
| `backend` | Django via gunicorn |
| `nginx` | serveert de React-frontend en stuurt `/api` + `/admin` door naar de backend |
Je hoeft op de server **niets** te installeren behalve Docker zelf (met de
`docker compose`-plugin). Python, Node, Postgres enz. zitten in de images.
## Eenmalig: server klaarzetten
1. Zorg dat Docker + Compose op de test-server staan:
```bash
docker --version
docker compose version
```
2. Haal de code op:
```bash
git clone <jouw-forgejo-url>/Roostersoftware.git
cd Roostersoftware
```
3. Maak het `.env`-bestand aan vanuit het voorbeeld en vul echte waarden in:
```bash
cp .env.example .env
nano .env
```
Belangrijk om aan te passen:
- `DJANGO_SECRET_KEY` — genereer met
`python3 -c "import secrets; print(secrets.token_urlsafe(50))"`
- `DJANGO_ALLOWED_HOSTS` — de hostnaam/IP van de test-server
- `DJANGO_CSRF_TRUSTED_ORIGINS``http://<host>:8080` (of je echte URL)
- `POSTGRES_PASSWORD` — een sterk wachtwoord
`.env` staat in `.gitignore` en komt dus **niet** in git — dat hoort zo.
## Starten
```bash
docker compose up -d --build
```
Wat er dan gebeurt: Postgres start, de backend wacht tot de database er is,
voert automatisch de migraties en `collectstatic` uit, en gunicorn + nginx
komen op. De app is bereikbaar op:
```
http://<server-host>:8080
```
(Poort `8080` staat ingesteld in `docker-compose.yml` bij de `nginx`-service;
pas hem daar aan als die poort bezet is.)
### Eerste keer: beheerder aanmaken
```bash
docker compose exec backend python manage.py createsuperuser
```
Daarna kun je inloggen op `http://<server-host>:8080/admin/`.
## Updaten na nieuwe code
```bash
git pull
docker compose up -d --build
```
Migraties draaien automatisch mee bij het opstarten van de backend.
## Handige commando's
| Doel | Commando |
|------|----------|
| Logs volgen | `docker compose logs -f` |
| Alleen backend-logs | `docker compose logs -f backend` |
| Status containers | `docker compose ps` |
| Stoppen | `docker compose down` |
| Stoppen **incl. database wissen** | `docker compose down -v` (let op: gegevens weg!) |
| Migratie handmatig | `docker compose exec backend python manage.py migrate` |
| Django-shell | `docker compose exec backend python manage.py shell` |
## Aandachtspunten
- **HTTPS/domein:** deze opzet draait op poort 8080 zonder TLS. Voor productie
zet je er een reverse proxy (bv. Caddy of Traefik) vóór die HTTPS regelt, of
je breidt de nginx-config uit met certificaten. De nginx-container is daar al
het logische punt voor.
- **Backups:** de database leeft in het volume `pgdata`. Maak hiervan backups,
bijvoorbeeld met `docker compose exec db pg_dump -U rooster rooster > backup.sql`.
- **`DJANGO_DEBUG` blijft 0** op de server. Zet hem nooit op 1 in productie.
- **Test eerst hier, dan productie:** bouw het image één keer, test op deze
server, en draai exact dezelfde code/compose op de productieserver met een
eigen `.env`.

118
PLAN.md Normal file
View file

@ -0,0 +1,118 @@
# Plan Roostersoftware Speciaal Onderwijs
Modulair, flexibel roostersysteem voor het speciaal onderwijs. Kleine aantallen, veel flexibiliteit per leerling, groep, subgroep, leerkracht en ondersteuner.
## 1. Uitgangspunten
- **Type**: webapplicatie (browser, werkt op pc en tablet).
- **Bouw**: Claude bouwt het leeuwendeel; jij stuurt, test en geeft richting.
- **MVP-focus**: (1) basisroostering, (2) flexibiliteit & uitzonderingen, (3) conflictdetectie.
- **Schaal**: één school, kleine aantallen — dus eenvoud boven complexe infrastructuur, maar het datamodel moet de flexibiliteit echt aankunnen.
## 2. Kernidee: het rooster als bouwstenen
De kracht voor speciaal onderwijs zit in *modulariteit*. In plaats van één vast groepsrooster bouwen we het rooster op uit losse **roosterblokken** die je aan wie dan ook kunt koppelen. Een blok = "wie doet wat, wanneer, waar, met wie".
Een blok kan toegewezen worden aan:
- een hele **groep**,
- een **subgroep** (deel van een groep),
- een **individuele leerling** (afwijking van het groepsrooster),
- met daaraan gekoppeld één of meer **leerkrachten** en/of **ondersteuners**.
Het rooster van een individuele leerling is dan: het groepsrooster **plus/min** zijn persoonlijke uitzonderingen. Zo houd je kleine aantallen overzichtelijk én volledig flexibel.
## 3. Datamodel (de modules)
Onafhankelijke, herbruikbare entiteiten:
- **Persoon** basis voor leerling, leerkracht, ondersteuner (één tabel, met rol/type).
- Leerling: hoort bij een groep, kan in subgroepen zitten.
- Leerkracht / Praktijkondersteuner / Klassenondersteuner: inzetbaarheid, beschikbaarheid.
- **Groep** klas; bevat leerlingen.
- **Subgroep** flexibele deelverzameling van leerlingen (bv. niveaugroepje rekenen), kan groep-overstijgend zijn.
- **Activiteit/Vak** wat er gedaan wordt (rekenen, gym, therapie, pauze…).
- **Locatie** lokaal/ruimte (optioneel maar handig voor conflictdetectie).
- **Tijdslot** dag + begin/eindtijd. We werken met **vaste schooltijden** (een instelbaar dagritme), maar tijdslots blijven aanpasbaar.
- **Roosterblok** de spil: koppelt activiteit + tijdslot + doelgroep (groep/subgroep/leerling) + begeleiders + locatie.
- **Uitzondering** afwijking voor een individu of dag (afwezigheid, vervanging, eenmalige wijziging).
- **Schooljaar / Kalender** jaaroverzicht met studiedagen, vrije dagen en vakanties. Een vrije/vakantiedag schakelt automatisch de roosterblokken op die dag uit, zodat het weekrooster en het jaaroverzicht altijd kloppen.
Modulair betekent: elke entiteit staat los en is later uitbreidbaar (bv. ouders, vervoer, doelen/handelingsplan) zonder de kern te herbouwen.
## 4. Conflictdetectie
Bij het plaatsen/wijzigen van een blok controleert het systeem automatisch op:
- **Dubbele inzet leerkracht/ondersteuner** staat iemand op twee plekken tegelijk.
- **Dubbele inzet leerling** zit een leerling in twee blokken op hetzelfde moment.
- **Locatiebotsing** twee blokken in dezelfde ruimte tegelijk.
- **Onderbezetting** blok zonder begeleider (waarschuwing, niet blokkerend).
Conflicten tonen we als duidelijke waarschuwingen; jij beslist of het mag.
## 5. Technische opzet
- **Backend**: Django (Python) met Django REST Framework. Django's **"apps"** vormen ons modulesysteem: elke functie is een losse, in-/uitschakelbare app.
- **Frontend**: React het roosterraster en de schermen.
- **Database**: SQLite om te starten (geen serverbeheer); later 1-op-1 te upgraden naar PostgreSQL.
- **Auth/rechten**: Django's ingebouwde gebruikers- en rechtensysteem (basis aanwezig, volledig uitgerold in de multi-user-module).
- **Draaien**: lokaal te starten; later te hosten op een eenvoudige server of clouddienst.
## 5b. Modulaire architectuur — een echt plugin-framework (verkoopargument)
Modulariteit is een **verkoopargument**: een school koopt de kern en schakelt naar wens modules bij. Modules moeten daarom écht aan/uit te zetten zijn — niet alleen nette code, maar instelbaar per school via een beheerscherm.
**Het plugin-framework (onderdeel van de kern):**
- **Moduleregister** elke module meldt zichzelf aan met naam, beschrijving, versie en afhankelijkheden.
- **Aan/uit per school** een beheerder zet modules in een **beheerscherm** in of uit; uitgeschakelde modules zijn onzichtbaar (geen menu's, geen schermen, geen API).
- **Vaste aanhechtpunten (hooks)** de kern biedt nette uitbreidpunten (menu, schermen, rooster-acties, exports) waar een module zich op aanhaakt, zónder de kern te wijzigen.
- **Afhankelijkheden & licenties** het register bewaakt afhankelijkheden tussen modules en biedt een haak voor licenties/feature-flags, zodat modules per school verkocht en geactiveerd kunnen worden.
Zo kun je het product gelaagd aanbieden: iedereen krijgt de kern, en modules als printen, ouderportaal of multi-user zijn losse, activeerbare uitbreidingen.
**Kern (altijd aan):** personen, groepen, subgroepen, activiteiten, locaties, tijdslots, roosterblokken, schooljaar/kalender, conflictdetectie + het plugin-framework zelf.
**Modules (in-/uitschakelbaar per school):**
- **Printen/Exporteren** roosters als PDF/print, per groep/leerling/leerkracht.
- **Multi-user & toegang** inloggen met rollen: leerling, ouder, leerkracht, ondersteuner en diverse lagen collega's (bv. directie, administratie), elk met eigen rechten en weergave.
- *(later mogelijk)* ouderportaal, vervoer, doelen/handelingsplan, meldingen.
## 6. Gefaseerde aanpak
**Fase 0 Fundament + plugin-framework**
Django-project, React-frontend, database, basis-UI-skelet én het plugin-framework: moduleregister, aan/uit-beheerscherm en uitbreidpunten. Dit framework is de ruggengraat van het verkoopargument, dus het komt meteen in fase 0.
**Fase 1 Basisgegevens (CRUD)**
Personen (leerlingen, leerkrachten, ondersteuners), groepen, subgroepen, activiteiten, locaties invoeren en beheren.
**Fase 2 Schooljaar & roostering**
Schooljaarkalender (studiedagen/vrije dagen/vakanties) + roosterblokken toewijzen aan groep/subgroep/leerling met begeleiders. Weekrooster tonen per groep, per leerling en per leerkracht.
**Fase 3 Flexibiliteit & uitzonderingen**
Individuele afwijkingen, subgroep-roosters, afwezigheid/vervanging. Leerlingrooster = groepsrooster + uitzonderingen. Vrije/vakantiedagen schakelen blokken automatisch uit.
**Fase 4 Conflictdetectie**
Automatische controle bij plaatsen/wijzigen, met heldere waarschuwingen.
**Fase 5 Module: Printen/Exporteren**
Eerste echte losse module — roosters als PDF/print, per groep/leerling/leerkracht.
**Fase 6 Module: Multi-user & toegang**
Inloggen met rollen (leerling, ouder, leerkracht, ondersteuner, collega-lagen), elk met eigen rechten en weergave.
Na elke fase heb je een werkende versie die je kunt uitproberen; we passen aan op basis van wat je ziet. De modules (fase 56) bewijzen meteen dat de modulaire opzet werkt.
## 7. Beslissingen (vastgelegd)
- **Techstack**: Django (backend, modules via apps) + React (frontend) + SQLite → later PostgreSQL.
- **Tijden**: vaste schooltijden, plus een jaaroverzicht met studiedagen, vrije dagen en vakanties.
- **Locaties**: meenemen in de MVP (ook voor conflictdetectie).
- **Multi-user**: niet in de MVP, maar als losse module gebouwd (leerling-, ouder- en gelaagde collega-logins).
- **Modulariteit**: een echt plugin-framework als verkoopargument — modules per school in-/uitschakelbaar via een beheerscherm, met haak voor licenties. Komt al in fase 0.
## 8. Volgende stap
Ik start met **Fase 0**: Django-project met modulestructuur, React-frontend en database opzetten — zodat we snel een werkend skelet hebben om op verder te bouwen. Zeg het woord en ik begin.

71
README.md Normal file
View file

@ -0,0 +1,71 @@
# Roostersoftware
Modulair, flexibel roostersysteem voor het speciaal onderwijs. Zie `PLAN.md`
voor de visie en de fasering.
Deze map bevat het resultaat van **Fase 0**: het fundament en een echt
plugin-framework waarmee modules per school in- en uitgeschakeld kunnen worden.
## Wat er nu staat
- **Backend** (`backend/`) — Django + Django REST Framework.
- `plugins/` — het plugin-framework: een moduleregister, opslag van de
aan/uit-status per module en een beheer-API.
- `core/` — de kern (altijd actief). Domeinmodellen (personen, groepen,
roosterblokken, kalender) volgen in fase 12.
- `modules/printing/` en `modules/accounts/` — twee voorbeeldmodules die
zich bij opstarten aanmelden bij het register.
- **Frontend** (`frontend/`) — React (Vite) met een zijbalk die alleen menu's
toont van ingeschakelde modules, plus een scherm **Modulebeheer** om modules
aan/uit te zetten.
## Hoe modulariteit werkt (het verkoopargument)
1. Een module is een gewone Django-app met een `apps.py` die in `ready()` een
`ModuleSpec` registreert (naam, beschrijving, versie, afhankelijkheden en
menu-items).
2. Het register weet wélke modules bestaan; de database onthoudt of ze **aan**
staan. Een beheerder schakelt ze per school in/uit via Modulebeheer.
3. Uitgeschakelde modules leveren geen menu's of schermen — de kern blijft
overal gelijk, modules zijn los bij te schakelen.
Een nieuwe module toevoegen = nieuwe app maken, één regel in
`MODULE_APPS` (in `backend/config/settings.py`), klaar.
## Starten
### Backend (Django, poort 8000)
```bash
cd backend
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser # optioneel, voor /admin/
python manage.py runserver
```
API-eindpunten:
- `GET /api/modules/` — alle modules met status.
- `POST /api/modules/<key>/state/` — body `{"enabled": true|false}`.
- `GET /api/info/` — korte systeem-/gezondheidsinfo.
- `/admin/` — Django-admin (modulestatus ook hier instelbaar).
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
> `ROOSTER_DB_PATH` naar een lokaal pad, of pas `DATABASES` aan.
### Frontend (React, poort 5173)
```bash
cd frontend
npm install
npm run dev
```
De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
aan/uit te zetten.
## Volgende stap (Fase 1)
Kerndomein bouwen: personen (leerlingen, leerkrachten, ondersteuners),
groepen en subgroepen — met beheerschermen.

8
backend/.dockerignore Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
db.sqlite3
db.sqlite3-journal
staticfiles/
.venv/
venv/
*.sqlite3

27
backend/Dockerfile Normal file
View file

@ -0,0 +1,27 @@
# Backend: Django achter gunicorn.
FROM python:3.12-slim
# Geen .pyc-bestanden, ongebufferde logs (handig in Docker).
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Systeempakketten die psycopg/gunicorn soms nodig hebben.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Eerst requirements (betere build-cache), dan de rest.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# entrypoint wacht op de database, migreert en verzamelt statics.
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
# 3 workers is ruim voldoende voor de kleine aantallen in het SO.
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

View file

6
backend/config/asgi.py Normal file
View file

@ -0,0 +1,6 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

153
backend/config/settings.py Normal file
View file

@ -0,0 +1,153 @@
"""
Django-instellingen voor de Roostersoftware.
De architectuur draait om een kleine KERN plus losse MODULES.
Modules zijn gewone Django-apps die zich bij opstarten aanmelden bij het
plugin-register (zie de app 'plugins'). Of een module zichtbaar/actief is,
bepaalt een beheerder per school via een aan/uit-vlag in de database.
"""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# --- Beveiliging ----------------------------------------------------------
# Instellingen komen uit omgevingsvariabelen, met veilige dev-defaults.
# In productie/op de test-server zet je deze via het .env-bestand.
def _env_bool(name, default):
return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
# Dev-default alleen voor lokaal werken; ZET DJANGO_SECRET_KEY in productie.
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me-in-productie")
# Standaard False (veilig). Lokaal zet je DJANGO_DEBUG=1.
DEBUG = _env_bool("DJANGO_DEBUG", False)
# Komma-gescheiden lijst hosts, bv. "rooster-test.example.nl,localhost".
ALLOWED_HOSTS = [h.strip() for h in os.environ.get(
"DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1"
).split(",") if h.strip()]
# Nodig achter nginx voor POST-verzoeken naar /admin en de API.
CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.environ.get(
"DJANGO_CSRF_TRUSTED_ORIGINS", ""
).split(",") if o.strip()]
# --- Applicaties ----------------------------------------------------------
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
THIRD_PARTY_APPS = [
"rest_framework",
"corsheaders",
]
# De kern van het roostersysteem + het plugin-framework.
CORE_APPS = [
"plugins", # het plugin-framework zelf (moduleregister + beheer-API)
"core", # kerndomein (altijd aan)
]
# Losse modules. Eén regel toevoegen = module beschikbaar maken.
# Aan/uit zetten gebeurt daarna per school via het beheerscherm.
MODULE_APPS = [
"modules.printing", # Printen/Exporteren
"modules.accounts", # Multi-user & toegang
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# --- Database -------------------------------------------------------------
# Als POSTGRES_DB gezet is (test-server/productie) gebruiken we PostgreSQL;
# anders valt het terug op SQLite voor lokaal ontwikkelen.
if os.environ.get("POSTGRES_DB"):
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ.get("POSTGRES_USER", "rooster"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("ROOSTER_DB_PATH", str(BASE_DIR / "db.sqlite3")),
}
}
# --- Wachtwoordvalidatie --------------------------------------------------
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# --- Internationalisatie --------------------------------------------------
LANGUAGE_CODE = "nl-nl"
TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True
USE_TZ = True
# --- Statische bestanden --------------------------------------------------
# collectstatic verzamelt hier; nginx serveert deze map op /static/
# (o.a. de CSS van de Django-admin).
STATIC_URL = "/static/"
STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles"))
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- API ------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
}
# Frontend (React dev-server) mag de API benaderen.
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]

View file

@ -0,0 +1,14 @@
"""Alleen voor geautomatiseerd testen in de sandbox.
SQLite kan in de testomgeving niet op de gekoppelde projectmap draaien,
daarom wijst deze test-config de database naar een lokaal pad. In productie
gebruik je gewoon config.settings.
"""
from config.settings import * # noqa: F401,F403
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/tmp/rooster_test.sqlite3",
}
}

11
backend/config/urls.py Normal file
View file

@ -0,0 +1,11 @@
"""Hoofd-URL-configuratie."""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
# Plugin-framework: lijst + aan/uit zetten van modules.
path("api/", include("plugins.urls")),
# Kern-API.
path("api/", include("core.urls")),
]

6
backend/config/wsgi.py Normal file
View file

@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

0
backend/core/__init__.py Normal file
View file

35
backend/core/apps.py Normal file
View file

@ -0,0 +1,35 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"
verbose_name = "Kern (roostering)"
def ready(self):
"""Meld de kern aan bij het plugin-register.
De kern is altijd actief (core=True) en levert de vaste navigatie:
rooster, personen en groepen. In latere fases vullen we deze schermen.
"""
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="core",
name="Roosterkern",
description=(
"Het hart van het systeem: personen, groepen, subgroepen, "
"activiteiten, locaties, tijdslots, roosterblokken, "
"schooljaarkalender en conflictdetectie."
),
version="0.1.0",
category="Kern",
core=True,
menu_items=(
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Personen", "/personen", icon="users", order=20),
MenuItem("Groepen", "/groepen", icon="layers", order=30),
),
)
)

View file

3
backend/core/models.py Normal file
View file

@ -0,0 +1,3 @@
# Kerndomeinmodellen (personen, groepen, roosterblokken, kalender ...)
# worden in Fase 1 en 2 toegevoegd. In Fase 0 ligt de nadruk op het
# fundament en het plugin-framework.

7
backend/core/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("info/", views.info, name="info"),
]

19
backend/core/views.py Normal file
View file

@ -0,0 +1,19 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from plugins import services
@api_view(["GET"])
def info(request):
"""Korte systeeminfo + welke modules actief zijn. Handig als gezondheidscheck."""
services.sync_states()
modules = services.describe_all()
return Response(
{
"naam": "Roostersoftware",
"fase": "0 - fundament + plugin-framework",
"modules_totaal": len(modules),
"modules_actief": sum(1 for m in modules if m["enabled"]),
}
)

BIN
backend/db.sqlite3-journal Normal file

Binary file not shown.

32
backend/entrypoint.sh Normal file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Wordt bij elke containerstart uitgevoerd, vóór gunicorn.
set -e
# Wacht tot PostgreSQL bereikbaar is (alleen als we Postgres gebruiken).
if [ -n "$POSTGRES_DB" ]; then
echo "Wachten op PostgreSQL op ${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}..."
python <<'PY'
import os, time, socket
host = os.environ.get("POSTGRES_HOST", "db")
port = int(os.environ.get("POSTGRES_PORT", "5432"))
for _ in range(60):
try:
with socket.create_connection((host, port), timeout=2):
print("Database is bereikbaar.")
break
except OSError:
time.sleep(1)
else:
raise SystemExit("Database niet bereikbaar na 60s.")
PY
fi
# Migraties uitvoeren en statische bestanden verzamelen.
echo "Migraties uitvoeren..."
python manage.py migrate --noinput
echo "Statische bestanden verzamelen..."
python manage.py collectstatic --noinput
# Geef door aan het CMD (gunicorn).
exec "$@"

20
backend/manage.py Normal file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env python
"""Django command-line utility voor administratieve taken."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Kon Django niet importeren. Staat de virtual environment aan "
"en is Django geïnstalleerd? (pip install -r requirements.txt)"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

View file

View file

@ -0,0 +1,30 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.accounts"
label = "module_accounts"
verbose_name = "Module: Multi-user & toegang"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="accounts",
name="Multi-user & toegang",
description=(
"Inloggen met rollen: leerling, ouder, leerkracht, "
"ondersteuner en collega-lagen (directie, administratie), "
"elk met eigen rechten en weergave."
),
version="0.1.0",
category="Toegang",
core=False,
default_enabled=False,
menu_items=(
MenuItem("Gebruikers", "/gebruikers", icon="key", order=300),
),
)
)

View file

View file

@ -0,0 +1,29 @@
from django.apps import AppConfig
class PrintingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.printing"
label = "module_printing"
verbose_name = "Module: Printen/Exporteren"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="printing",
name="Printen / Exporteren",
description=(
"Roosters afdrukken of als PDF exporteren, per groep, "
"leerling of leerkracht."
),
version="0.1.0",
category="Uitvoer",
core=False,
default_enabled=True,
menu_items=(
MenuItem("Afdrukken", "/afdrukken", icon="printer", order=200),
),
)
)

View file

10
backend/plugins/admin.py Normal file
View file

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import ModuleState
@admin.register(ModuleState)
class ModuleStateAdmin(admin.ModelAdmin):
list_display = ("key", "enabled", "updated_at")
list_filter = ("enabled",)
list_editable = ("enabled",)

7
backend/plugins/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class PluginsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plugins"
verbose_name = "Plugin-framework"

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-06-09 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ModuleState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=64, unique=True, verbose_name='modulesleutel')),
('enabled', models.BooleanField(default=False, verbose_name='ingeschakeld')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'modulestatus',
'verbose_name_plural': 'modulestatussen',
'ordering': ['key'],
},
),
]

View file

25
backend/plugins/models.py Normal file
View file

@ -0,0 +1,25 @@
"""
Opslag van de aan/uit-status van modules per installatie (school).
Het register (registry.py) weet wélke modules bestaan; dit model onthoudt
of ze aan staan. Zo blijft de instelling bewaard en kan een beheerder modules
via het beheerscherm in- en uitschakelen.
"""
from django.db import models
class ModuleState(models.Model):
"""De aan/uit-status van één module."""
key = models.CharField("modulesleutel", max_length=64, unique=True)
enabled = models.BooleanField("ingeschakeld", default=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "modulestatus"
verbose_name_plural = "modulestatussen"
ordering = ["key"]
def __str__(self) -> str:
status = "aan" if self.enabled else "uit"
return f"{self.key} ({status})"

View file

@ -0,0 +1,66 @@
"""
Het plugin-register: het hart van de modulaire architectuur.
Elke module (een Django-app) maakt zichzelf bij opstarten bekend door een
`ModuleSpec` te registreren. Het register houdt bij wélke modules er bestaan,
wat ze aanbieden (menu-items / uitbreidpunten) en hoe ze van elkaar afhangen.
Of een module daadwerkelijk AAN staat, wordt per school bewaard in de database
(zie models.ModuleState) en is via het beheerscherm in te stellen. Zo is
modulariteit een echt verkoopargument: de kern is overal gelijk, en een school
schakelt naar wens modules bij.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class MenuItem:
"""Een navigatie-item dat een module toevoegt aan de app."""
label: str
path: str
icon: str = "puzzle"
order: int = 100
@dataclass(frozen=True)
class ModuleSpec:
"""Beschrijving waarmee een module zich aanmeldt bij het register."""
key: str # unieke sleutel, bv. "printing"
name: str # leesbare naam
description: str = ""
version: str = "0.1.0"
category: str = "Overig"
core: bool = False # kern-module: altijd aan, niet uitschakelbaar
default_enabled: bool = False # standaard aan na installatie?
depends_on: tuple[str, ...] = field(default_factory=tuple)
menu_items: tuple[MenuItem, ...] = field(default_factory=tuple)
class ModuleRegistry:
"""Centrale verzameling van alle bekende modules (in-memory)."""
def __init__(self) -> None:
self._specs: dict[str, ModuleSpec] = {}
def register(self, spec: ModuleSpec) -> None:
if spec.key in self._specs:
raise ValueError(f"Module '{spec.key}' is al geregistreerd.")
self._specs[spec.key] = spec
def all(self) -> list[ModuleSpec]:
return sorted(self._specs.values(), key=lambda s: (s.category, s.name))
def get(self, key: str) -> ModuleSpec | None:
return self._specs.get(key)
def dependents_of(self, key: str) -> list[ModuleSpec]:
"""Modules die deze module nodig hebben."""
return [s for s in self._specs.values() if key in s.depends_on]
# Eén gedeeld register voor de hele applicatie.
registry = ModuleRegistry()

100
backend/plugins/services.py Normal file
View file

@ -0,0 +1,100 @@
"""
Bedrijfslogica rond modules: status uitlezen en veilig aan/uit zetten,
met respect voor onderlinge afhankelijkheden.
"""
from __future__ import annotations
from .models import ModuleState
from .registry import ModuleSpec, registry
class ModuleError(Exception):
"""Een aan/uit-actie is niet toegestaan."""
def _state_map() -> dict[str, bool]:
return {s.key: s.enabled for s in ModuleState.objects.all()}
def is_enabled(key: str) -> bool:
spec = registry.get(key)
if spec is None:
return False
if spec.core:
return True
state = ModuleState.objects.filter(key=key).first()
return bool(state and state.enabled)
def sync_states() -> None:
"""Zorg dat elke geregistreerde module een rij in de database heeft."""
existing = set(ModuleState.objects.values_list("key", flat=True))
for spec in registry.all():
if spec.core:
continue
if spec.key not in existing:
ModuleState.objects.create(key=spec.key, enabled=spec.default_enabled)
def describe_all() -> list[dict]:
"""Alle modules met metadata + actuele status, voor de API/het beheerscherm."""
states = _state_map()
result: list[dict] = []
for spec in registry.all():
enabled = True if spec.core else states.get(spec.key, spec.default_enabled)
result.append(
{
"key": spec.key,
"name": spec.name,
"description": spec.description,
"version": spec.version,
"category": spec.category,
"core": spec.core,
"enabled": enabled,
"depends_on": list(spec.depends_on),
"menu_items": [
{
"label": m.label,
"path": m.path,
"icon": m.icon,
"order": m.order,
}
for m in spec.menu_items
]
if enabled
else [],
}
)
return result
def set_enabled(key: str, enabled: bool) -> ModuleSpec:
"""Zet een module aan of uit, met controle op afhankelijkheden."""
spec = registry.get(key)
if spec is None:
raise ModuleError(f"Onbekende module: {key}")
if spec.core:
raise ModuleError(f"Kernmodule '{key}' kan niet worden uitgeschakeld.")
if enabled:
# Vereiste modules moeten ook aan staan.
for dep in spec.depends_on:
if not is_enabled(dep):
dep_spec = registry.get(dep)
dep_name = dep_spec.name if dep_spec else dep
raise ModuleError(
f"Module '{spec.name}' vereist eerst '{dep_name}'."
)
else:
# Modules die hiervan afhangen mogen niet meer aan staan.
for dependent in registry.dependents_of(key):
if is_enabled(dependent.key):
raise ModuleError(
f"'{dependent.name}' heeft '{spec.name}' nodig; "
f"schakel die eerst uit."
)
state, _ = ModuleState.objects.get_or_create(key=key)
state.enabled = enabled
state.save(update_fields=["enabled", "updated_at"])
return spec

8
backend/plugins/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("modules/", views.module_list, name="module-list"),
path("modules/<str:key>/state/", views.module_set_state, name="module-set-state"),
]

24
backend/plugins/views.py Normal file
View file

@ -0,0 +1,24 @@
"""API voor het plugin-framework: modules opvragen en aan/uit zetten."""
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from . import services
@api_view(["GET"])
def module_list(request):
"""Alle modules met metadata en actuele aan/uit-status."""
services.sync_states()
return Response(services.describe_all())
@api_view(["POST"])
def module_set_state(request, key: str):
"""Zet één module aan of uit. Body: {"enabled": true|false}."""
enabled = bool(request.data.get("enabled", False))
try:
services.set_enabled(key, enabled)
except services.ModuleError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(services.describe_all())

7
backend/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
Django>=5.2,<6.0
djangorestframework>=3.15
django-cors-headers>=4.4
# --- Productie / test-server ---------------------------------------------
gunicorn>=22.0 # WSGI-server die Django draait
psycopg[binary]>=3.2 # PostgreSQL-driver

57
docker-compose.yml Normal file
View file

@ -0,0 +1,57 @@
# Stack voor de test-server (en later productie):
# db - PostgreSQL
# backend - Django via gunicorn
# nginx - serveert de frontend en proxyt /api + /admin naar de backend
#
# Instellingen komen uit het .env-bestand (zie .env.example).
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_DEBUG: ${DJANGO_DEBUG:-0}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
volumes:
# Verzamelde statics delen met nginx.
- static_volume:/app/staticfiles
nginx:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
ports:
# Linkerkant is de poort op de server; pas aan indien bezet.
- "8080:80"
volumes:
# Lees de door Django verzamelde statics (alleen-lezen).
- static_volume:/static:ro
volumes:
pgdata:
static_volume:

3
frontend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

17
frontend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# Frontend in twee fases:
# 1) Node bouwt de React-app tot statische bestanden.
# 2) Nginx serveert die bestanden en proxyt /api en /admin naar de backend.
# --- Fase 1: build --------------------------------------------------------
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Fase 2: nginx --------------------------------------------------------
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Roostersoftware</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

41
frontend/nginx.conf Normal file
View file

@ -0,0 +1,41 @@
# Nginx: serveert de React-app en stuurt API/admin door naar gunicorn.
server {
listen 80;
server_name _;
# Upload-/postlimiet wat ruimer (bv. importbestanden later).
client_max_body_size 20M;
# Door Django verzamelde statische bestanden (o.a. admin-CSS).
# Deze map komt uit het gedeelde volume 'static_volume'.
location /static/ {
alias /static/;
access_log off;
expires 30d;
}
# API-verzoeken naar de Django-backend (gunicorn).
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Django-admin loopt ook via de backend.
location /admin/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Alle overige paden: de single-page React-app.
# try_files zorgt dat client-side routes terugvallen op index.html.
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}

19
frontend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "roostersoftware-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
}
}

155
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from "react";
import { getModules, setModuleState } from "./api.js";
const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩",
};
export default function App() {
const [modules, setModules] = useState([]);
const [active, setActive] = useState("/rooster");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
setModules(await getModules());
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
const menu = useMemo(() => {
const items = [];
for (const m of modules) {
if (!m.enabled) continue;
for (const it of m.menu_items) items.push({ ...it, module: m.key });
}
items.push({ label: "Modulebeheer", path: "/modules", icon: "puzzle", order: 9999 });
return items.sort((a, b) => a.order - b.order);
}, [modules]);
return (
<div className="app">
<aside className="sidebar">
<div className="brand">Roostersoftware</div>
<nav>
{menu.map((it) => (
<button
key={it.path}
className={active === it.path ? "nav-item active" : "nav-item"}
onClick={() => setActive(it.path)}
>
<span className="ico">{ICONS[it.icon] || "•"}</span>
{it.label}
</button>
))}
</nav>
<div className="hint">Menu's verschijnen alleen voor ingeschakelde modules.</div>
</aside>
<main className="content">
{error && <div className="banner error"> {error}</div>}
{loading ? (
<p>Laden</p>
) : active === "/modules" ? (
<ModuleManager modules={modules} onChange={load} />
) : (
<Placeholder path={active} menu={menu} />
)}
</main>
</div>
);
}
function Placeholder({ path, menu }) {
const item = menu.find((m) => m.path === path);
return (
<section>
<h1>{item ? item.label : "Welkom"}</h1>
<p className="muted">
Dit scherm wordt in een volgende fase ingevuld. Fase 0 levert het
fundament en het plugin-framework.
</p>
</section>
);
}
function ModuleManager({ modules, onChange }) {
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState(null);
async function toggle(m) {
setBusy(m.key);
setMsg(null);
try {
await setModuleState(m.key, !m.enabled);
await onChange();
} catch (e) {
setMsg(e.message);
} finally {
setBusy(null);
}
}
return (
<section>
<h1>Modulebeheer</h1>
<p className="muted">
Schakel modules per school in of uit. De kern staat altijd aan.
</p>
{msg && <div className="banner error"> {msg}</div>}
<div className="cards">
{modules.map((m) => (
<div className={m.enabled ? "card on" : "card"} key={m.key}>
<div className="card-head">
<div>
<div className="card-title">{m.name}</div>
<div className="card-cat">{m.category} · v{m.version}</div>
</div>
<Toggle
checked={m.enabled}
disabled={m.core || busy === m.key}
onChange={() => toggle(m)}
/>
</div>
<p className="card-desc">{m.description}</p>
<div className="card-foot">
{m.core ? (
<span className="tag core">Kern · altijd aan</span>
) : (
<span className={m.enabled ? "tag on" : "tag"}>
{m.enabled ? "Ingeschakeld" : "Uitgeschakeld"}
</span>
)}
{m.depends_on.length > 0 && (
<span className="tag dep">vereist: {m.depends_on.join(", ")}</span>
)}
</div>
</div>
))}
</div>
</section>
);
}
function Toggle({ checked, disabled, onChange }) {
return (
<button
className={`toggle ${checked ? "on" : ""}`}
disabled={disabled}
onClick={onChange}
aria-pressed={checked}
>
<span className="knob" />
</button>
);
}

19
frontend/src/api.js Normal file
View file

@ -0,0 +1,19 @@
// Dunne API-laag richting de Django-backend.
const BASE = "/api";
export async function getModules() {
const res = await fetch(`${BASE}/modules/`);
if (!res.ok) throw new Error("Kon modules niet laden");
return res.json();
}
export async function setModuleState(key, enabled) {
const res = await fetch(`${BASE}/modules/${key}/state/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Wijzigen mislukt");
return data;
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

62
frontend/src/styles.css Normal file
View file

@ -0,0 +1,62 @@
:root {
--bg: #f4f6fb;
--panel: #ffffff;
--ink: #1f2937;
--muted: #6b7280;
--line: #e5e7eb;
--brand: #2563eb;
--brand-soft: #eef2ff;
--on: #16a34a;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color: var(--ink); background: var(--bg); }
.app { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; }
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.brand { font-weight: 700; font-size: 18px; margin: 4px 8px 18px; color: var(--brand); }
.nav-item {
display: flex; align-items: center; gap: 10px;
width: 100%; text-align: left;
background: transparent; border: 0; border-radius: 8px;
padding: 10px 10px; font-size: 14px; color: var(--ink); cursor: pointer;
}
.nav-item:hover { background: var(--bg); }
.nav-item.active { background: var(--brand-soft); color: var(--brand); font-weight: 600; }
.ico { width: 18px; text-align: center; }
.hint { margin-top: auto; font-size: 12px; color: var(--muted); padding: 12px 8px 0; }
.content { padding: 32px 40px; }
h1 { margin: 0 0 6px; font-size: 24px; }
.muted { color: var(--muted); }
.banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; }
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-top: 20px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.card.on { border-color: #bbf7d0; }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.card-title { font-weight: 600; }
.card-cat { font-size: 12px; color: var(--muted); margin-top: 2px; }
.card-desc { font-size: 14px; color: #374151; margin: 10px 0 12px; }
.card-foot { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { font-size: 12px; padding: 3px 8px; border-radius: 999px; background: #f3f4f6; color: var(--muted); }
.tag.on { background: #dcfce7; color: var(--on); }
.tag.core { background: var(--brand-soft); color: var(--brand); }
.tag.dep { background: #fef9c3; color: #854d0e; }
.toggle { width: 44px; height: 26px; border-radius: 999px; border: 0; background: #d1d5db; position: relative; cursor: pointer; transition: background .15s; flex: none; }
.toggle.on { background: var(--on); }
.toggle:disabled { opacity: .5; cursor: not-allowed; }
.knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: left .15s; }
.toggle.on .knob { left: 21px; }

13
frontend/vite.config.js Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Dev-server draait op poort 5173 en stuurt /api door naar Django (poort 8000).
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
},
},
});