diff --git a/README.md b/README.md
index 41e2253..e6da926 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,17 @@ Modulebeheer.
Er zijn twee rooster-schermen: **Rooster** (alleen-lezen weergave, om te bekijken
en af te drukken) en **Roostermaker** (de aanpasser met drag-and-drop, tabs en
-conflictdetectie). De module **Printen/Exporteren** voegt — als die aanstaat — een
-subtiele **Afdrukken**-knop toe aan beide; die drukt het weekrooster
-printvriendelijk af (of bewaart als PDF via de printdialoog).
+conflictdetectie). In de Roostermaker open je een blok met een **klik** om het te
+bewerken (vak, locatie, doelgroep, begeleiders, opmerking) of te **kopiëren** naar
+andere tijden/dagen. Beide schermen hebben een **scope-filter** (alles / per klas /
+per leerplein / per leerling / per personeel); de module **Printen/Exporteren**
+voegt — als die aanstaat — een subtiele **Afdrukken**-knop toe die het (gefilterde)
+weekrooster printvriendelijk afdrukt of als PDF bewaart.
+
+Optionele module **Multi-user & toegang**: een **Gebruikers**-scherm (onder
+Instellingen, alleen voor beheerders) om inloggers te beheren — gebruikersnaam,
+wachtwoord, rol (beheerder/medewerker/ouder/leerling), koppeling aan een persoon,
+en actief/beheerder-status.
Optionele module **Leerplein**: koppelt subgroepen aan groepen en bundelt
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein.
diff --git a/ROADMAP.md b/ROADMAP.md
index c57c7e6..15a582a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,12 +6,12 @@ volgt.
## Fase 6 — Rooster bewerken (snel & overzichtelijk)
-- [ ] **Blokken kopiëren** naar andere tijden en dagen (dupliceren naar een ander
- tijdslot, evt. meerdere tegelijk).
-- [ ] **Blok aanpassen bij klik**: op een blok klikken opent direct een
- bewerkscherm (nu kan alleen slepen/verwijderen).
-- [ ] **Snelle inline-aanpassing per blok**: groep, vak/activiteit, gekoppeld
- personeel én individuele leerlingen wijzigen vanuit het blok zelf.
+- [x] **Blokken kopiëren** naar andere tijden en dagen (via het bewerkpaneel naar
+ meerdere tijdsloten tegelijk).
+- [x] **Blok aanpassen bij klik**: klik op een blok in de Roostermaker opent een
+ bewerkpaneel.
+- [x] **Snelle inline-aanpassing per blok**: vak/activiteit, locatie, doelgroep
+ (groep/subgroep/leerling), begeleiders en opmerking wijzigen in het paneel.
- [ ] Vanuit het vak/blok direct een **subgroep aanmaken** (zonder naar het
Subgroepen-scherm te gaan).
- [ ] **Sneltoetsen** voor veelgebruikte acties (bv. nieuw blok, kopiëren,
@@ -24,9 +24,9 @@ volgt.
## Fase 6 — Overzicht & navigatie
-- [ ] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt —
- bijvoorbeeld **per leerplein een eigen overzicht** in plaats van alles door
- elkaar.
+- [~] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt — een
+ **scope-filter** (per klas/leerplein/leerling/personeel) is toegevoegd in
+ Rooster en Roostermaker; een vast **per-leerplein-overzicht** kan nog volgen.
- [ ] **Snelmenu** om per klas of per leerplein direct het rooster te tonen.
- [x] **Schooljaar uit het hoofdmenu** halen en **onder Rooster** plaatsen
(tab "Schooljaar") met snelle invoer + kalenderbeheer.
diff --git a/backend/config/urls.py b/backend/config/urls.py
index 6b8d9b0..06167ea 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -11,4 +11,5 @@ urlpatterns = [
# Module-API's.
path("api/", include("modules.leerplein.urls")),
path("api/", include("modules.stage.urls")),
+ path("api/", include("modules.accounts.urls")),
]
diff --git a/backend/modules/accounts/admin.py b/backend/modules/accounts/admin.py
new file mode 100644
index 0000000..d55ebf8
--- /dev/null
+++ b/backend/modules/accounts/admin.py
@@ -0,0 +1,10 @@
+from django.contrib import admin
+
+from .models import Gebruikersprofiel
+
+
+@admin.register(Gebruikersprofiel)
+class GebruikersprofielAdmin(admin.ModelAdmin):
+ list_display = ("user", "rol", "persoon")
+ list_filter = ("rol",)
+ search_fields = ("user__username",)
diff --git a/backend/modules/accounts/migrations/0001_initial.py b/backend/modules/accounts/migrations/0001_initial.py
new file mode 100644
index 0000000..4844c20
--- /dev/null
+++ b/backend/modules/accounts/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.2.15
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('core', '0004_activiteit_theorievak_persoon_geboortedatum_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Gebruikersprofiel',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('rol', models.CharField(choices=[('beheerder', 'Beheerder'), ('medewerker', 'Medewerker'), ('ouder', 'Ouder'), ('leerling', 'Leerling')], default='medewerker', max_length=16, verbose_name='rol')),
+ ('persoon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gebruikers', to='core.persoon')),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='gebruikersprofiel', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'gebruikersprofiel',
+ 'verbose_name_plural': 'gebruikersprofielen',
+ },
+ ),
+ ]
diff --git a/backend/modules/accounts/models.py b/backend/modules/accounts/models.py
new file mode 100644
index 0000000..5581017
--- /dev/null
+++ b/backend/modules/accounts/models.py
@@ -0,0 +1,36 @@
+"""
+Accounts-module: gebruikersbeheer.
+
+Bovenop Django's ingebouwde User leggen we een profiel met een rol en een
+optionele koppeling aan een persoon (leerling/medewerker) uit de kern.
+"""
+from django.conf import settings
+from django.db import models
+
+
+class Gebruikersrol(models.TextChoices):
+ BEHEERDER = "beheerder", "Beheerder"
+ MEDEWERKER = "medewerker", "Medewerker"
+ OUDER = "ouder", "Ouder"
+ LEERLING = "leerling", "Leerling"
+
+
+class Gebruikersprofiel(models.Model):
+ user = models.OneToOneField(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="gebruikersprofiel"
+ )
+ rol = models.CharField(
+ "rol", max_length=16, choices=Gebruikersrol.choices, default=Gebruikersrol.MEDEWERKER
+ )
+ persoon = models.ForeignKey(
+ "core.Persoon", on_delete=models.SET_NULL, null=True, blank=True,
+ related_name="gebruikers",
+ help_text="Koppeling aan een leerling of medewerker uit de kern.",
+ )
+
+ class Meta:
+ verbose_name = "gebruikersprofiel"
+ verbose_name_plural = "gebruikersprofielen"
+
+ def __str__(self) -> str:
+ return f"{self.user.get_username()} ({self.get_rol_display()})"
diff --git a/backend/modules/accounts/serializers.py b/backend/modules/accounts/serializers.py
new file mode 100644
index 0000000..87393ff
--- /dev/null
+++ b/backend/modules/accounts/serializers.py
@@ -0,0 +1,80 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+
+from core.models import Persoon
+
+from .models import Gebruikersprofiel, Gebruikersrol
+
+User = get_user_model()
+
+
+class GebruikerSerializer(serializers.ModelSerializer):
+ password = serializers.CharField(write_only=True, required=False, allow_blank=True, style={"input_type": "password"})
+ rol = serializers.ChoiceField(choices=Gebruikersrol.choices, required=False)
+ persoon = serializers.PrimaryKeyRelatedField(
+ queryset=Persoon.objects.all(), required=False, allow_null=True
+ )
+ persoon_naam = serializers.SerializerMethodField()
+ rol_label = serializers.SerializerMethodField()
+
+ class Meta:
+ model = User
+ fields = [
+ "id", "username", "password", "first_name", "last_name", "email",
+ "is_staff", "is_active", "rol", "rol_label", "persoon", "persoon_naam",
+ "last_login",
+ ]
+ read_only_fields = ["last_login"]
+
+ # --- profiel-helpers ---
+ def _profiel(self, user):
+ return getattr(user, "gebruikersprofiel", None)
+
+ def get_persoon_naam(self, user):
+ p = self._profiel(user)
+ return p.persoon.volledige_naam if (p and p.persoon_id) else None
+
+ def get_rol_label(self, user):
+ p = self._profiel(user)
+ return p.get_rol_display() if p else None
+
+ def to_representation(self, user):
+ data = super().to_representation(user)
+ p = self._profiel(user)
+ data["rol"] = p.rol if p else None
+ data["persoon"] = p.persoon_id if p else None
+ return data
+
+ def validate(self, data):
+ if not self.instance and not data.get("password"):
+ raise serializers.ValidationError({"password": "Een wachtwoord is verplicht voor een nieuwe gebruiker."})
+ return data
+
+ def create(self, validated_data):
+ rol = validated_data.pop("rol", Gebruikersrol.MEDEWERKER)
+ persoon = validated_data.pop("persoon", None)
+ password = validated_data.pop("password", None)
+ user = User(**validated_data)
+ user.set_password(password)
+ user.save()
+ Gebruikersprofiel.objects.create(user=user, rol=rol, persoon=persoon)
+ return user
+
+ def update(self, user, validated_data):
+ rol = validated_data.pop("rol", None)
+ persoon = validated_data.pop("persoon", "__keep__")
+ password = validated_data.pop("password", None)
+ for k, v in validated_data.items():
+ setattr(user, k, v)
+ if password:
+ user.set_password(password)
+ user.save()
+ profiel, _ = Gebruikersprofiel.objects.get_or_create(user=user)
+ if rol is not None:
+ profiel.rol = rol
+ if persoon != "__keep__":
+ profiel.persoon = persoon
+ profiel.save()
+ # Ververs de gecachete reverse-relatie, anders toont de respons de oude rol.
+ user.gebruikersprofiel = profiel
+ return user
diff --git a/backend/modules/accounts/urls.py b/backend/modules/accounts/urls.py
new file mode 100644
index 0000000..7d560c1
--- /dev/null
+++ b/backend/modules/accounts/urls.py
@@ -0,0 +1,9 @@
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from . import views
+
+router = DefaultRouter()
+router.register("gebruikers", views.GebruikerViewSet, basename="gebruiker")
+
+urlpatterns = [path("", include(router.urls))]
diff --git a/backend/modules/accounts/views.py b/backend/modules/accounts/views.py
new file mode 100644
index 0000000..d785aa0
--- /dev/null
+++ b/backend/modules/accounts/views.py
@@ -0,0 +1,17 @@
+from django.contrib.auth import get_user_model
+from rest_framework import viewsets
+from rest_framework.permissions import IsAdminUser
+
+from .serializers import GebruikerSerializer
+
+User = get_user_model()
+
+
+class GebruikerViewSet(viewsets.ModelViewSet):
+ """Gebruikersbeheer — alleen voor beheerders (is_staff)."""
+
+ serializer_class = GebruikerSerializer
+ permission_classes = [IsAdminUser]
+
+ def get_queryset(self):
+ return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username")
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8b6f9ed..11142af 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -5,7 +5,7 @@ import {
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage,
- AfwezigheidPage, LeerpleinPage, StagePage,
+ AfwezigheidPage, LeerpleinPage, StagePage, GebruikersPage,
} from "./pages.jsx";
const ICONS = {
@@ -30,6 +30,7 @@ const PAGES = {
"/afwezigheid": AfwezigheidPage,
"/leerplein": LeerpleinPage,
"/stage": StagePage,
+ "/gebruikers": GebruikersPage,
};
export default function App() {
diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx
index 5a9d60d..429350a 100644
--- a/frontend/src/pages.jsx
+++ b/frontend/src/pages.jsx
@@ -981,6 +981,103 @@ export function StagePage() {
);
}
+/* -------------------------------- Gebruikers ------------------------------ */
+const GEBRUIKERSROLLEN = [
+ { value: "beheerder", label: "Beheerder" },
+ { value: "medewerker", label: "Medewerker" },
+ { value: "ouder", label: "Ouder" },
+ { value: "leerling", label: "Leerling" },
+];
+
+export function GebruikersPage() {
+ const { items, error, loading, reload, setError } = useList("gebruikers");
+ const [personen, setPersonen] = useState([]);
+ const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true };
+ const [form, setForm] = useState(leeg);
+ const [editId, setEditId] = useState(null);
+
+ useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
+
+ async function opslaan(e) {
+ e.preventDefault();
+ const payload = {
+ username: form.username, first_name: form.first_name, last_name: form.last_name,
+ email: form.email, rol: form.rol, persoon: form.persoon || null,
+ is_staff: form.is_staff, is_active: form.is_active,
+ };
+ if (form.password) payload.password = form.password;
+ try {
+ if (editId) await updateResource("gebruikers", editId, payload);
+ else await createResource("gebruikers", payload);
+ setForm(leeg);
+ setEditId(null);
+ await reload();
+ } catch (err) { setError(err.message); }
+ }
+ async function verwijder(id) {
+ if (!confirm("Deze gebruiker verwijderen?")) return;
+ try { await deleteResource("gebruikers", id); await reload(); } catch (err) { setError(err.message); }
+ }
+ function bewerk(u) {
+ setEditId(u.id);
+ setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active });
+ }
+
+ return (
+ Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken. Laden…Gebruikers
+
+
+ )}
+
+
+ {items.map((u) => (
+ Gebruiker Rol Persoon Status
+
+ ))}
+ {items.length === 0 && {u.username}{(u.first_name || u.last_name) &&
+ {u.rol_label || "—"}{u.is_staff && beheer}
+ {u.persoon_naam || "—"}
+ {u.is_active ? "actief" : "inactief"}
+
+
+
+
+ }
+
+ Geen gebruikers.