Gebruikersmodule (rollen + persoonskoppeling), blok bewerken/kopiëren in Roostermaker, printopties per klas/leerplein/leerling/personeel
This commit is contained in:
parent
e8e0022359
commit
87c1559553
12 changed files with 490 additions and 19 deletions
14
README.md
14
README.md
|
|
@ -20,9 +20,17 @@ Modulebeheer.
|
||||||
|
|
||||||
Er zijn twee rooster-schermen: **Rooster** (alleen-lezen weergave, om te bekijken
|
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
|
en af te drukken) en **Roostermaker** (de aanpasser met drag-and-drop, tabs en
|
||||||
conflictdetectie). De module **Printen/Exporteren** voegt — als die aanstaat — een
|
conflictdetectie). In de Roostermaker open je een blok met een **klik** om het te
|
||||||
subtiele **Afdrukken**-knop toe aan beide; die drukt het weekrooster
|
bewerken (vak, locatie, doelgroep, begeleiders, opmerking) of te **kopiëren** naar
|
||||||
printvriendelijk af (of bewaart als PDF via de printdialoog).
|
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
|
Optionele module **Leerplein**: koppelt subgroepen aan groepen en bundelt
|
||||||
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein.
|
groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein.
|
||||||
|
|
|
||||||
18
ROADMAP.md
18
ROADMAP.md
|
|
@ -6,12 +6,12 @@ volgt.
|
||||||
|
|
||||||
## Fase 6 — Rooster bewerken (snel & overzichtelijk)
|
## Fase 6 — Rooster bewerken (snel & overzichtelijk)
|
||||||
|
|
||||||
- [ ] **Blokken kopiëren** naar andere tijden en dagen (dupliceren naar een ander
|
- [x] **Blokken kopiëren** naar andere tijden en dagen (via het bewerkpaneel naar
|
||||||
tijdslot, evt. meerdere tegelijk).
|
meerdere tijdsloten tegelijk).
|
||||||
- [ ] **Blok aanpassen bij klik**: op een blok klikken opent direct een
|
- [x] **Blok aanpassen bij klik**: klik op een blok in de Roostermaker opent een
|
||||||
bewerkscherm (nu kan alleen slepen/verwijderen).
|
bewerkpaneel.
|
||||||
- [ ] **Snelle inline-aanpassing per blok**: groep, vak/activiteit, gekoppeld
|
- [x] **Snelle inline-aanpassing per blok**: vak/activiteit, locatie, doelgroep
|
||||||
personeel én individuele leerlingen wijzigen vanuit het blok zelf.
|
(groep/subgroep/leerling), begeleiders en opmerking wijzigen in het paneel.
|
||||||
- [ ] Vanuit het vak/blok direct een **subgroep aanmaken** (zonder naar het
|
- [ ] Vanuit het vak/blok direct een **subgroep aanmaken** (zonder naar het
|
||||||
Subgroepen-scherm te gaan).
|
Subgroepen-scherm te gaan).
|
||||||
- [ ] **Sneltoetsen** voor veelgebruikte acties (bv. nieuw blok, kopiëren,
|
- [ ] **Sneltoetsen** voor veelgebruikte acties (bv. nieuw blok, kopiëren,
|
||||||
|
|
@ -24,9 +24,9 @@ volgt.
|
||||||
|
|
||||||
## Fase 6 — Overzicht & navigatie
|
## Fase 6 — Overzicht & navigatie
|
||||||
|
|
||||||
- [ ] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt —
|
- [~] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt — een
|
||||||
bijvoorbeeld **per leerplein een eigen overzicht** in plaats van alles door
|
**scope-filter** (per klas/leerplein/leerling/personeel) is toegevoegd in
|
||||||
elkaar.
|
Rooster en Roostermaker; een vast **per-leerplein-overzicht** kan nog volgen.
|
||||||
- [ ] **Snelmenu** om per klas of per leerplein direct het rooster te tonen.
|
- [ ] **Snelmenu** om per klas of per leerplein direct het rooster te tonen.
|
||||||
- [x] **Schooljaar uit het hoofdmenu** halen en **onder Rooster** plaatsen
|
- [x] **Schooljaar uit het hoofdmenu** halen en **onder Rooster** plaatsen
|
||||||
(tab "Schooljaar") met snelle invoer + kalenderbeheer.
|
(tab "Schooljaar") met snelle invoer + kalenderbeheer.
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ urlpatterns = [
|
||||||
# Module-API's.
|
# Module-API's.
|
||||||
path("api/", include("modules.leerplein.urls")),
|
path("api/", include("modules.leerplein.urls")),
|
||||||
path("api/", include("modules.stage.urls")),
|
path("api/", include("modules.stage.urls")),
|
||||||
|
path("api/", include("modules.accounts.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
10
backend/modules/accounts/admin.py
Normal file
10
backend/modules/accounts/admin.py
Normal file
|
|
@ -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",)
|
||||||
31
backend/modules/accounts/migrations/0001_initial.py
Normal file
31
backend/modules/accounts/migrations/0001_initial.py
Normal file
|
|
@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
36
backend/modules/accounts/models.py
Normal file
36
backend/modules/accounts/models.py
Normal file
|
|
@ -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()})"
|
||||||
80
backend/modules/accounts/serializers.py
Normal file
80
backend/modules/accounts/serializers.py
Normal file
|
|
@ -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
|
||||||
9
backend/modules/accounts/urls.py
Normal file
9
backend/modules/accounts/urls.py
Normal file
|
|
@ -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))]
|
||||||
17
backend/modules/accounts/views.py
Normal file
17
backend/modules/accounts/views.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
|
||||||
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage,
|
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage,
|
||||||
RoosterWeergavePage, RoostermakerPage,
|
RoosterWeergavePage, RoostermakerPage,
|
||||||
AfwezigheidPage, LeerpleinPage, StagePage,
|
AfwezigheidPage, LeerpleinPage, StagePage, GebruikersPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|
@ -30,6 +30,7 @@ const PAGES = {
|
||||||
"/afwezigheid": AfwezigheidPage,
|
"/afwezigheid": AfwezigheidPage,
|
||||||
"/leerplein": LeerpleinPage,
|
"/leerplein": LeerpleinPage,
|
||||||
"/stage": StagePage,
|
"/stage": StagePage,
|
||||||
|
"/gebruikers": GebruikersPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<section>
|
||||||
|
<h1>Gebruikers</h1>
|
||||||
|
<p className="muted">Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.</p>
|
||||||
|
<Foutmelding msg={error} />
|
||||||
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
|
<div className="form-row">
|
||||||
|
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
||||||
|
<input type="password" placeholder={editId ? "Wachtwoord (leeg = ongewijzigd)" : "Wachtwoord"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
|
||||||
|
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
|
||||||
|
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input placeholder="Voornaam" value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} />
|
||||||
|
<input placeholder="Achternaam" value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} />
|
||||||
|
<input type="email" placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={form.persoon || ""} onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
|
||||||
|
<option value="">— koppel aan persoon (optioneel) —</option>
|
||||||
|
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
|
||||||
|
</select>
|
||||||
|
<label className="veld"><input type="checkbox" checked={form.is_staff} onChange={(e) => setForm({ ...form, is_staff: e.target.checked })} /> beheerder (is_staff)</label>
|
||||||
|
<label className="veld"><input type="checkbox" checked={form.is_active} onChange={(e) => setForm({ ...form, is_active: e.target.checked })} /> actief</label>
|
||||||
|
</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>Gebruiker</th><th>Rol</th><th>Persoon</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.username}{(u.first_name || u.last_name) && <div className="muted small">{u.first_name} {u.last_name}</div>}</td>
|
||||||
|
<td>{u.rol_label || "—"}{u.is_staff && <span className="tag core"> beheer</span>}</td>
|
||||||
|
<td className="muted">{u.persoon_naam || "—"}</td>
|
||||||
|
<td className="muted small">{u.is_active ? "actief" : "inactief"}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button className="link" onClick={() => bewerk(u)}>bewerken</button>
|
||||||
|
<button className="link danger" onClick={() => verwijder(u.id)}>verwijderen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <tr><td colSpan="5" className="muted">Geen gebruikers.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------- Conflicten ------------------------------ */
|
/* -------------------------------- Conflicten ------------------------------ */
|
||||||
function Conflicten({ schooljaar }) {
|
function Conflicten({ schooljaar }) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
|
@ -1013,6 +1110,128 @@ function fmtDM(d) { return `${pad2(d.getDate())}-${pad2(d.getMonth() + 1)}`; }
|
||||||
function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; }
|
function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; }
|
||||||
function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
||||||
|
|
||||||
|
/* ------------------------- Rooster-scope (filter/print) ------------------- */
|
||||||
|
// Welke leerlingen raakt een blok (via groep, subgroep of individueel)?
|
||||||
|
export function leerlingenVanBlok(blok, ctx) {
|
||||||
|
if (blok.groep) return ctx.leerlingen.filter((l) => l.groep === blok.groep).map((l) => l.id);
|
||||||
|
if (blok.subgroep) {
|
||||||
|
const s = ctx.subgroepen.find((x) => x.id === blok.subgroep);
|
||||||
|
return s ? s.leden_namen.map((m) => m.id) : [];
|
||||||
|
}
|
||||||
|
if (blok.leerling) return [blok.leerling];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Welke leerlingen vallen binnen een gekozen scope (klas/leerplein/leerling)?
|
||||||
|
export function scopeLeerlingen(scope, ctx) {
|
||||||
|
if (scope.type === "leerling") return [scope.id];
|
||||||
|
if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id);
|
||||||
|
if (scope.type === "leerplein") {
|
||||||
|
const p = ctx.leerpleinen.find((x) => x.id === scope.id);
|
||||||
|
if (!p) return [];
|
||||||
|
const ids = new Set(p.leerlingen || []);
|
||||||
|
(p.groepen || []).forEach((g) => ctx.leerlingen.filter((l) => l.groep === g).forEach((l) => ids.add(l.id)));
|
||||||
|
(p.subgroepen || []).forEach((sid) => {
|
||||||
|
const s = ctx.subgroepen.find((x) => x.id === sid);
|
||||||
|
if (s) s.leden_namen.forEach((m) => ids.add(m.id));
|
||||||
|
});
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Valt een blok binnen de gekozen scope?
|
||||||
|
export function blokInScope(blok, scope, ctx) {
|
||||||
|
if (scope.type === "alles" || !scope.id) return true;
|
||||||
|
if (scope.type === "personeel") return (blok.begeleiders || []).includes(scope.id);
|
||||||
|
const set = new Set(scopeLeerlingen(scope, ctx));
|
||||||
|
return leerlingenVanBlok(blok, ctx).some((id) => set.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- Blok bewerken/kopiëren --------------------- */
|
||||||
|
function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
|
||||||
|
const { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx;
|
||||||
|
const [f, setF] = useState({
|
||||||
|
activiteit: blok.activiteit,
|
||||||
|
locatie: blok.locatie || "",
|
||||||
|
doel_type: blok.doel_type,
|
||||||
|
doel: blok.groep || blok.subgroep || blok.leerling || "",
|
||||||
|
begeleiders: [...(blok.begeleiders || [])],
|
||||||
|
opmerkingen: blok.opmerkingen || "",
|
||||||
|
});
|
||||||
|
const [kopieDoelen, setKopieDoelen] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepen : leerlingen;
|
||||||
|
const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] });
|
||||||
|
const toggleKopie = (id) => setKopieDoelen((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]);
|
||||||
|
const doelPayload = () => ({
|
||||||
|
groep: f.doel_type === "groep" ? f.doel : null,
|
||||||
|
subgroep: f.doel_type === "subgroep" ? f.doel : null,
|
||||||
|
leerling: f.doel_type === "leerling" ? f.doel : null,
|
||||||
|
});
|
||||||
|
async function opslaan() {
|
||||||
|
try { await updateResource("roosterblokken", blok.id, { activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); }
|
||||||
|
catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
async function kopieer() {
|
||||||
|
try {
|
||||||
|
for (const ts of kopieDoelen) {
|
||||||
|
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen });
|
||||||
|
}
|
||||||
|
await onKlaar();
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
async function verwijderen() {
|
||||||
|
if (!confirm("Dit blok (alle weken) verwijderen?")) return;
|
||||||
|
try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onSluit}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-kop"><h2>Blok bewerken</h2><button className="link" onClick={onSluit}>sluiten ✕</button></div>
|
||||||
|
<Foutmelding msg={error} />
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={f.activiteit} onChange={(e) => setF({ ...f, activiteit: e.target.value })}>
|
||||||
|
{activiteiten.map((a) => <option key={a.id} value={a.id}>{a.naam}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={f.locatie} onChange={(e) => setF({ ...f, locatie: e.target.value })}>
|
||||||
|
<option value="">— locatie —</option>
|
||||||
|
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={f.doel_type} onChange={(e) => setF({ ...f, doel_type: e.target.value, doel: "" })}>
|
||||||
|
<option value="groep">Groep</option>
|
||||||
|
<option value="subgroep">Subgroep</option>
|
||||||
|
<option value="leerling">Individuele leerling</option>
|
||||||
|
</select>
|
||||||
|
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
|
||||||
|
<option value="">— kies {f.doel_type} —</option>
|
||||||
|
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="leden-keuze">
|
||||||
|
<div className="muted small">Begeleiders:</div>
|
||||||
|
<div className="chips">
|
||||||
|
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row"><input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} /></div>
|
||||||
|
<div className="form-row">
|
||||||
|
<button className="btn primary" onClick={opslaan}>Opslaan</button>
|
||||||
|
<button className="btn" onClick={verwijderen}>Verwijderen</button>
|
||||||
|
</div>
|
||||||
|
<hr className="modal-hr" />
|
||||||
|
<div className="muted small">Kopiëren naar andere tijden/dagen:</div>
|
||||||
|
<div className="chips">
|
||||||
|
{tijdsloten.map((t) => <button type="button" key={t.id} className={kopieDoelen.includes(t.id) ? "chip on" : "chip"} onClick={() => toggleKopie(t.id)}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</button>)}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<button className="btn" disabled={kopieDoelen.length === 0} onClick={kopieer}>Kopieer naar {kopieDoelen.length} tijdslot(en)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------- Rooster ------------------------------- */
|
/* ---------------------------------- Rooster ------------------------------- */
|
||||||
// Eén component met twee modi:
|
// Eén component met twee modi:
|
||||||
// - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen)
|
// - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen)
|
||||||
|
|
@ -1033,10 +1252,16 @@ export function RoosterScherm({ mode }) {
|
||||||
const [kalender, setKalender] = useState([]);
|
const [kalender, setKalender] = useState([]);
|
||||||
const [afwezig, setAfwezig] = useState([]);
|
const [afwezig, setAfwezig] = useState([]);
|
||||||
const [uitzonderingen, setUitzonderingen] = useState([]);
|
const [uitzonderingen, setUitzonderingen] = useState([]);
|
||||||
|
const [leerpleinen, setLeerpleinen] = useState([]);
|
||||||
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
|
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
|
||||||
const [toonForm, setToonForm] = useState(false);
|
const [toonForm, setToonForm] = useState(false);
|
||||||
const [tab, setTab] = useState("week");
|
const [tab, setTab] = useState("week");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
// Scope-filter (ook voor gericht afdrukken): per klas/leerplein/leerling/personeel.
|
||||||
|
const [scopeType, setScopeType] = useState("alles");
|
||||||
|
const [scopeId, setScopeId] = useState("");
|
||||||
|
// Blok bewerken/kopiëren (alleen in de Roostermaker).
|
||||||
|
const [bewerkBlok, setBewerkBlok] = useState(null);
|
||||||
|
|
||||||
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
|
const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
|
||||||
const [form, setForm] = useState(leeg);
|
const [form, setForm] = useState(leeg);
|
||||||
|
|
@ -1056,10 +1281,21 @@ export function RoosterScherm({ mode }) {
|
||||||
listResource("subgroepen").then(setSubgroepen).catch(() => {});
|
listResource("subgroepen").then(setSubgroepen).catch(() => {});
|
||||||
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
|
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
|
||||||
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
|
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
|
||||||
|
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
|
||||||
// Afdrukken alleen tonen als de print-module aanstaat.
|
// Afdrukken alleen tonen als de print-module aanstaat.
|
||||||
getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {});
|
getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Scope-context + gefilterde blokken (voor weergave/afdrukken).
|
||||||
|
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
|
||||||
|
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
|
||||||
|
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx));
|
||||||
|
const scopeOpties = scopeType === "klas" ? groepen
|
||||||
|
: scopeType === "leerplein" ? leerpleinen
|
||||||
|
: scopeType === "leerling" ? leerlingen
|
||||||
|
: scopeType === "personeel" ? medewerkers
|
||||||
|
: [];
|
||||||
|
|
||||||
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
|
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
|
||||||
const vanISO = fmtISO(maandag);
|
const vanISO = fmtISO(maandag);
|
||||||
const totISO = fmtISO(plusDagen(maandag, 6));
|
const totISO = fmtISO(plusDagen(maandag, 6));
|
||||||
|
|
@ -1176,6 +1412,27 @@ export function RoosterScherm({ mode }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{schooljaar && (edit ? tab === "week" : true) && (
|
||||||
|
<div className="rooster-balk no-print scope-balk">
|
||||||
|
<label className="veld">Toon
|
||||||
|
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
|
||||||
|
<option value="alles">Alles</option>
|
||||||
|
<option value="klas">Per klas</option>
|
||||||
|
<option value="leerplein">Per leerplein</option>
|
||||||
|
<option value="leerling">Per leerling</option>
|
||||||
|
<option value="personeel">Per personeel</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{scopeType !== "alles" && (
|
||||||
|
<select value={scopeId} onChange={(e) => setScopeId(e.target.value)}>
|
||||||
|
<option value="">— kies —</option>
|
||||||
|
{scopeOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{scopeType !== "alles" && scopeId && <span className="muted small">{zichtbareBlokken.length} blok(ken)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{edit && (
|
{edit && (
|
||||||
<div className="tabbar no-print">
|
<div className="tabbar no-print">
|
||||||
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
|
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
|
||||||
|
|
@ -1241,7 +1498,7 @@ export function RoosterScherm({ mode }) {
|
||||||
</div>
|
</div>
|
||||||
{slots.length === 0 && <div className="muted small">—</div>}
|
{slots.length === 0 && <div className="muted small">—</div>}
|
||||||
{slots.map((t) => {
|
{slots.map((t) => {
|
||||||
const slotBlokken = blokken.filter((b) => b.tijdslot === t.id);
|
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
|
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
|
||||||
|
|
@ -1254,14 +1511,16 @@ export function RoosterScherm({ mode }) {
|
||||||
const vervallen = !!kal || (uit && uit.vervalt);
|
const vervallen = !!kal || (uit && uit.vervalt);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={vervallen ? "rooster-blok vervallen" : "rooster-blok"}
|
className={(vervallen ? "rooster-blok vervallen" : "rooster-blok") + (edit ? " klikbaar" : "")}
|
||||||
key={b.id}
|
key={b.id}
|
||||||
style={{ borderLeftColor: b.activiteit_kleur }}
|
style={{ borderLeftColor: b.activiteit_kleur }}
|
||||||
|
onClick={edit ? () => setBewerkBlok(b) : undefined}
|
||||||
|
title={edit ? "Klik om te bewerken" : undefined}
|
||||||
>
|
>
|
||||||
<div className="rb-top">
|
<div className="rb-top">
|
||||||
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} title="Sleep naar ander tijdslot">⠿</span>}
|
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿</span>}
|
||||||
<strong>{b.activiteit_naam}</strong>
|
<strong>{b.activiteit_naam}</strong>
|
||||||
{edit && <button className="link danger rb-x" onClick={() => verwijder(b.id)} title="Blok verwijderen">×</button>}
|
{edit && <button className="link danger rb-x" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="small">🎯 {b.doel_naam} <span className="muted">({b.doel_type})</span></div>
|
<div className="small">🎯 {b.doel_naam} <span className="muted">({b.doel_type})</span></div>
|
||||||
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
|
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
|
||||||
|
|
@ -1271,10 +1530,10 @@ export function RoosterScherm({ mode }) {
|
||||||
))}</div>
|
))}</div>
|
||||||
)}
|
)}
|
||||||
{kal && <div className="small rb-status">Vervalt — {kal.type_label}</div>}
|
{kal && <div className="small rb-status">Vervalt — {kal.type_label}</div>}
|
||||||
{!kal && uit && uit.vervalt && <div className="small rb-status">Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <> · <button className="link" onClick={() => herstel(uit.id)}>herstel</button></>}</div>}
|
{!kal && uit && uit.vervalt && <div className="small rb-status">Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <> · <button className="link" onClick={(e) => { e.stopPropagation(); herstel(uit.id); }}>herstel</button></>}</div>}
|
||||||
{!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">↪ Vervanger: {uit.vervanger_naam}</div>}
|
{!kal && uit && !uit.vervalt && uit.vervanger_naam && <div className="small rb-verv">↪ Vervanger: {uit.vervanger_naam}</div>}
|
||||||
{edit && !kal && !uit && (
|
{edit && !kal && !uit && (
|
||||||
<button className="link danger small rb-verval no-print" onClick={() => laatVervallen(b, iso)}>deze dag laten vervallen</button>
|
<button className="link danger small rb-verval no-print" onClick={(e) => { e.stopPropagation(); laatVervallen(b, iso); }}>deze dag laten vervallen</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1306,6 +1565,15 @@ export function RoosterScherm({ mode }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{edit && bewerkBlok && (
|
||||||
|
<BlokBewerker
|
||||||
|
blok={bewerkBlok}
|
||||||
|
ctx={{ activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten }}
|
||||||
|
onSluit={() => setBewerkBlok(null)}
|
||||||
|
onKlaar={async () => { setBewerkBlok(null); await laadBlokken(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{edit && ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
|
{edit && ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,16 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
||||||
|
|
||||||
.drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
|
.drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); }
|
||||||
|
|
||||||
|
/* --- Blok-bewerker (modal) --- */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.45); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 900; overflow: auto; }
|
||||||
|
.modal { background: var(--panel); border-radius: 12px; padding: 18px 20px; width: min(560px, 100%); box-shadow: 0 20px 50px rgba(0,0,0,.3); }
|
||||||
|
.modal-kop { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.modal-kop h2 { margin: 0; font-size: 18px; }
|
||||||
|
.modal-hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0 12px; }
|
||||||
|
.rooster-blok.klikbaar { cursor: pointer; }
|
||||||
|
.rooster-blok.klikbaar:hover { box-shadow: 0 0 0 2px var(--brand-soft); }
|
||||||
|
.scope-balk { margin-top: -6px; }
|
||||||
|
|
||||||
/* --- Afdrukken --- */
|
/* --- Afdrukken --- */
|
||||||
.print-knop { margin-left: auto; background: #fff; }
|
.print-knop { margin-left: auto; background: #fff; }
|
||||||
@media print {
|
@media print {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue