Rebrand naar Roosterwijs + Fase 1: kerndomein (personen, groepen, subgroepen)

This commit is contained in:
bes-r 2026-06-09 23:17:47 +02:00
parent ac634d343f
commit e51b94159b
16 changed files with 936 additions and 41 deletions

View file

@ -1,23 +1,25 @@
# Roostersoftware
# Roosterwijs
Modulair, flexibel roostersysteem voor het speciaal onderwijs. Zie `PLAN.md`
voor de visie en de fasering.
Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
Deze map bevat het resultaat van **Fase 0**: het fundament en een echt
plugin-framework waarmee modules per school in- en uitgeschakeld kunnen worden.
Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein:
personen, groepen, subgroepen) zijn klaar.
## 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.
- `core/` — de kern (altijd actief). **Fase 1**: modellen Persoon, Groep en
Subgroep met een volledige CRUD-API. Roosterblokken en kalender volgen in
fase 2.
- `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.
- **Frontend** (`frontend/`) — React (Vite). Zijbalk met Roosterwijs-logo die
alleen menu's toont van ingeschakelde modules, beheerschermen voor
**Personen**, **Groepen** en **Subgroepen**, en een scherm **Modulebeheer**.
## Hoe modulariteit werkt (het verkoopargument)
@ -48,7 +50,9 @@ 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).
- `GET/POST /api/personen/`, `/api/groepen/`, `/api/subgroepen/` — CRUD
(plus `PATCH`/`DELETE` op `/<id>/`). Personen filteren kan met `?rol=leerling`.
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
> `ROOSTER_DB_PATH` naar een lokaal pad, of pas `DATABASES` aan.
@ -65,7 +69,8 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
aan/uit te zetten.
## Volgende stap (Fase 1)
## Volgende stap (Fase 2)
Kerndomein bouwen: personen (leerlingen, leerkrachten, ondersteuners),
groepen en subgroepen — met beheerschermen.
Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties)
en roosterblokken die je toewijst aan een groep, subgroep of individuele
leerling, met begeleiders en locatie.

26
backend/core/admin.py Normal file
View file

@ -0,0 +1,26 @@
from django.contrib import admin
from .models import Groep, Persoon, Subgroep
@admin.register(Groep)
class GroepAdmin(admin.ModelAdmin):
list_display = ("naam", "omschrijving", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
@admin.register(Persoon)
class PersoonAdmin(admin.ModelAdmin):
list_display = ("achternaam", "voornaam", "rol", "groep", "actief")
list_filter = ("rol", "actief", "groep")
search_fields = ("voornaam", "achternaam")
autocomplete_fields = ("groep",)
@admin.register(Subgroep)
class SubgroepAdmin(admin.ModelAdmin):
list_display = ("naam", "omschrijving", "actief")
list_filter = ("actief",)
search_fields = ("naam",)
filter_horizontal = ("leden",)

View file

@ -30,6 +30,7 @@ class CoreConfig(AppConfig):
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Personen", "/personen", icon="users", order=20),
MenuItem("Groepen", "/groepen", icon="layers", order=30),
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
),
)
)

View file

@ -0,0 +1,64 @@
# Generated by Django 5.2.15 on 2026-06-09 21:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Groep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=100, unique=True, verbose_name='naam')),
('omschrijving', models.CharField(blank=True, max_length=255, verbose_name='omschrijving')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'groep',
'verbose_name_plural': 'groepen',
'ordering': ['naam'],
},
),
migrations.CreateModel(
name='Persoon',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('voornaam', models.CharField(max_length=80, verbose_name='voornaam')),
('achternaam', models.CharField(max_length=120, verbose_name='achternaam')),
('rol', models.CharField(choices=[('leerling', 'Leerling'), ('leerkracht', 'Leerkracht'), ('praktijkondersteuner', 'Praktijkondersteuner'), ('klassenondersteuner', 'Klassenondersteuner')], max_length=32, verbose_name='rol')),
('actief', models.BooleanField(default=True, verbose_name='actief')),
('opmerkingen', models.TextField(blank=True, verbose_name='opmerkingen')),
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
('groep', models.ForeignKey(blank=True, help_text='Alleen van toepassing op leerlingen.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leerlingen', to='core.groep', verbose_name='groep')),
],
options={
'verbose_name': 'persoon',
'verbose_name_plural': 'personen',
'ordering': ['achternaam', 'voornaam'],
},
),
migrations.CreateModel(
name='Subgroep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('naam', models.CharField(max_length=100, 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)),
('leden', models.ManyToManyField(blank=True, limit_choices_to={'rol': 'leerling'}, related_name='subgroepen', to='core.persoon', verbose_name='leden')),
],
options={
'verbose_name': 'subgroep',
'verbose_name_plural': 'subgroepen',
'ordering': ['naam'],
},
),
]

View file

@ -1,3 +1,104 @@
# 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.
"""
Kerndomein van Roosterwijs (Fase 1).
Bewust klein gehouden, met veel flexibiliteit voor het speciaal onderwijs:
- één Persoon-tabel met een rol (leerling, leerkracht, ondersteuner);
- leerlingen horen bij een groep;
- subgroepen zijn vrije, groep-overstijgende verzamelingen leerlingen.
Roosterblokken, tijdslots en de kalender volgen in Fase 2.
"""
from django.db import models
class Rol(models.TextChoices):
LEERLING = "leerling", "Leerling"
LEERKRACHT = "leerkracht", "Leerkracht"
PRAKTIJKONDERSTEUNER = "praktijkondersteuner", "Praktijkondersteuner"
KLASSENONDERSTEUNER = "klassenondersteuner", "Klassenondersteuner"
class Groep(models.Model):
"""Een klas/groep waar leerlingen bij horen."""
naam = models.CharField("naam", max_length=100, 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 = "groep"
verbose_name_plural = "groepen"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam
class Persoon(models.Model):
"""Leerling, leerkracht of ondersteuner - een tabel met een rol."""
voornaam = models.CharField("voornaam", max_length=80)
achternaam = models.CharField("achternaam", max_length=120)
rol = models.CharField("rol", max_length=32, choices=Rol.choices)
groep = models.ForeignKey(
Groep,
verbose_name="groep",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="leerlingen",
help_text="Alleen van toepassing op leerlingen.",
)
actief = models.BooleanField("actief", default=True)
opmerkingen = models.TextField("opmerkingen", blank=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "persoon"
verbose_name_plural = "personen"
ordering = ["achternaam", "voornaam"]
def __str__(self) -> str:
return f"{self.volledige_naam} ({self.get_rol_display()})"
@property
def volledige_naam(self) -> str:
return f"{self.voornaam} {self.achternaam}".strip()
@property
def is_leerling(self) -> bool:
return self.rol == Rol.LEERLING
def save(self, *args, **kwargs):
# Een groepskoppeling hoort alleen bij leerlingen.
if self.rol != Rol.LEERLING:
self.groep = None
super().save(*args, **kwargs)
class Subgroep(models.Model):
"""Een vrije, groep-overstijgende verzameling leerlingen.
Bijvoorbeeld een niveaugroepje rekenen met leerlingen uit meerdere groepen.
"""
naam = models.CharField("naam", max_length=100)
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
leden = models.ManyToManyField(
Persoon,
verbose_name="leden",
related_name="subgroepen",
blank=True,
limit_choices_to={"rol": Rol.LEERLING},
)
actief = models.BooleanField("actief", default=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "subgroep"
verbose_name_plural = "subgroepen"
ordering = ["naam"]
def __str__(self) -> str:
return self.naam

View file

@ -0,0 +1,61 @@
"""DRF-serializers voor het kerndomein."""
from rest_framework import serializers
from .models import Groep, Persoon, Subgroep
class GroepSerializer(serializers.ModelSerializer):
aantal_leerlingen = serializers.IntegerField(
source="leerlingen.count", read_only=True
)
class Meta:
model = Groep
fields = [
"id", "naam", "omschrijving", "actief",
"aantal_leerlingen", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
class PersoonSerializer(serializers.ModelSerializer):
volledige_naam = serializers.CharField(read_only=True)
rol_label = serializers.CharField(source="get_rol_display", read_only=True)
groep_naam = serializers.CharField(
source="groep.naam", read_only=True, default=None
)
class Meta:
model = Persoon
fields = [
"id", "voornaam", "achternaam", "volledige_naam",
"rol", "rol_label", "groep", "groep_naam",
"actief", "opmerkingen", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
class SubgroepSerializer(serializers.ModelSerializer):
# Lezen: namen tonen. Schrijven: lijst met leerling-id's.
leden = serializers.PrimaryKeyRelatedField(
many=True, queryset=Persoon.objects.filter(rol="leerling"), required=False
)
leden_namen = serializers.SerializerMethodField()
aantal_leden = serializers.SerializerMethodField()
class Meta:
model = Subgroep
fields = [
"id", "naam", "omschrijving", "actief",
"leden", "leden_namen", "aantal_leden", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
def get_leden_namen(self, obj):
return [
{"id": p.id, "naam": p.volledige_naam}
for p in obj.leden.all()
]
def get_aantal_leden(self, obj):
return obj.leden.count()

View file

@ -1,7 +1,14 @@
from django.urls import path
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("personen", views.PersoonViewSet, basename="persoon")
router.register("groepen", views.GroepViewSet, basename="groep")
router.register("subgroepen", views.SubgroepViewSet, basename="subgroep")
urlpatterns = [
path("info/", views.info, name="info"),
path("", include(router.urls)),
]

View file

@ -1,8 +1,12 @@
from rest_framework import viewsets
from rest_framework.decorators import api_view
from rest_framework.response import Response
from plugins import services
from .models import Groep, Persoon, Subgroep
from .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer
@api_view(["GET"])
def info(request):
@ -11,9 +15,30 @@ def info(request):
modules = services.describe_all()
return Response(
{
"naam": "Roostersoftware",
"fase": "0 - fundament + plugin-framework",
"naam": "Roosterwijs",
"fase": "1 - kerndomein (personen, groepen, subgroepen)",
"modules_totaal": len(modules),
"modules_actief": sum(1 for m in modules if m["enabled"]),
}
)
class GroepViewSet(viewsets.ModelViewSet):
queryset = Groep.objects.all()
serializer_class = GroepSerializer
class PersoonViewSet(viewsets.ModelViewSet):
serializer_class = PersoonSerializer
def get_queryset(self):
qs = Persoon.objects.select_related("groep").all()
rol = self.request.query_params.get("rol")
if rol:
qs = qs.filter(rol=rol)
return qs
class SubgroepViewSet(viewsets.ModelViewSet):
queryset = Subgroep.objects.prefetch_related("leden").all()
serializer_class = SubgroepSerializer

View file

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Roostersoftware</title>
<link rel="icon" type="image/svg+xml" href="/owl.svg" />
<title>Roosterwijs</title>
</head>
<body>
<div id="root"></div>

View file

@ -1,5 +1,5 @@
{
"name": "roostersoftware-frontend",
"name": "roosterwijs-frontend",
"private": true,
"version": "0.1.0",
"type": "module",

11
frontend/public/owl.svg Normal file
View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2563eb"/>
<path d="M32 10c-11 0-19 7.5-19 20 0 12 8 22 19 22s19-10 19-22C51 17.5 43 10 32 10z" fill="#ffffff"/>
<path d="M18 15c1.8 1.8 3.6 4.5 4.5 8-3.6-.9-6.3-2.7-8-5.4 1-1 2-1.8 3.5-2.6z" fill="#ffffff"/>
<path d="M46 15c-1.8 1.8-3.6 4.5-4.5 8 3.6-.9 6.3-2.7 8-5.4-1-1-2-1.8-3.5-2.6z" fill="#ffffff"/>
<circle cx="25" cy="30" r="8.5" fill="#1f2937"/>
<circle cx="39" cy="30" r="8.5" fill="#1f2937"/>
<circle cx="25" cy="30" r="3.5" fill="#ffffff"/>
<circle cx="39" cy="30" r="3.5" fill="#ffffff"/>
<path d="M32 33l3.5 5.5h-7L32 33z" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 706 B

View file

@ -1,14 +1,24 @@
import { useEffect, useMemo, useState } from "react";
import { getModules, setModuleState } from "./api.js";
import Logo from "./Logo.jsx";
import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx";
const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩",
};
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
// placeholder (worden in een latere fase ingevuld).
const PAGES = {
"/personen": PersonenPage,
"/groepen": GroepenPage,
"/subgroepen": SubgroepenPage,
};
export default function App() {
const [modules, setModules] = useState([]);
const [active, setActive] = useState("/rooster");
const [active, setActive] = useState("/personen");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
@ -37,10 +47,15 @@ export default function App() {
return items.sort((a, b) => a.order - b.order);
}, [modules]);
const Page = PAGES[active];
return (
<div className="app">
<aside className="sidebar">
<div className="brand">Roostersoftware</div>
<div className="brand">
<span className="brand-logo"><Logo size={30} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
<nav>
{menu.map((it) => (
<button
@ -62,6 +77,8 @@ export default function App() {
<p>Laden</p>
) : active === "/modules" ? (
<ModuleManager modules={modules} onChange={load} />
) : Page ? (
<Page />
) : (
<Placeholder path={active} menu={menu} />
)}
@ -76,8 +93,8 @@ function Placeholder({ path, menu }) {
<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.
Dit scherm wordt in een volgende fase ingevuld. Fase 1 levert het
kerndomein: personen, groepen en subgroepen.
</p>
</section>
);

36
frontend/src/Logo.jsx Normal file
View file

@ -0,0 +1,36 @@
// Uil-logo van Roosterwijs. Een uil staat voor wijsheid -> "Roosterwijs".
// Schaalbaar; gebruikt currentColor zodat het meekleurt met de tekstkleur.
export default function Logo({ size = 28 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
role="img"
aria-label="Roosterwijs uil-logo"
>
{/* takje */}
<rect x="14" y="56" width="36" height="3" rx="1.5" fill="currentColor" opacity="0.5" />
{/* lichaam */}
<path
d="M32 6c-12 0-21 8.5-21 22 0 13.5 9 24 21 24s21-10.5 21-24C53 14.5 44 6 32 6z"
fill="currentColor"
/>
{/* oortjes */}
<path d="M16 12c2 2 4 5 5 9-4-1-7-3-9-6 1-1 2-2 4-3z" fill="currentColor" />
<path d="M48 12c-2 2-4 5-5 9 4-1 7-3 9-6-1-1-2-2-4-3z" fill="currentColor" />
{/* buik */}
<ellipse cx="32" cy="38" rx="12" ry="13" fill="#ffffff" opacity="0.18" />
{/* ogen */}
<circle cx="24" cy="28" r="9" fill="#ffffff" />
<circle cx="40" cy="28" r="9" fill="#ffffff" />
<circle cx="24" cy="28" r="4" fill="#1f2937" />
<circle cx="40" cy="28" r="4" fill="#1f2937" />
<circle cx="25.5" cy="26.5" r="1.3" fill="#ffffff" />
<circle cx="41.5" cy="26.5" r="1.3" fill="#ffffff" />
{/* snavel */}
<path d="M32 31l4 6h-8l4-6z" fill="#f59e0b" />
</svg>
);
}

View file

@ -1,19 +1,39 @@
// 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();
async function request(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(`${BASE}${url}`, opts);
if (res.status === 204) return null;
let data = null;
try {
data = await res.json();
} catch {
data = null;
}
if (!res.ok) {
const msg =
(data && (data.detail || JSON.stringify(data))) || "Er ging iets mis";
throw new Error(msg);
}
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;
}
// --- Plugin-framework -----------------------------------------------------
export const getModules = () => request("GET", "/modules/");
export const setModuleState = (key, enabled) =>
request("POST", `/modules/${key}/state/`, { enabled });
// --- Generieke CRUD voor het kerndomein -----------------------------------
export const listResource = (resource, query = "") =>
request("GET", `/${resource}/${query}`);
export const createResource = (resource, body) =>
request("POST", `/${resource}/`, body);
export const updateResource = (resource, id, body) =>
request("PATCH", `/${resource}/${id}/`, body);
export const deleteResource = (resource, id) =>
request("DELETE", `/${resource}/${id}/`);

481
frontend/src/pages.jsx Normal file
View file

@ -0,0 +1,481 @@
import { useEffect, useState } from "react";
import {
listResource,
createResource,
updateResource,
deleteResource,
} from "./api.js";
const ROLLEN = [
{ value: "leerling", label: "Leerling" },
{ value: "leerkracht", label: "Leerkracht" },
{ value: "praktijkondersteuner", label: "Praktijkondersteuner" },
{ value: "klassenondersteuner", label: "Klassenondersteuner" },
];
// Kleine helper voor laden + foutmelding.
function useList(resource) {
const [items, setItems] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function reload() {
setLoading(true);
try {
setItems(await listResource(resource));
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
reload();
}, [resource]);
return { items, error, loading, reload, setError };
}
function Foutmelding({ msg }) {
if (!msg) return null;
return <div className="banner error"> {msg}</div>;
}
/* --------------------------------- Groepen -------------------------------- */
export function GroepenPage() {
const { items, error, loading, reload, setError } = useList("groepen");
const leeg = { naam: "", omschrijving: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource("groepen", editId, form);
else await createResource("groepen", form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze groep verwijderen?")) return;
try {
await deleteResource("groepen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Groepen</h1>
<p className="muted">Klassen waar leerlingen bij horen.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input
placeholder="Naam"
value={form.naam}
required
onChange={(e) => setForm({ ...form, naam: e.target.value })}
/>
<input
placeholder="Omschrijving (optioneel)"
value={form.omschrijving}
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
/>
<button className="btn primary" type="submit">
{editId ? "Opslaan" : "Toevoegen"}
</button>
{editId && (
<button
type="button"
className="btn"
onClick={() => {
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button>
)}
</form>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Omschrijving</th>
<th>Leerlingen</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((g) => (
<tr key={g.id}>
<td>{g.naam}</td>
<td className="muted">{g.omschrijving || "—"}</td>
<td>{g.aantal_leerlingen}</td>
<td className="row-actions">
<button
className="link"
onClick={() => {
setEditId(g.id);
setForm({ naam: g.naam, omschrijving: g.omschrijving });
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(g.id)}>
verwijderen
</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan="4" className="muted">
Nog geen groepen.
</td>
</tr>
)}
</tbody>
</table>
)}
</section>
);
}
/* -------------------------------- Personen -------------------------------- */
export function PersonenPage() {
const { items, error, loading, reload, setError } = useList("personen");
const [groepen, setGroepen] = useState([]);
const leeg = { voornaam: "", achternaam: "", rol: "leerling", groep: "" };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const [filter, setFilter] = useState("");
useEffect(() => {
listResource("groepen").then(setGroepen).catch(() => {});
}, []);
async function opslaan(e) {
e.preventDefault();
const payload = { ...form, groep: form.rol === "leerling" && form.groep ? form.groep : null };
try {
if (editId) await updateResource("personen", editId, payload);
else await createResource("personen", payload);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze persoon verwijderen?")) return;
try {
await deleteResource("personen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
const zichtbaar = filter ? items.filter((p) => p.rol === filter) : items;
return (
<section>
<h1>Personen</h1>
<p className="muted">Leerlingen, leerkrachten en ondersteuners.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<input
placeholder="Voornaam"
value={form.voornaam}
required
onChange={(e) => setForm({ ...form, voornaam: e.target.value })}
/>
<input
placeholder="Achternaam"
value={form.achternaam}
required
onChange={(e) => setForm({ ...form, achternaam: e.target.value })}
/>
<select
value={form.rol}
onChange={(e) => setForm({ ...form, rol: e.target.value })}
>
{ROLLEN.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
{form.rol === "leerling" && (
<select
value={form.groep || ""}
onChange={(e) => setForm({ ...form, groep: e.target.value })}
>
<option value=""> geen groep </option>
{groepen.map((g) => (
<option key={g.id} value={g.id}>
{g.naam}
</option>
))}
</select>
)}
<button className="btn primary" type="submit">
{editId ? "Opslaan" : "Toevoegen"}
</button>
{editId && (
<button
type="button"
className="btn"
onClick={() => {
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button>
)}
</form>
<div className="filterbar">
<span className="muted">Filter:</span>
<button className={filter === "" ? "chip on" : "chip"} onClick={() => setFilter("")}>
Alle
</button>
{ROLLEN.map((r) => (
<button
key={r.value}
className={filter === r.value ? "chip on" : "chip"}
onClick={() => setFilter(r.value)}
>
{r.label}
</button>
))}
</div>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Rol</th>
<th>Groep</th>
<th></th>
</tr>
</thead>
<tbody>
{zichtbaar.map((p) => (
<tr key={p.id}>
<td>{p.volledige_naam}</td>
<td>{p.rol_label}</td>
<td className="muted">{p.groep_naam || "—"}</td>
<td className="row-actions">
<button
className="link"
onClick={() => {
setEditId(p.id);
setForm({
voornaam: p.voornaam,
achternaam: p.achternaam,
rol: p.rol,
groep: p.groep || "",
});
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(p.id)}>
verwijderen
</button>
</td>
</tr>
))}
{zichtbaar.length === 0 && (
<tr>
<td colSpan="4" className="muted">
Geen personen.
</td>
</tr>
)}
</tbody>
</table>
)}
</section>
);
}
/* ------------------------------- Subgroepen ------------------------------- */
export function SubgroepenPage() {
const { items, error, loading, reload, setError } = useList("subgroepen");
const [leerlingen, setLeerlingen] = useState([]);
const leeg = { naam: "", omschrijving: "", leden: [] };
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
useEffect(() => {
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
}, []);
function toggleLid(id) {
setForm((f) =>
f.leden.includes(id)
? { ...f, leden: f.leden.filter((x) => x !== id) }
: { ...f, leden: [...f.leden, id] }
);
}
async function opslaan(e) {
e.preventDefault();
try {
if (editId) await updateResource("subgroepen", editId, form);
else await createResource("subgroepen", form);
setForm(leeg);
setEditId(null);
await reload();
} catch (err) {
setError(err.message);
}
}
async function verwijder(id) {
if (!confirm("Deze subgroep verwijderen?")) return;
try {
await deleteResource("subgroepen", id);
await reload();
} catch (err) {
setError(err.message);
}
}
return (
<section>
<h1>Subgroepen</h1>
<p className="muted">
Vrije, groep-overstijgende groepjes leerlingen (bv. niveaugroep rekenen).
</p>
<Foutmelding msg={error} />
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input
placeholder="Naam"
value={form.naam}
required
onChange={(e) => setForm({ ...form, naam: e.target.value })}
/>
<input
placeholder="Omschrijving (optioneel)"
value={form.omschrijving}
onChange={(e) => setForm({ ...form, omschrijving: e.target.value })}
/>
</div>
<div className="leden-keuze">
<div className="muted small">Leden (leerlingen):</div>
<div className="chips">
{leerlingen.map((l) => (
<button
type="button"
key={l.id}
className={form.leden.includes(l.id) ? "chip on" : "chip"}
onClick={() => toggleLid(l.id)}
>
{l.volledige_naam}
</button>
))}
{leerlingen.length === 0 && (
<span className="muted small">Nog geen leerlingen ingevoerd.</span>
)}
</div>
</div>
<div className="form-row">
<button className="btn primary" type="submit">
{editId ? "Opslaan" : "Toevoegen"}
</button>
{editId && (
<button
type="button"
className="btn"
onClick={() => {
setEditId(null);
setForm(leeg);
}}
>
Annuleren
</button>
)}
</div>
</form>
{loading ? (
<p>Laden</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Naam</th>
<th>Leden</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>
{s.naam}
{s.omschrijving && (
<div className="muted small">{s.omschrijving}</div>
)}
</td>
<td>
<span className="badge">{s.aantal_leden}</span>{" "}
<span className="muted small">
{s.leden_namen.map((x) => x.naam).join(", ")}
</span>
</td>
<td className="row-actions">
<button
className="link"
onClick={() => {
setEditId(s.id);
setForm({
naam: s.naam,
omschrijving: s.omschrijving,
leden: s.leden_namen.map((x) => x.id),
});
}}
>
bewerken
</button>
<button className="link danger" onClick={() => verwijder(s.id)}>
verwijderen
</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan="3" className="muted">
Nog geen subgroepen.
</td>
</tr>
)}
</tbody>
</table>
)}
</section>
);
}

View file

@ -22,7 +22,9 @@ body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color:
flex-direction: column;
gap: 6px;
}
.brand { font-weight: 700; font-size: 18px; margin: 4px 8px 18px; color: var(--brand); }
.brand { display: flex; align-items: center; gap: 10px; margin: 4px 8px 18px; color: var(--brand); }
.brand-logo { display: inline-flex; color: var(--brand); }
.brand-name { font-weight: 800; font-size: 19px; letter-spacing: .2px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
width: 100%; text-align: left;
@ -60,3 +62,40 @@ h1 { margin: 0 0 6px; font-size: 24px; }
.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; }
/* --- Formulieren --- */
.form-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 18px 0; }
.form-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 18px 0; }
.form-card .form-row { margin: 0 0 12px; }
input, select {
font: inherit; padding: 9px 11px; border: 1px solid var(--line);
border-radius: 8px; background: #fff; color: var(--ink); min-width: 150px;
}
input:focus, select:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
.btn { font: inherit; padding: 9px 14px; border-radius: 8px; border: 1px solid var(--line); background: #fff; cursor: pointer; }
.btn:hover { background: var(--bg); }
.btn.primary { background: var(--brand); color: #fff; border-color: var(--brand); }
.btn.primary:hover { filter: brightness(1.05); }
.link { background: none; border: 0; color: var(--brand); cursor: pointer; padding: 2px 4px; font: inherit; }
.link:hover { text-decoration: underline; }
.link.danger { color: #b91c1c; }
/* --- Tabel --- */
.grid { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.grid th, .grid td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
.grid th { background: #fafbff; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); }
.grid tr:last-child td { border-bottom: 0; }
.row-actions { display: flex; gap: 10px; white-space: nowrap; }
.small { font-size: 12px; }
/* --- Chips / filters --- */
.filterbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 8px 0 16px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.chip { font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: #fff; cursor: pointer; }
.chip:hover { background: var(--bg); }
.chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
.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; }