Fase 3 - Afwezigheid, uitzonderingen, vrije dagen; touchscreen
This commit is contained in:
parent
4fcc169c7c
commit
0117c754fa
11 changed files with 477 additions and 107 deletions
22
README.md
22
README.md
|
|
@ -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 0–3** 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).
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
47
backend/core/migrations/0003_afwezigheid_blokuitzondering.py
Normal file
47
backend/core/migrations/0003_afwezigheid_blokuitzondering.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue