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
|
||||
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.
|
||||
|
|
|
|||
18
ROADMAP.md
18
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.
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
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,
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue