Fase 3 - Afwezigheid, uitzonderingen, vrije dagen; touchscreen

This commit is contained in:
bes-r 2026-06-10 15:21:06 +02:00
parent 4fcc169c7c
commit 0117c754fa
11 changed files with 477 additions and 107 deletions

View file

@ -4,10 +4,13 @@ Modulair, flexibel roostersysteem voor het speciaal onderwijs (uil-logo: een
uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de uil staat voor wijsheid → *Roosterwijs*). Zie `PLAN.md` voor de visie en de
fasering, en `DEPLOY.md` voor draaien op een test-/productieserver. fasering, en `DEPLOY.md` voor draaien op een test-/productieserver.
Status: **Fase 0** (fundament + plugin-framework), **Fase 1** (kerndomein: Status: **Fase 03** klaar. Fase 0 (plugin-framework), Fase 1 (personen,
personen, groepen, subgroepen, functies) en **Fase 2** (schooljaarkalender + groepen, subgroepen, functies), Fase 2 (schooljaarkalender + roosterblokken) en
roosterblokken) zijn klaar. Medewerkers kunnen meerdere functies hebben uit een Fase 3 (afwezigheid, uitzonderingen, effectief weekrooster). Medewerkers kunnen
beheerbare functie-catalogus. meerdere functies hebben uit een beheerbare functie-catalogus. Het Rooster-scherm
is een **weekweergave met drag-and-drop** (muis én touch) en een
**touch-vriendelijk** ontwerp; vrije dagen, afwezigheid en uitzonderingen worden
automatisch in het rooster verwerkt.
## Wat er nu staat ## Wat er nu staat
@ -57,7 +60,9 @@ API-eindpunten:
`/api/subgroepen/`, `/api/functies/`, `/api/schooljaren/`, `/api/subgroepen/`, `/api/functies/`, `/api/schooljaren/`,
`/api/kalenderdagen/` (filter `?schooljaar=<id>`), `/api/tijdsloten/`, `/api/kalenderdagen/` (filter `?schooljaar=<id>`), `/api/tijdsloten/`,
`/api/activiteiten/`, `/api/locaties/`, `/api/roosterblokken/` `/api/activiteiten/`, `/api/locaties/`, `/api/roosterblokken/`
(filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`). (filters `?schooljaar=`, `?groep=`, `?subgroep=`, `?leerling=`),
`/api/afwezigheden/` (filters `?persoon=`, `?van=`, `?tot=`),
`/api/blok-uitzonderingen/` (filters `?roosterblok=`, `?schooljaar=`, `?van=`, `?tot=`).
- `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus). - `/admin/` — Django-admin (personen, groepen, subgroepen en modulestatus).
> Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan > Draait SQLite niet op je projectmap (bv. op een netwerkschijf)? Zet dan
@ -75,8 +80,7 @@ De dev-server stuurt `/api` automatisch door naar de backend op poort 8000.
Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules Open daarna http://localhost:5173 en ga naar **Modulebeheer** om modules
aan/uit te zetten. aan/uit te zetten.
## Volgende stap (Fase 3 & 4) ## Volgende stap (Fase 4)
Flexibiliteit & uitzonderingen (individuele afwijkingen, afwezigheid/vervanging, Conflictdetectie: automatisch signaleren van dubbele inzet van leerkrachten,
vrije dagen die blokken automatisch uitschakelen) en conflictdetectie ondersteuners of leerlingen op hetzelfde moment, en locatiebotsingen.
(dubbele inzet van personen/leerlingen, locatiebotsingen).

View file

@ -1,8 +1,8 @@
from django.contrib import admin from django.contrib import admin
from .models import ( from .models import (
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
Roosterblok, Schooljaar, Subgroep, Tijdslot, Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot,
) )
@ -76,3 +76,18 @@ class RoosterblokAdmin(admin.ModelAdmin):
list_display = ("activiteit", "tijdslot", "doel_type", "locatie", "schooljaar") list_display = ("activiteit", "tijdslot", "doel_type", "locatie", "schooljaar")
list_filter = ("schooljaar", "tijdslot__dag", "activiteit") list_filter = ("schooljaar", "tijdslot__dag", "activiteit")
filter_horizontal = ("begeleiders",) filter_horizontal = ("begeleiders",)
@admin.register(Afwezigheid)
class AfwezigheidAdmin(admin.ModelAdmin):
list_display = ("persoon", "start_datum", "eind_datum", "reden")
list_filter = ("start_datum",)
search_fields = ("persoon__voornaam", "persoon__achternaam")
date_hierarchy = "start_datum"
@admin.register(BlokUitzondering)
class BlokUitzonderingAdmin(admin.ModelAdmin):
list_display = ("roosterblok", "datum", "vervalt", "vervanger")
list_filter = ("vervalt", "datum")
date_hierarchy = "datum"

View file

@ -36,6 +36,7 @@ class CoreConfig(AppConfig):
MenuItem("Locaties", "/locaties", icon="pin", order=55), MenuItem("Locaties", "/locaties", icon="pin", order=55),
MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60), MenuItem("Tijdsloten", "/tijdsloten", icon="clock", order=60),
MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70), MenuItem("Schooljaar", "/schooljaar", icon="calendar", order=70),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=80),
), ),
) )
) )

View file

@ -0,0 +1,47 @@
# Generated by Django 5.2.15 on 2026-06-10 13:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_activiteit_functie_locatie_schooljaar_tijdslot_and_more'),
]
operations = [
migrations.CreateModel(
name='Afwezigheid',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_datum', models.DateField(verbose_name='van')),
('eind_datum', models.DateField(verbose_name='tot en met')),
('reden', models.CharField(blank=True, max_length=160, verbose_name='reden')),
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
('persoon', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='afwezigheden', to='core.persoon')),
],
options={
'verbose_name': 'afwezigheid',
'verbose_name_plural': 'afwezigheden',
'ordering': ['-start_datum'],
},
),
migrations.CreateModel(
name='BlokUitzondering',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datum', models.DateField(verbose_name='datum')),
('vervalt', models.BooleanField(default=True, verbose_name='vervalt')),
('opmerking', models.CharField(blank=True, max_length=200, verbose_name='opmerking')),
('roosterblok', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uitzonderingen', to='core.roosterblok')),
('vervanger', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vervangingen', to='core.persoon')),
],
options={
'verbose_name': 'blok-uitzondering',
'verbose_name_plural': 'blok-uitzonderingen',
'ordering': ['datum'],
'constraints': [models.UniqueConstraint(fields=('roosterblok', 'datum'), name='uniek_blok_datum')],
},
),
]

View file

@ -334,3 +334,65 @@ class Roosterblok(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.activiteit} @ {self.tijdslot}" return f"{self.activiteit} @ {self.tijdslot}"
# --------------------------------------------------------------------------
# Fase 3: flexibiliteit en uitzonderingen
# --------------------------------------------------------------------------
class Afwezigheid(models.Model):
"""Een persoon (leerling of medewerker) is afwezig in een periode."""
persoon = models.ForeignKey(
Persoon, on_delete=models.CASCADE, related_name="afwezigheden"
)
start_datum = models.DateField("van")
eind_datum = models.DateField("tot en met")
reden = models.CharField("reden", max_length=160, blank=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "afwezigheid"
verbose_name_plural = "afwezigheden"
ordering = ["-start_datum"]
def clean(self):
if self.eind_datum and self.start_datum and self.eind_datum < self.start_datum:
raise ValidationError("De einddatum ligt vóór de startdatum.")
def __str__(self) -> str:
return f"{self.persoon} afwezig {self.start_datum}{self.eind_datum}"
class BlokUitzondering(models.Model):
"""Een eenmalige afwijking op een roosterblok voor één specifieke datum.
- vervalt=True -> het blok gaat die dag niet door;
- vervanger -> een vervangende begeleider voor die dag;
- opmerking -> toelichting (bv. 'in ander lokaal').
"""
roosterblok = models.ForeignKey(
Roosterblok, on_delete=models.CASCADE, related_name="uitzonderingen"
)
datum = models.DateField("datum")
vervalt = models.BooleanField("vervalt", default=True)
vervanger = models.ForeignKey(
Persoon, on_delete=models.SET_NULL, null=True, blank=True,
related_name="vervangingen",
)
opmerking = models.CharField("opmerking", max_length=200, blank=True)
class Meta:
verbose_name = "blok-uitzondering"
verbose_name_plural = "blok-uitzonderingen"
ordering = ["datum"]
constraints = [
models.UniqueConstraint(
fields=["roosterblok", "datum"], name="uniek_blok_datum"
)
]
def __str__(self) -> str:
wat = "vervalt" if self.vervalt else "wijziging"
return f"{self.roosterblok} op {self.datum} ({wat})"

View file

@ -2,8 +2,8 @@
from rest_framework import serializers from rest_framework import serializers
from .models import ( from .models import (
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
Roosterblok, Schooljaar, Subgroep, Tijdslot, Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot,
) )
@ -157,3 +157,36 @@ class RoosterblokSerializer(serializers.ModelSerializer):
"Kies precies één doelgroep: groep, subgroep of leerling." "Kies precies één doelgroep: groep, subgroep of leerling."
) )
return data return data
class AfwezigheidSerializer(serializers.ModelSerializer):
persoon_naam = serializers.CharField(source="persoon.volledige_naam", read_only=True)
persoon_rol = serializers.CharField(source="persoon.rol", read_only=True)
class Meta:
model = Afwezigheid
fields = [
"id", "persoon", "persoon_naam", "persoon_rol",
"start_datum", "eind_datum", "reden", "aangemaakt_op",
]
read_only_fields = ["aangemaakt_op"]
def validate(self, data):
start = data.get("start_datum", getattr(self.instance, "start_datum", None))
eind = data.get("eind_datum", getattr(self.instance, "eind_datum", None))
if start and eind and eind < start:
raise serializers.ValidationError("De einddatum ligt vóór de startdatum.")
return data
class BlokUitzonderingSerializer(serializers.ModelSerializer):
vervanger_naam = serializers.CharField(
source="vervanger.volledige_naam", read_only=True, default=None
)
class Meta:
model = BlokUitzondering
fields = [
"id", "roosterblok", "datum", "vervalt",
"vervanger", "vervanger_naam", "opmerking",
]

View file

@ -14,6 +14,8 @@ router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot")
router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit") router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit")
router.register("locaties", views.LocatieViewSet, basename="locatie") router.register("locaties", views.LocatieViewSet, basename="locatie")
router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok") router.register("roosterblokken", views.RoosterblokViewSet, basename="roosterblok")
router.register("afwezigheden", views.AfwezigheidViewSet, basename="afwezigheid")
router.register("blok-uitzonderingen", views.BlokUitzonderingViewSet, basename="blok-uitzondering")
urlpatterns = [ urlpatterns = [
path("health/", views.health, name="health"), path("health/", views.health, name="health"),

View file

@ -10,14 +10,14 @@ from rest_framework.response import Response
from plugins import services from plugins import services
from .models import ( from .models import (
Activiteit, Functie, Groep, KalenderDag, Locatie, Persoon, Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
Roosterblok, Schooljaar, Subgroep, Tijdslot, Locatie, Persoon, Roosterblok, Schooljaar, Subgroep, Tijdslot,
) )
from .serializers import ( from .serializers import (
ActiviteitSerializer, FunctieSerializer, GroepSerializer, ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer,
KalenderDagSerializer, LocatieSerializer, PersoonSerializer, FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer,
RoosterblokSerializer, SchooljaarSerializer, SubgroepSerializer, PersoonSerializer, RoosterblokSerializer, SchooljaarSerializer,
TijdslotSerializer, SubgroepSerializer, TijdslotSerializer,
) )
logger = logging.getLogger("roosterwijs.auth") logger = logging.getLogger("roosterwijs.auth")
@ -170,3 +170,41 @@ class RoosterblokViewSet(viewsets.ModelViewSet):
if waarde: if waarde:
qs = qs.filter(**{f"{veld}_id": waarde}) qs = qs.filter(**{f"{veld}_id": waarde})
return qs return qs
class AfwezigheidViewSet(viewsets.ModelViewSet):
serializer_class = AfwezigheidSerializer
def get_queryset(self):
qs = Afwezigheid.objects.select_related("persoon").all()
persoon = self.request.query_params.get("persoon")
if persoon:
qs = qs.filter(persoon_id=persoon)
# Overlap met een periode [van, tot]: start <= tot EN eind >= van.
van = self.request.query_params.get("van")
tot = self.request.query_params.get("tot")
if van:
qs = qs.filter(eind_datum__gte=van)
if tot:
qs = qs.filter(start_datum__lte=tot)
return qs
class BlokUitzonderingViewSet(viewsets.ModelViewSet):
serializer_class = BlokUitzonderingSerializer
def get_queryset(self):
qs = BlokUitzondering.objects.select_related("vervanger", "roosterblok").all()
blok = self.request.query_params.get("roosterblok")
if blok:
qs = qs.filter(roosterblok_id=blok)
van = self.request.query_params.get("van")
tot = self.request.query_params.get("tot")
if van:
qs = qs.filter(datum__gte=van)
if tot:
qs = qs.filter(datum__lte=tot)
schooljaar = self.request.query_params.get("schooljaar")
if schooljaar:
qs = qs.filter(roosterblok__schooljaar_id=schooljaar)
return qs

View file

@ -4,11 +4,13 @@ import Logo from "./Logo.jsx";
import { import {
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage, GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage, ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterPage,
AfwezigheidPage,
} from "./pages.jsx"; } from "./pages.jsx";
const ICONS = { const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
away: "🌴",
}; };
// Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een // Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een
@ -23,6 +25,7 @@ const PAGES = {
"/locaties": LocatiesPage, "/locaties": LocatiesPage,
"/tijdsloten": TijdslotenPage, "/tijdsloten": TijdslotenPage,
"/schooljaar": SchooljaarPage, "/schooljaar": SchooljaarPage,
"/afwezigheid": AfwezigheidPage,
}; };
export default function App() { export default function App() {

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
listResource, listResource,
createResource, createResource,
@ -687,7 +687,73 @@ function Kalender({ schooljaarId }) {
); );
} }
/* ------------------------------- Afwezigheid ------------------------------ */
export function AfwezigheidPage() {
const { items, error, loading, reload, setError } = useList("afwezigheden");
const [personen, setPersonen] = useState([]);
const leeg = { persoon: "", start_datum: "", eind_datum: "", reden: "" };
const [form, setForm] = useState(leeg);
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
async function opslaan(e) {
e.preventDefault();
try {
await createResource("afwezigheden", form);
setForm(leeg);
await reload();
} catch (err) { setError(err.message); }
}
async function verwijder(id) {
if (!confirm("Deze afwezigheid verwijderen?")) return;
try { await deleteResource("afwezigheden", id); await reload(); } catch (err) { setError(err.message); }
}
return (
<section>
<h1>Afwezigheid</h1>
<p className="muted">Leg vast wie wanneer afwezig is. In het rooster zie je dat terug bij de betrokken blokken.</p>
<Foutmelding msg={error} />
<form className="form-row" onSubmit={opslaan}>
<select value={form.persoon} required onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
<option value=""> persoon </option>
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
</select>
<label className="veld">Van <input type="date" value={form.start_datum} required onChange={(e) => setForm({ ...form, start_datum: e.target.value })} /></label>
<label className="veld">T/m <input type="date" value={form.eind_datum} required onChange={(e) => setForm({ ...form, eind_datum: e.target.value })} /></label>
<input placeholder="Reden (optioneel)" value={form.reden} onChange={(e) => setForm({ ...form, reden: e.target.value })} />
<button className="btn primary" type="submit">Toevoegen</button>
</form>
{loading ? <p>Laden</p> : (
<table className="grid">
<thead><tr><th>Persoon</th><th>Periode</th><th>Reden</th><th></th></tr></thead>
<tbody>
{items.map((a) => (
<tr key={a.id}>
<td>{a.persoon_naam}</td>
<td className="muted">{a.start_datum} t/m {a.eind_datum}</td>
<td className="muted">{a.reden || "—"}</td>
<td className="row-actions"><button className="link danger" onClick={() => verwijder(a.id)}>verwijderen</button></td>
</tr>
))}
{items.length === 0 && <tr><td colSpan="4" className="muted">Geen afwezigheden.</td></tr>}
</tbody>
</table>
)}
</section>
);
}
/* ------------------------------ Datumhulpjes ------------------------------ */
function pad2(n) { return String(n).padStart(2, "0"); }
function fmtISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
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 ------------------------------- */ /* ---------------------------------- Rooster ------------------------------- */
// Weekweergave met effectief rooster (vrije dagen, afwezigheid, uitzonderingen)
// en drag-and-drop (muis + touch) om een blok naar een ander tijdslot te slepen.
export function RoosterPage() { export function RoosterPage() {
const [schooljaren, setSchooljaren] = useState([]); const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState(""); const [schooljaar, setSchooljaar] = useState("");
@ -699,19 +765,24 @@ export function RoosterPage() {
const [leerlingen, setLeerlingen] = useState([]); const [leerlingen, setLeerlingen] = useState([]);
const [medewerkers, setMedewerkers] = useState([]); const [medewerkers, setMedewerkers] = useState([]);
const [blokken, setBlokken] = useState([]); const [blokken, setBlokken] = useState([]);
const [kalender, setKalender] = useState([]);
const [afwezig, setAfwezig] = useState([]);
const [uitzonderingen, setUitzonderingen] = useState([]);
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [toonForm, setToonForm] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const leeg = { const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" };
tijdslot: "", activiteit: "", locatie: "",
doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "",
};
const [form, setForm] = useState(leeg); const [form, setForm] = useState(leeg);
// Drag-and-drop via pointer events (werkt op muis én touch).
const dragRef = useRef(null);
const laadRef = useRef(() => {});
const [ghost, setGhost] = useState(null);
const [hoverSlot, setHoverSlot] = useState(null);
useEffect(() => { useEffect(() => {
listResource("schooljaren").then((j) => { listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
setSchooljaren(j);
if (j.length && !schooljaar) setSchooljaar(String(j[0].id));
}).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {}); listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("activiteiten").then(setActiviteiten).catch(() => {}); listResource("activiteiten").then(setActiviteiten).catch(() => {});
listResource("locaties").then(setLocaties).catch(() => {}); listResource("locaties").then(setLocaties).catch(() => {});
@ -721,96 +792,120 @@ export function RoosterPage() {
listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {});
}, []); }, []);
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const vanISO = fmtISO(maandag);
const totISO = fmtISO(plusDagen(maandag, 6));
async function laadBlokken() { async function laadBlokken() {
if (!schooljaar) return; if (!schooljaar) { setBlokken([]); return; }
try { try {
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`)); setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}`));
setKalender(await listResource("kalenderdagen", `?schooljaar=${schooljaar}`));
setError(null); setError(null);
} catch (e) { } catch (e) { setError(e.message); }
setError(e.message);
}
} }
async function laadWeek() {
if (!schooljaar) return;
try {
setAfwezig(await listResource("afwezigheden", `?van=${vanISO}&tot=${totISO}`));
setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`));
} catch (e) { setError(e.message); }
}
laadRef.current = laadBlokken;
useEffect(() => { laadBlokken(); }, [schooljaar]); useEffect(() => { laadBlokken(); }, [schooljaar]);
useEffect(() => { laadWeek(); }, [schooljaar, vanISO]);
const doelOpties = form.doel_type === "groep" ? groepen // Listeners voor drag-and-drop, één keer opgezet.
: form.doel_type === "subgroep" ? subgroepen useEffect(() => {
: leerlingen; function move(e) {
if (!dragRef.current) return;
setGhost({ x: e.clientX, y: e.clientY, label: dragRef.current.activiteit_naam });
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
setHoverSlot(cell ? Number(cell.dataset.tijdslot) : null);
}
async function up(e) {
if (!dragRef.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el && el.closest("[data-tijdslot]");
const doel = cell ? Number(cell.dataset.tijdslot) : null;
const blok = dragRef.current;
dragRef.current = null;
setGhost(null);
setHoverSlot(null);
if (doel && doel !== blok.tijdslot) {
try { await updateResource("roosterblokken", blok.id, { tijdslot: doel }); await laadRef.current(); }
catch (err) { setError(err.message); }
}
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
}, []);
function toggleBegeleider(id) { function startDrag(e, blok) {
setForm((f) => e.preventDefault();
f.begeleiders.includes(id) dragRef.current = blok;
? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } setGhost({ x: e.clientX, y: e.clientY, label: blok.activiteit_naam });
: { ...f, begeleiders: [...f.begeleiders, id] } setHoverSlot(blok.tijdslot);
);
} }
const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen;
function toggleBegeleider(id) {
setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] });
}
async function opslaan(e) { async function opslaan(e) {
e.preventDefault(); e.preventDefault();
const payload = { const payload = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, groep: form.doel_type === "groep" ? form.doel : null, subgroep: form.doel_type === "subgroep" ? form.doel : null, leerling: form.doel_type === "leerling" ? form.doel : null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen };
schooljaar, try { await createResource("roosterblokken", payload); setForm({ ...leeg, doel_type: form.doel_type }); setToonForm(false); await laadBlokken(); } catch (err) { setError(err.message); }
tijdslot: form.tijdslot,
activiteit: form.activiteit,
locatie: form.locatie || null,
groep: form.doel_type === "groep" ? form.doel : null,
subgroep: form.doel_type === "subgroep" ? form.doel : null,
leerling: form.doel_type === "leerling" ? form.doel : null,
begeleiders: form.begeleiders,
opmerkingen: form.opmerkingen,
};
try {
await createResource("roosterblokken", payload);
setForm({ ...leeg, doel_type: form.doel_type });
await laadBlokken();
} catch (err) {
setError(err.message);
}
} }
async function verwijder(id) { async function verwijder(id) {
if (!confirm("Dit roosterblok verwijderen?")) return; if (!confirm("Dit roosterblok (alle weken) verwijderen?")) return;
try { try { await deleteResource("roosterblokken", id); await laadBlokken(); } catch (err) { setError(err.message); }
await deleteResource("roosterblokken", id); }
await laadBlokken(); async function laatVervallen(blok, iso) {
} catch (err) { try { await createResource("blok-uitzonderingen", { roosterblok: blok.id, datum: iso, vervalt: true }); await laadWeek(); }
setError(err.message); catch (err) { setError(err.message); }
} }
async function herstel(uitId) {
try { await deleteResource("blok-uitzonderingen", uitId); await laadWeek(); } catch (err) { setError(err.message); }
} }
// Groepeer blokken per weekdag voor een overzichtelijk rooster. // Hulpfuncties voor het effectieve rooster.
const perDag = WEEKDAGEN.map((d) => ({ const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
dag: d, const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
blokken: blokken const persoonAfwezig = (naam, iso) => afwezig.some((a) => a.persoon_naam === naam && a.start_datum <= iso && a.eind_datum >= iso);
.filter((b) => {
const t = tijdsloten.find((x) => x.id === b.tijdslot);
return t && t.dag === d.value;
})
.sort((a, b) => {
const ta = tijdsloten.find((x) => x.id === a.tijdslot);
const tb = tijdsloten.find((x) => x.id === b.tijdslot);
return (ta?.volgorde ?? 0) - (tb?.volgorde ?? 0);
}),
}));
return ( return (
<section> <section>
<h1>Rooster</h1> <h1>Rooster</h1>
<p className="muted">Roosterblokken: wie doet wat, wanneer, waar en met wie. Koppel aan een groep, subgroep of individuele leerling.</p> <p className="muted">Sleep een blok naar een ander tijdslot om het te verplaatsen. Vrije dagen, afwezigheid en uitzonderingen worden meegenomen.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<div className="form-row"> <div className="rooster-balk">
<label className="veld">Schooljaar <label className="veld">Schooljaar
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}> <select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> kies </option> <option value=""> kies </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)} {schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select> </select>
</label> </label>
{schooljaar && (
<div className="week-nav">
<button className="btn" onClick={() => setMaandag(plusDagen(maandag, -7))}> vorige</button>
<button className="btn" onClick={() => setMaandag(maandagVan(new Date()))}>deze week</button>
<button className="btn" onClick={() => setMaandag(plusDagen(maandag, 7))}>volgende </button>
<span className="week-label">week van {fmtDM(maandag)}</span>
</div>
)}
{schooljaar && <button className="btn primary" onClick={() => setToonForm((v) => !v)}>{toonForm ? "Sluiten" : "+ Blok"}</button>}
</div> </div>
{schooljaar && ( {schooljaar && toonForm && (
<form className="form-card" onSubmit={opslaan}> <form className="form-card" onSubmit={opslaan}>
<div className="form-row"> <div className="form-row">
<select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}> <select value={form.tijdslot} required onChange={(e) => setForm({ ...form, tijdslot: e.target.value })}>
<option value=""> tijdslot </option> <option value=""> tijdslot </option>
{tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0,5)} {t.naam}</option>)} {tijdsloten.map((t) => <option key={t.id} value={t.id}>{t.dag_label} {t.start_tijd?.slice(0, 5)} {t.naam}</option>)}
</select> </select>
<select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}> <select value={form.activiteit} required onChange={(e) => setForm({ ...form, activiteit: e.target.value })}>
<option value=""> activiteit </option> <option value=""> activiteit </option>
@ -835,9 +930,7 @@ export function RoosterPage() {
<div className="leden-keuze"> <div className="leden-keuze">
<div className="muted small">Begeleiders:</div> <div className="muted small">Begeleiders:</div>
<div className="chips"> <div className="chips">
{medewerkers.map((m) => ( {medewerkers.map((m) => <button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}</button>)}
<button type="button" key={m.id} className={form.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBegeleider(m.id)}>{m.volledige_naam}</button>
))}
{medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>} {medewerkers.length === 0 && <span className="muted small">Nog geen medewerkers.</span>}
</div> </div>
</div> </div>
@ -850,29 +943,67 @@ export function RoosterPage() {
{schooljaar && ( {schooljaar && (
<div className="rooster-grid"> <div className="rooster-grid">
{perDag.map(({ dag, blokken }) => ( {WEEKDAGEN.map((d, i) => {
<div className="rooster-kolom" key={dag.value}> const datum = weekDatums[i];
<div className="rooster-dag">{dag.label}</div> const iso = fmtISO(datum);
{blokken.length === 0 && <div className="muted small"></div>} const kal = kalenderOp(iso);
{blokken.map((b) => ( const slots = tijdsloten.filter((t) => t.dag === d.value).sort((a, b) => a.volgorde - b.volgorde);
<div className="rooster-blok" key={b.id} style={{ borderLeftColor: b.activiteit_kleur }}> return (
<div className="rb-top"> <div className={kal ? "rooster-kolom vrij" : "rooster-kolom"} key={d.value}>
<strong>{b.activiteit_naam}</strong> <div className="rooster-dag">
<button className="link danger small" onClick={() => verwijder(b.id)}>×</button> {d.label} <span className="muted">{fmtDM(datum)}</span>
</div> {kal && <div className={`tag dag-${kal.type}`}>{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}</div>}
<div className="muted small">{b.tijdslot_label}</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.begeleiders_namen.length > 0 && (
<div className="small muted">👤 {b.begeleiders_namen.map((x) => x.naam).join(", ")}</div>
)}
{b.opmerkingen && <div className="small muted">📝 {b.opmerkingen}</div>}
</div> </div>
))} {slots.length === 0 && <div className="muted small"></div>}
</div> {slots.map((t) => {
))} const slotBlokken = blokken.filter((b) => b.tijdslot === t.id);
return (
<div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
data-tijdslot={t.id}
key={t.id}
>
<div className="slot-tijd">{t.start_tijd?.slice(0, 5)} {t.naam}</div>
{slotBlokken.map((b) => {
const uit = uitzOp(b.id, iso);
const vervallen = !!kal || (uit && uit.vervalt);
return (
<div
className={vervallen ? "rooster-blok vervallen" : "rooster-blok"}
key={b.id}
style={{ borderLeftColor: b.activiteit_kleur }}
>
<div className="rb-top">
<span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} title="Sleep naar ander tijdslot"></span>
<strong>{b.activiteit_naam}</strong>
<button className="link danger rb-x" onClick={() => 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>}
{b.begeleiders_namen.length > 0 && (
<div className="small muted">👤 {b.begeleiders_namen.map((x, idx) => (
<span key={x.id} className={persoonAfwezig(x.naam, iso) ? "afwezig" : ""}>{idx > 0 ? ", " : ""}{x.naam}</span>
))}</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})` : ""} · <button className="link" onClick={() => 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 && (
<button className="link danger small rb-verval" onClick={() => laatVervallen(b, iso)}>deze dag laten vervallen</button>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div> </div>
)} )}
{ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section> </section>
); );
} }

View file

@ -122,6 +122,40 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.link.small { font-size: 16px; line-height: 1; padding: 0 4px; } .link.small { font-size: 16px; line-height: 1; padding: 0 4px; }
@media (max-width: 900px) { .rooster-grid { grid-template-columns: 1fr; } } @media (max-width: 900px) { .rooster-grid { grid-template-columns: 1fr; } }
/* --- Fase 3: weekweergave, drag-and-drop, touch --- */
.rooster-balk { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0; }
.week-nav { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.week-label { font-weight: 600; }
.rooster-kolom.vrij { background: repeating-linear-gradient(45deg, #fafafa, #fafafa 8px, #f1f1f4 8px, #f1f1f4 16px); }
.rooster-dag .tag { display: inline-block; margin-top: 4px; }
.slot-cel { border: 1px dashed transparent; border-radius: 8px; padding: 4px; margin-bottom: 8px; transition: background .1s, border-color .1s; }
.slot-cel.hover { border-color: var(--brand); background: var(--brand-soft); }
.slot-tijd { font-size: 11px; color: var(--muted); padding: 2px 2px 4px; }
.rooster-blok { touch-action: none; }
.rooster-blok.vervallen { opacity: .55; }
.rooster-blok.vervallen strong { text-decoration: line-through; }
.rb-greep { cursor: grab; color: var(--muted); font-size: 16px; padding: 0 4px; touch-action: none; user-select: none; }
.rb-greep:active { cursor: grabbing; }
.rb-x { margin-left: auto; }
.rb-status { color: #b91c1c; font-weight: 600; }
.rb-verv { color: #166534; font-weight: 600; }
.rb-verval { display: inline-block; margin-top: 4px; }
.afwezig { text-decoration: line-through; color: #b91c1c; }
.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); }
/* Touch-vriendelijk: ruimere tikdoelen op kleinere/aanraakschermen. */
@media (pointer: coarse) {
.btn, .chip, input, select { min-height: 44px; }
.nav-item { padding: 14px 12px; font-size: 15px; }
.link { padding: 8px 6px; display: inline-block; }
.rb-greep { font-size: 22px; padding: 4px 10px; }
.grid th, .grid td { padding: 14px; }
}
/* --- Login --- */ /* --- Login --- */
.center-msg { text-align: center; margin-top: 20vh; color: var(--muted); } .center-msg { text-align: center; margin-top: 20vh; color: var(--muted); }
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; } .login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }