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`
|
||||
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 1–2.
|
||||
- `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
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("Personen", "/personen", icon="users", order=20),
|
||||
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
|
||||
# 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
|
||||
|
|
|
|||
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
|
||||
|
||||
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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
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 { 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
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.
|
||||
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");
|
||||
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);
|
||||
}
|
||||
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;
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue