Gebruikersmodule (rollen + persoonskoppeling), blok bewerken/kopiëren in Roostermaker, printopties per klas/leerplein/leerling/personeel

This commit is contained in:
bes-r 2026-06-12 23:35:19 +02:00
parent e8e0022359
commit 87c1559553
12 changed files with 490 additions and 19 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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")),
]

View 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",)

View 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',
},
),
]

View 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()})"

View 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

View 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))]

View 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")

View file

@ -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() {

View file

@ -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 ------------------------------ */
function Conflicten({ schooljaar }) {
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 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 ------------------------------- */
// Eén component met twee modi:
// - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen)
@ -1033,10 +1252,16 @@ export function RoosterScherm({ mode }) {
const [kalender, setKalender] = useState([]);
const [afwezig, setAfwezig] = useState([]);
const [uitzonderingen, setUitzonderingen] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false);
const [tab, setTab] = useState("week");
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 [form, setForm] = useState(leeg);
@ -1056,10 +1281,21 @@ export function RoosterScherm({ mode }) {
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("personen", "?rol=leerling").then(setLeerlingen).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.
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 vanISO = fmtISO(maandag);
const totISO = fmtISO(plusDagen(maandag, 6));
@ -1176,6 +1412,27 @@ export function RoosterScherm({ mode }) {
)}
</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 && (
<div className="tabbar no-print">
<button className={tab === "week" ? "tab on" : "tab"} onClick={() => setTab("week")}>Weekrooster</button>
@ -1241,7 +1498,7 @@ export function RoosterScherm({ mode }) {
</div>
{slots.length === 0 && <div className="muted small"></div>}
{slots.map((t) => {
const slotBlokken = blokken.filter((b) => b.tijdslot === t.id);
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id);
return (
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
@ -1254,14 +1511,16 @@ export function RoosterScherm({ mode }) {
const vervallen = !!kal || (uit && uit.vervalt);
return (
<div
className={vervallen ? "rooster-blok vervallen" : "rooster-blok"}
className={(vervallen ? "rooster-blok vervallen" : "rooster-blok") + (edit ? " klikbaar" : "")}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur }}
onClick={edit ? () => setBewerkBlok(b) : undefined}
title={edit ? "Klik om te bewerken" : undefined}
>
<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>
{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 className="small">🎯 {b.doel_naam} <span className="muted">({b.doel_type})</span></div>
{b.locatie_naam && <div className="small">📍 {b.locatie_naam}</div>}
@ -1271,10 +1530,10 @@ export function RoosterScherm({ mode }) {
))}</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>}
{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>
);
@ -1306,6 +1565,15 @@ export function RoosterScherm({ mode }) {
</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>}
</section>
);

View file

@ -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); }
/* --- 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 --- */
.print-knop { margin-left: auto; background: #fff; }
@media print {