Rebrand naar Roosterwijs + Fase 1: kerndomein (personen, groepen, subgroepen)
This commit is contained in:
parent
ac634d343f
commit
e51b94159b
16 changed files with 936 additions and 41 deletions
33
README.md
33
README.md
|
|
@ -1,23 +1,25 @@
|
||||||
# Roostersoftware
|
# Roosterwijs
|
||||||
|
|
||||||
Modulair, flexibel roostersysteem voor het speciaal onderwijs. Zie `PLAN.md`
|
Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een
|
||||||
voor de visie en de fasering.
|
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
|
Status: **Fase 0** (fundament + plugin-framework) en **Fase 1** (kerndomein:
|
||||||
plugin-framework waarmee modules per school in- en uitgeschakeld kunnen worden.
|
personen, groepen, subgroepen) zijn klaar.
|
||||||
|
|
||||||
## Wat er nu staat
|
## Wat er nu staat
|
||||||
|
|
||||||
- **Backend** (`backend/`) — Django + Django REST Framework.
|
- **Backend** (`backend/`) — Django + Django REST Framework.
|
||||||
- `plugins/` — het plugin-framework: een moduleregister, opslag van de
|
- `plugins/` — het plugin-framework: een moduleregister, opslag van de
|
||||||
aan/uit-status per module en een beheer-API.
|
aan/uit-status per module en een beheer-API.
|
||||||
- `core/` — de kern (altijd actief). Domeinmodellen (personen, groepen,
|
- `core/` — de kern (altijd actief). **Fase 1**: modellen Persoon, Groep en
|
||||||
roosterblokken, kalender) volgen in fase 1–2.
|
Subgroep met een volledige CRUD-API. Roosterblokken en kalender volgen in
|
||||||
|
fase 2.
|
||||||
- `modules/printing/` en `modules/accounts/` — twee voorbeeldmodules die
|
- `modules/printing/` en `modules/accounts/` — twee voorbeeldmodules die
|
||||||
zich bij opstarten aanmelden bij het register.
|
zich bij opstarten aanmelden bij het register.
|
||||||
- **Frontend** (`frontend/`) — React (Vite) met een zijbalk die alleen menu's
|
- **Frontend** (`frontend/`) — React (Vite). Zijbalk met Roosterwijs-logo die
|
||||||
toont van ingeschakelde modules, plus een scherm **Modulebeheer** om modules
|
alleen menu's toont van ingeschakelde modules, beheerschermen voor
|
||||||
aan/uit te zetten.
|
**Personen**, **Groepen** en **Subgroepen**, en een scherm **Modulebeheer**.
|
||||||
|
|
||||||
## Hoe modulariteit werkt (het verkoopargument)
|
## Hoe modulariteit werkt (het verkoopargument)
|
||||||
|
|
||||||
|
|
@ -48,7 +50,9 @@ 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.
|
||||||
- `/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
|
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
|
||||||
> `ROOSTER_DB_PATH` naar een lokaal pad, of pas `DATABASES` aan.
|
> `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
|
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
|
||||||
aan/uit te zetten.
|
aan/uit te zetten.
|
||||||
|
|
||||||
## Volgende stap (Fase 1)
|
## Volgende stap (Fase 2)
|
||||||
|
|
||||||
Kerndomein bouwen: personen (leerlingen, leerkrachten, ondersteuners),
|
Schooljaar & roostering: een jaarkalender (studiedagen, vrije dagen, vakanties)
|
||||||
groepen en subgroepen — met beheerschermen.
|
en roosterblokken die je toewijst aan een groep, subgroep of individuele
|
||||||
|
leerling, met begeleiders en locatie.
|
||||||
|
|
|
||||||
26
backend/core/admin.py
Normal file
26
backend/core/admin.py
Normal 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",)
|
||||||
|
|
@ -30,6 +30,7 @@ class CoreConfig(AppConfig):
|
||||||
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("Groepen", "/groepen", icon="layers", order=30),
|
MenuItem("Groepen", "/groepen", icon="layers", order=30),
|
||||||
|
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
64
backend/core/migrations/0001_initial.py
Normal file
64
backend/core/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +1,104 @@
|
||||||
# Kerndomeinmodellen (personen, groepen, roosterblokken, kalender ...)
|
"""
|
||||||
# worden in Fase 1 en 2 toegevoegd. In Fase 0 ligt de nadruk op het
|
Kerndomein van Roosterwijs (Fase 1).
|
||||||
# fundament en het plugin-framework.
|
|
||||||
|
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
|
||||||
|
|
|
||||||
61
backend/core/serializers.py
Normal file
61
backend/core/serializers.py
Normal 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()
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from . import views
|
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 = [
|
urlpatterns = [
|
||||||
path("info/", views.info, name="info"),
|
path("info/", views.info, name="info"),
|
||||||
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
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 .serializers import GroepSerializer, PersoonSerializer, SubgroepSerializer
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def info(request):
|
def info(request):
|
||||||
|
|
@ -11,9 +15,30 @@ def info(request):
|
||||||
modules = services.describe_all()
|
modules = services.describe_all()
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"naam": "Roostersoftware",
|
"naam": "Roosterwijs",
|
||||||
"fase": "0 - fundament + plugin-framework",
|
"fase": "1 - kerndomein (personen, groepen, subgroepen)",
|
||||||
"modules_totaal": len(modules),
|
"modules_totaal": len(modules),
|
||||||
"modules_actief": sum(1 for m in modules if m["enabled"]),
|
"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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "roostersoftware-frontend",
|
"name": "roosterwijs-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
11
frontend/public/owl.svg
Normal file
11
frontend/public/owl.svg
Normal 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 |
|
|
@ -1,14 +1,24 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { getModules, setModuleState } from "./api.js";
|
import { getModules, setModuleState } from "./api.js";
|
||||||
|
import Logo from "./Logo.jsx";
|
||||||
|
import { GroepenPage, PersonenPage, SubgroepenPage } from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
||||||
key: "🔑", puzzle: "🧩",
|
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() {
|
export default function App() {
|
||||||
const [modules, setModules] = useState([]);
|
const [modules, setModules] = useState([]);
|
||||||
const [active, setActive] = useState("/rooster");
|
const [active, setActive] = useState("/personen");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -37,10 +47,15 @@ export default function App() {
|
||||||
return items.sort((a, b) => a.order - b.order);
|
return items.sort((a, b) => a.order - b.order);
|
||||||
}, [modules]);
|
}, [modules]);
|
||||||
|
|
||||||
|
const Page = PAGES[active];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<aside className="sidebar">
|
<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>
|
<nav>
|
||||||
{menu.map((it) => (
|
{menu.map((it) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -62,6 +77,8 @@ export default function App() {
|
||||||
<p>Laden…</p>
|
<p>Laden…</p>
|
||||||
) : active === "/modules" ? (
|
) : active === "/modules" ? (
|
||||||
<ModuleManager modules={modules} onChange={load} />
|
<ModuleManager modules={modules} onChange={load} />
|
||||||
|
) : Page ? (
|
||||||
|
<Page />
|
||||||
) : (
|
) : (
|
||||||
<Placeholder path={active} menu={menu} />
|
<Placeholder path={active} menu={menu} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -76,8 +93,8 @@ function Placeholder({ path, menu }) {
|
||||||
<section>
|
<section>
|
||||||
<h1>{item ? item.label : "Welkom"}</h1>
|
<h1>{item ? item.label : "Welkom"}</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Dit scherm wordt in een volgende fase ingevuld. Fase 0 levert het
|
Dit scherm wordt in een volgende fase ingevuld. Fase 1 levert het
|
||||||
fundament en het plugin-framework.
|
kerndomein: personen, groepen en subgroepen.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
36
frontend/src/Logo.jsx
Normal file
36
frontend/src/Logo.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,39 @@
|
||||||
// Dunne API-laag richting de Django-backend.
|
// Dunne API-laag richting de Django-backend.
|
||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
|
|
||||||
export async function getModules() {
|
async function request(method, url, body) {
|
||||||
const res = await fetch(`${BASE}/modules/`);
|
const opts = { method, headers: {} };
|
||||||
if (!res.ok) throw new Error("Kon modules niet laden");
|
if (body !== undefined) {
|
||||||
return res.json();
|
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;
|
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
481
frontend/src/pages.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,9 @@ body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color:
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
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 {
|
.nav-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
width: 100%; text-align: left;
|
width: 100%; text-align: left;
|
||||||
|
|
@ -60,3 +62,40 @@ h1 { margin: 0 0 6px; font-size: 24px; }
|
||||||
.toggle:disabled { opacity: .5; cursor: not-allowed; }
|
.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; }
|
.knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: left .15s; }
|
||||||
.toggle.on .knob { left: 21px; }
|
.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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue