perioderooster: hernoemd, boven roostermaker, voltooien met periode + preview (Periode-model, sjabloon)
This commit is contained in:
parent
66590553fe
commit
6024a46b35
13 changed files with 277 additions and 43 deletions
18
ROADMAP.md
18
ROADMAP.md
|
|
@ -25,14 +25,18 @@ volgt.
|
|||
leerling/medewerker enkel toevoegen óf in bulk via plakken/CSV (komma,
|
||||
puntkomma of tab; koprij optioneel) met preview; endpoint /api/personen-import/.
|
||||
|
||||
## Fase 6 — Weekroostermaker
|
||||
## Fase 6 — Perioderooster
|
||||
|
||||
- [x] **Weekroostermaker** (`/weekroostermaker`, menu onder Rooster): matrix met
|
||||
alle klassen als kolommen en de algemene tijdvakken als rijen. Vakken per
|
||||
klas plaatsen in het **vaste basisweekrooster** (geen periode-koppeling;
|
||||
`geldig_van/tot` leeg). Live conflictsignalering over alle klassen heen
|
||||
(begeleider/lokaal dubbel) + "Weekrooster voltooien" als eindcontrole.
|
||||
Periode-afwijkingen blijven in de Roostermaker.
|
||||
- [x] **Perioderooster** (`/perioderooster`, menu onder Rooster, **boven**
|
||||
Roostermaker): matrix met alle klassen als kolommen en de algemene
|
||||
tijdvakken als rijen. Vakken per klas plaatsen in een **sjabloon**-opzet
|
||||
(los van de kalender). Live conflictsignalering over alle klassen heen.
|
||||
- [x] **Voltooien → periode inladen**: bij voltooien kies of maak je een
|
||||
**Periode** (naam = schooljaar + nummer) met datumbereik; een **preview**
|
||||
toont welke vakken geplaatst en welke overlappende bestaande blokken
|
||||
verwijderd worden, daarna bevestigen. De sjabloon-opzet blijft bewaard
|
||||
voor een volgende periode. Sjabloonblokken tellen niet mee in het echte
|
||||
rooster/conflicten/uren/auto-rooster (`Roosterblok.sjabloon`).
|
||||
|
||||
## Fase 6 — Overzicht & navigatie
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ class CoreConfig(AppConfig):
|
|||
menu_items=(
|
||||
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
|
||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10, group="Rooster"),
|
||||
MenuItem("Perioderooster", "/perioderooster", icon="calendar", order=11, group="Rooster"),
|
||||
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12, group="Rooster"),
|
||||
MenuItem("Weekroostermaker", "/weekroostermaker", icon="calendar", order=13, group="Rooster"),
|
||||
MenuItem("Leerlingrooster", "/leerlingrooster", icon="book", order=14, group="Rooster"),
|
||||
MenuItem("Ouderrooster", "/ouderrooster", icon="badge", order=16, group="Rooster"),
|
||||
# Submenu Organisatie: wie hoort waar.
|
||||
|
|
|
|||
36
backend/core/migrations/0010_periode_roosterblok_sjabloon.py
Normal file
36
backend/core/migrations/0010_periode_roosterblok_sjabloon.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Generated for Perioderooster: Periode-model + Roosterblok.sjabloon.
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_activiteit_pictogram'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='roosterblok',
|
||||
name='sjabloon',
|
||||
field=models.BooleanField(default=False, verbose_name='sjabloon'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Periode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('nummer', models.PositiveIntegerField(default=1, verbose_name='periodenummer')),
|
||||
('start_datum', models.DateField(verbose_name='startdatum')),
|
||||
('eind_datum', models.DateField(verbose_name='einddatum')),
|
||||
('aangemaakt_op', models.DateTimeField(auto_now_add=True)),
|
||||
('schooljaar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='periodes', to='core.schooljaar')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'periode',
|
||||
'verbose_name_plural': 'periodes',
|
||||
'ordering': ['schooljaar', 'nummer'],
|
||||
'unique_together': {('schooljaar', 'nummer')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -414,6 +414,10 @@ class Roosterblok(models.Model):
|
|||
# datumbereik. geldig_van == geldig_tot betekent een eenmalig blok op die dag.
|
||||
geldig_van = models.DateField("geldig vanaf", null=True, blank=True)
|
||||
geldig_tot = models.DateField("geldig tot en met", null=True, blank=True)
|
||||
# Sjabloonblok: hoort bij de Perioderooster-opzet en telt NIET mee in het
|
||||
# echte rooster/conflicten. Bij 'voltooien' worden er echte (sjabloon=False)
|
||||
# blokken van gekopieerd voor de gekozen periode.
|
||||
sjabloon = models.BooleanField("sjabloon", default=False)
|
||||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -445,6 +449,39 @@ class Roosterblok(models.Model):
|
|||
return f"{self.activiteit} @ {self.tijdslot}"
|
||||
|
||||
|
||||
class Periode(models.Model):
|
||||
"""Een periode binnen een schooljaar (Periode 1, 2, ...) met datumbereik.
|
||||
|
||||
Het Perioderooster zet bij 'voltooien' het opgebouwde sjabloon om in echte
|
||||
roosterblokken met geldig_van/geldig_tot gelijk aan deze periode.
|
||||
"""
|
||||
|
||||
schooljaar = models.ForeignKey(
|
||||
Schooljaar, on_delete=models.CASCADE, related_name="periodes"
|
||||
)
|
||||
nummer = models.PositiveIntegerField("periodenummer", default=1)
|
||||
start_datum = models.DateField("startdatum")
|
||||
eind_datum = models.DateField("einddatum")
|
||||
aangemaakt_op = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "periode"
|
||||
verbose_name_plural = "periodes"
|
||||
ordering = ["schooljaar", "nummer"]
|
||||
unique_together = ("schooljaar", "nummer")
|
||||
|
||||
@property
|
||||
def naam(self) -> str:
|
||||
return f"{self.schooljaar.naam} · Periode {self.nummer}"
|
||||
|
||||
def clean(self):
|
||||
if self.eind_datum < self.start_datum:
|
||||
raise ValidationError("'einddatum' ligt vóór 'startdatum'.")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.naam
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fase 3: flexibiliteit en uitzonderingen
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from rest_framework import serializers
|
|||
|
||||
from .models import (
|
||||
Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
|
||||
Locatie, Lokaal, Lokaalinstelling, Persoon, Roosterblok, Schooljaar,
|
||||
Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar,
|
||||
Subgroep, Tijdslot,
|
||||
)
|
||||
|
||||
|
|
@ -150,6 +150,22 @@ class SchooljaarSerializer(serializers.ModelSerializer):
|
|||
return data
|
||||
|
||||
|
||||
class PeriodeSerializer(serializers.ModelSerializer):
|
||||
naam = serializers.CharField(read_only=True)
|
||||
schooljaar_naam = serializers.CharField(source="schooljaar.naam", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Periode
|
||||
fields = ["id", "schooljaar", "schooljaar_naam", "nummer", "naam", "start_datum", "eind_datum"]
|
||||
|
||||
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 moet op of na de startdatum liggen.")
|
||||
return data
|
||||
|
||||
|
||||
class KalenderDagSerializer(serializers.ModelSerializer):
|
||||
type_label = serializers.CharField(source="get_type_display", read_only=True)
|
||||
|
||||
|
|
@ -223,7 +239,7 @@ class RoosterblokSerializer(serializers.ModelSerializer):
|
|||
"activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", "activiteit_pictogram",
|
||||
"locatie", "locatie_naam", "lokaal", "lokaal_naam",
|
||||
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
|
||||
"geldig_van", "geldig_tot",
|
||||
"geldig_van", "geldig_tot", "sjabloon",
|
||||
"begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op",
|
||||
]
|
||||
read_only_fields = ["aangemaakt_op"]
|
||||
|
|
@ -280,9 +296,10 @@ class RoosterblokSerializer(serializers.ModelSerializer):
|
|||
lokaal = data.get("lokaal", getattr(self.instance, "lokaal", None))
|
||||
tijdslot = data.get("tijdslot", getattr(self.instance, "tijdslot", None))
|
||||
schooljaar = data.get("schooljaar", getattr(self.instance, "schooljaar", None))
|
||||
if lokaal is not None and tijdslot is not None and Lokaalinstelling.actueel().dubbel_blokkeren:
|
||||
nieuw_sjabloon = data.get("sjabloon", getattr(self.instance, "sjabloon", False))
|
||||
if not nieuw_sjabloon and lokaal is not None and tijdslot is not None and Lokaalinstelling.actueel().dubbel_blokkeren:
|
||||
botsing = Roosterblok.objects.filter(
|
||||
lokaal=lokaal, tijdslot=tijdslot, schooljaar=schooljaar,
|
||||
lokaal=lokaal, tijdslot=tijdslot, schooljaar=schooljaar, sjabloon=False,
|
||||
)
|
||||
if self.instance is not None:
|
||||
botsing = botsing.exclude(pk=self.instance.pk)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ router.register("groepen", views.GroepViewSet, basename="groep")
|
|||
router.register("subgroepen", views.SubgroepViewSet, basename="subgroep")
|
||||
router.register("functies", views.FunctieViewSet, basename="functie")
|
||||
router.register("schooljaren", views.SchooljaarViewSet, basename="schooljaar")
|
||||
router.register("periodes", views.PeriodeViewSet, basename="periode")
|
||||
router.register("kalenderdagen", views.KalenderDagViewSet, basename="kalenderdag")
|
||||
router.register("tijdsloten", views.TijdslotViewSet, basename="tijdslot")
|
||||
router.register("activiteiten", views.ActiviteitViewSet, basename="activiteit")
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ from plugins import services
|
|||
|
||||
from .models import (
|
||||
Activiteit, Afwezigheid, BlokUitzondering, Functie, Groep, KalenderDag,
|
||||
Locatie, Lokaal, Lokaalinstelling, Persoon, Roosterblok, Schooljaar,
|
||||
Locatie, Lokaal, Lokaalinstelling, Periode, Persoon, Roosterblok, Schooljaar,
|
||||
Subgroep, Tijdslot,
|
||||
)
|
||||
from .serializers import (
|
||||
ActiviteitSerializer, AfwezigheidSerializer, BlokUitzonderingSerializer,
|
||||
FunctieSerializer, GroepSerializer, KalenderDagSerializer, LocatieSerializer,
|
||||
LokaalSerializer, LokaalinstellingSerializer,
|
||||
LokaalSerializer, LokaalinstellingSerializer, PeriodeSerializer,
|
||||
PersoonSerializer, RoosterblokSerializer, SchooljaarSerializer,
|
||||
SubgroepSerializer, TijdslotSerializer,
|
||||
)
|
||||
|
|
@ -132,6 +132,24 @@ class RoosterblokViewSet(viewsets.ModelViewSet):
|
|||
waarde = self.request.query_params.get(veld)
|
||||
if waarde:
|
||||
qs = qs.filter(**{f"{veld}_id": waarde})
|
||||
# Sjabloonblokken (Perioderooster-opzet) standaard uitsluiten uit het
|
||||
# echte rooster; alleen tonen bij expliciet ?sjabloon=1.
|
||||
sjabloon = self.request.query_params.get("sjabloon")
|
||||
if sjabloon in ("1", "true", "True"):
|
||||
qs = qs.filter(sjabloon=True)
|
||||
else:
|
||||
qs = qs.filter(sjabloon=False)
|
||||
return qs
|
||||
|
||||
|
||||
class PeriodeViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = PeriodeSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Periode.objects.select_related("schooljaar").all()
|
||||
schooljaar = self.request.query_params.get("schooljaar")
|
||||
if schooljaar:
|
||||
qs = qs.filter(schooljaar_id=schooljaar)
|
||||
return qs
|
||||
|
||||
|
||||
|
|
@ -197,6 +215,7 @@ def conflicten(request):
|
|||
schooljaar = request.query_params.get("schooljaar")
|
||||
qs = (
|
||||
Roosterblok.objects
|
||||
.filter(sjabloon=False)
|
||||
.select_related("tijdslot", "locatie", "lokaal", "groep", "subgroep", "leerling")
|
||||
.prefetch_related("begeleiders", "groep__leerlingen", "subgroep__leden")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ def voorstel(request):
|
|||
beg_bezet = {} # tijdslot -> set(begeleider-id) (globaal)
|
||||
lok_bezet = {} # tijdslot -> set(lokaal-id) (globaal)
|
||||
if respecteer:
|
||||
for b in Roosterblok.objects.filter(schooljaar=sj).prefetch_related("begeleiders"):
|
||||
for b in Roosterblok.objects.filter(schooljaar=sj, sjabloon=False).prefetch_related("begeleiders"):
|
||||
for p in b.begeleiders.all():
|
||||
beg_bezet.setdefault(b.tijdslot_id, set()).add(p.id)
|
||||
if b.lokaal_id:
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ def _lokaal_bezet_door_ander(lokaal, omvang, dag, dagdeel, tijdslot, exclude_sta
|
|||
return None
|
||||
les = (
|
||||
Roosterblok.objects
|
||||
.filter(lokaal=lokaal, tijdslot_id__in=slot_ids)
|
||||
.filter(lokaal=lokaal, tijdslot_id__in=slot_ids, sjabloon=False)
|
||||
.select_related("activiteit").first()
|
||||
)
|
||||
if les:
|
||||
|
|
@ -127,7 +127,7 @@ def _theorievak_botsing(begeleider, omvang, dag, dagdeel, tijdslot):
|
|||
|
||||
qs = (
|
||||
Roosterblok.objects
|
||||
.filter(begeleiders=begeleider, activiteit__theorievak=True)
|
||||
.filter(begeleiders=begeleider, activiteit__theorievak=True, sjabloon=False)
|
||||
.select_related("tijdslot", "activiteit")
|
||||
)
|
||||
for blok in qs:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def bereken_uren(sj):
|
|||
d += timedelta(days=1)
|
||||
|
||||
blokken = list(
|
||||
Roosterblok.objects.filter(schooljaar=sj)
|
||||
Roosterblok.objects.filter(schooljaar=sj, sjabloon=False)
|
||||
.select_related("tijdslot", "activiteit", "groep", "subgroep", "leerling", "leerling__groep")
|
||||
.prefetch_related("uitzonderingen")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Logo from "./Logo.jsx";
|
|||
import {
|
||||
PersonenPage, FunctiesPage,
|
||||
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
|
||||
RoosterWeergavePage, RoostermakerPage, WeekRoostermakerPage,
|
||||
RoosterWeergavePage, RoostermakerPage, PeriodeRoosterPage,
|
||||
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
||||
LeerlingRoosterPage, OuderRoosterPage,
|
||||
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
|
||||
|
|
@ -27,8 +27,8 @@ const normaliseerPad = (path) => (GROEPSINDELING_PADEN.has(path) ? "/groepsindel
|
|||
// placeholder (worden in een latere fase ingevuld).
|
||||
const PAGES = {
|
||||
"/rooster": RoosterWeergavePage,
|
||||
"/perioderooster": PeriodeRoosterPage,
|
||||
"/roostermaker": RoostermakerPage,
|
||||
"/weekroostermaker": WeekRoostermakerPage,
|
||||
"/groepsindeling": GroepsindelingPage,
|
||||
"/personen": PersonenPage,
|
||||
"/functies": FunctiesPage,
|
||||
|
|
|
|||
|
|
@ -2764,11 +2764,11 @@ export function RoosterScherm({ mode }) {
|
|||
export function RoosterWeergavePage() { return <RoosterScherm mode="view" />; }
|
||||
export function RoostermakerPage() { return <RoosterScherm mode="edit" />; }
|
||||
|
||||
/* --------------- Weekroostermaker: alle klassen in één matrix ------------- */
|
||||
// Bouwt het VASTE basisweekrooster (geen periode/kalender-koppeling): per klas
|
||||
// vakken in elk tijdvak, met directe conflictsignalering over alle klassen heen.
|
||||
// Afwijken per periode gebeurt daarna in de Roostermaker.
|
||||
export function WeekRoostermakerPage() {
|
||||
/* --------------- Perioderooster: alle klassen in één matrix --------------- */
|
||||
// Bouwt een weekopzet voor alle klassen (nog zonder periode/kalender-koppeling):
|
||||
// per klas vakken in elk tijdvak, met directe conflictsignalering over alle
|
||||
// klassen heen. Bij voltooien wordt een periode gekozen en ingeladen.
|
||||
export function PeriodeRoosterPage() {
|
||||
const [schooljaren, setSchooljaren] = useState([]);
|
||||
const [schooljaar, setSchooljaar] = useState("");
|
||||
const [tijdsloten, setTijdsloten] = useState([]);
|
||||
|
|
@ -2784,7 +2784,14 @@ export function WeekRoostermakerPage() {
|
|||
const [cel, setCel] = useState(null); // {tijdslot, groep}
|
||||
const [form, setForm] = useState({ activiteit: "", begeleiders: [], lokaal: "" });
|
||||
const [bezig, setBezig] = useState(false);
|
||||
const [voltooid, setVoltooid] = useState(false);
|
||||
// Voltooien → periode kiezen/aanmaken + preview.
|
||||
const [periodes, setPeriodes] = useState([]);
|
||||
const [voltooiOpen, setVoltooiOpen] = useState(false);
|
||||
const [periodeKeuze, setPeriodeKeuze] = useState("_new"); // id of "_new"
|
||||
const [nieuwP, setNieuwP] = useState({ start_datum: "", eind_datum: "" });
|
||||
const [preview, setPreview] = useState(null); // {toevoegen:[], verwijderen:[]}
|
||||
const [applyBezig, setApplyBezig] = useState(false);
|
||||
const [klaarMelding, setKlaarMelding] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
|
||||
|
|
@ -2798,15 +2805,18 @@ export function WeekRoostermakerPage() {
|
|||
listResource("leerpleinen").then(setLeerpleinen).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { laadBlokken(); }, [schooljaar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { laadBlokken(); laadPeriodes(); }, [schooljaar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
async function laadBlokken() {
|
||||
if (!schooljaar) { setBlokken([]); return; }
|
||||
try {
|
||||
const j = await listResource("roosterblokken", `?schooljaar=${schooljaar}`);
|
||||
setBlokken(j.filter((b) => !b.geldig_van && !b.geldig_tot));
|
||||
setBlokken(await listResource("roosterblokken", `?schooljaar=${schooljaar}&sjabloon=1`));
|
||||
setError(null);
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
async function laadPeriodes() {
|
||||
if (!schooljaar) { setPeriodes([]); return; }
|
||||
try { setPeriodes(await listResource("periodes", `?schooljaar=${schooljaar}`)); } catch { setPeriodes([]); }
|
||||
}
|
||||
|
||||
const ctx = { leerlingen, subgroepen, leerpleinen };
|
||||
const conflictInfo = berekenRoosterConflicten(blokken, ctx, tijdsloten);
|
||||
|
|
@ -2822,41 +2832,96 @@ export function WeekRoostermakerPage() {
|
|||
if (!cel || !form.activiteit) return;
|
||||
setBezig(true);
|
||||
try {
|
||||
await createResource("roosterblokken", { schooljaar, tijdslot: cel.tijdslot, activiteit: form.activiteit, groep: cel.groep, subgroep: null, leerling: null, begeleiders: form.begeleiders, lokaal: form.lokaal || null, locatie: null, opmerkingen: "", geldig_van: null, geldig_tot: null });
|
||||
await createResource("roosterblokken", { schooljaar, tijdslot: cel.tijdslot, activiteit: form.activiteit, groep: cel.groep, subgroep: null, leerling: null, begeleiders: form.begeleiders, lokaal: form.lokaal || null, locatie: null, opmerkingen: "", geldig_van: null, geldig_tot: null, sjabloon: true });
|
||||
setCel(null);
|
||||
setVoltooid(false);
|
||||
setKlaarMelding(null);
|
||||
await laadBlokken();
|
||||
} catch (err) { setError(err.message); }
|
||||
setBezig(false);
|
||||
}
|
||||
async function verwijder(id) {
|
||||
try { await deleteResource("roosterblokken", id); setVoltooid(false); await laadBlokken(); } catch (e) { setError(e.message); }
|
||||
try { await deleteResource("roosterblokken", id); setKlaarMelding(null); await laadBlokken(); } catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
// ---- Voltooien: periode kiezen/aanmaken, preview en toepassen ----
|
||||
const doelKey = (b) => `${b.tijdslot}|${b.groep || ""}|${b.subgroep || ""}|${b.leerling || ""}`;
|
||||
const overlaptPeriode = (b, van, tot) => {
|
||||
const bv = b.geldig_van || null, bt = b.geldig_tot || null;
|
||||
if (bt && bt < van) return false;
|
||||
if (bv && bv > tot) return false;
|
||||
return true;
|
||||
};
|
||||
function gekozenPeriodeDatums() {
|
||||
if (periodeKeuze === "_new") return { van: nieuwP.start_datum, tot: nieuwP.eind_datum };
|
||||
const p = periodes.find((x) => String(x.id) === String(periodeKeuze));
|
||||
return p ? { van: p.start_datum, tot: p.eind_datum } : { van: "", tot: "" };
|
||||
}
|
||||
function openVoltooien() {
|
||||
const volgend = (periodes.reduce((m, p) => Math.max(m, p.nummer), 0) || 0) + 1;
|
||||
setPeriodeKeuze(periodes.length ? String(periodes[0].id) : "_new");
|
||||
setNieuwP({ start_datum: "", eind_datum: "", nummer: volgend });
|
||||
setPreview(null);
|
||||
setKlaarMelding(null);
|
||||
setVoltooiOpen(true);
|
||||
}
|
||||
async function maakPreview() {
|
||||
const { van, tot } = gekozenPeriodeDatums();
|
||||
if (!van || !tot) { setError("Kies of vul een periode met begin- en einddatum."); return; }
|
||||
try {
|
||||
const live = await listResource("roosterblokken", `?schooljaar=${schooljaar}`); // sjabloon=False
|
||||
const keys = new Set(blokken.map(doelKey));
|
||||
const verwijderen = live.filter((b) => keys.has(doelKey(b)) && overlaptPeriode(b, van, tot));
|
||||
setPreview({ toevoegen: blokken, verwijderen, van, tot });
|
||||
setError(null);
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
async function bevestigVoltooien() {
|
||||
if (!preview) return;
|
||||
setApplyBezig(true);
|
||||
try {
|
||||
let periodeId = periodeKeuze;
|
||||
if (periodeKeuze === "_new") {
|
||||
const p = await createResource("periodes", { schooljaar, nummer: nieuwP.nummer, start_datum: preview.van, eind_datum: preview.tot });
|
||||
periodeId = p.id;
|
||||
}
|
||||
for (const b of preview.verwijderen) {
|
||||
await deleteResource("roosterblokken", b.id);
|
||||
}
|
||||
for (const b of preview.toevoegen) {
|
||||
await createResource("roosterblokken", {
|
||||
schooljaar, tijdslot: b.tijdslot, activiteit: b.activiteit,
|
||||
groep: b.groep, subgroep: b.subgroep, leerling: b.leerling,
|
||||
begeleiders: (b.begeleiders_namen || []).map((x) => x.id),
|
||||
lokaal: b.lokaal || null, locatie: b.locatie || null, opmerkingen: b.opmerkingen || "",
|
||||
geldig_van: preview.van, geldig_tot: preview.tot, sjabloon: false,
|
||||
});
|
||||
}
|
||||
await laadPeriodes();
|
||||
setVoltooiOpen(false);
|
||||
setPreview(null);
|
||||
setKlaarMelding(`Perioderooster ingeladen (${preview.toevoegen.length} vak(ken) geplaatst, ${preview.verwijderen.length} overlappend verwijderd). De weekopzet blijft als sjabloon bewaard.`);
|
||||
} catch (e) { setError(e.message); }
|
||||
setApplyBezig(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="weekmaker">
|
||||
<h1>Weekroostermaker</h1>
|
||||
<p className="muted no-print">Geef alle klassen in één overzicht hun vaste weekvakken. Dit is het basisweekrooster — niet aan een periode gekoppeld; per periode afwijken (laten vervallen, vervanger) doe je daarna in de Roostermaker. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.</p>
|
||||
<h1>Perioderooster</h1>
|
||||
<p className="muted no-print">Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij <b>voltooien</b> kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.</p>
|
||||
<Foutmelding msg={error} />
|
||||
|
||||
<div className="rooster-balk no-print">
|
||||
<label className="veld">Schooljaar
|
||||
<select value={schooljaar} onChange={(e) => { setSchooljaar(e.target.value); setVoltooid(false); }}>
|
||||
<select value={schooljaar} onChange={(e) => { setSchooljaar(e.target.value); setKlaarMelding(null); }}>
|
||||
<option value="">— kies —</option>
|
||||
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className={conflictInfo.meldingen.length ? "wm-status fout" : "wm-status ok"}>{conflictInfo.meldingen.length ? `⚠ ${conflictInfo.meldingen.length} conflict(en)` : "✓ geen conflicten"}</span>
|
||||
<button className="btn primary" disabled={!schooljaar || blokken.length === 0} onClick={() => setVoltooid(true)}>Weekrooster voltooien</button>
|
||||
<button className="btn primary" disabled={!schooljaar || blokken.length === 0} onClick={openVoltooien}>Voltooien & periode inladen</button>
|
||||
</div>
|
||||
|
||||
{voltooid && (
|
||||
<div className={conflictInfo.meldingen.length ? "banner waarschuwing" : "banner ok"}>
|
||||
{conflictInfo.meldingen.length
|
||||
? <>Er zijn nog {conflictInfo.meldingen.length} conflict(en). Los de rood omlijnde vakken op voor je het weekrooster als definitief beschouwt.</>
|
||||
: <>Het weekrooster is compleet en conflictvrij; het geldt nu als vast weekrooster voor elke week. Wil je in een bepaalde periode afwijken, ga dan naar de Roostermaker.</>}
|
||||
</div>
|
||||
)}
|
||||
{klaarMelding && <div className="banner ok">{klaarMelding}</div>}
|
||||
|
||||
{conflictInfo.meldingen.length > 0 && (
|
||||
<ul className="conflict-lijst wm-conflicten no-print">
|
||||
|
|
@ -2913,6 +2978,59 @@ export function WeekRoostermakerPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{voltooiOpen && (
|
||||
<div className="modal-overlay no-print" onClick={() => setVoltooiOpen(false)}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="Perioderooster voltooien" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-kop">
|
||||
<div><h2>Voltooien & periode inladen</h2><div className="muted small">De weekopzet wordt als rooster geplaatst in de gekozen periode. De opzet blijft als sjabloon bewaard.</div></div>
|
||||
<button type="button" className="icon-btn" title="Sluiten" onClick={() => setVoltooiOpen(false)}>×</button>
|
||||
</div>
|
||||
{conflictInfo.meldingen.length > 0 && <div className="banner waarschuwing">Let op: er zijn nog {conflictInfo.meldingen.length} conflict(en) in de opzet. Je kunt toch inladen, maar los ze bij voorkeur eerst op.</div>}
|
||||
<div className="editor-grid">
|
||||
<label className="veld-kol"><span className="veld-kop">Periode</span>
|
||||
<select value={periodeKeuze} onChange={(e) => { setPeriodeKeuze(e.target.value); setPreview(null); }}>
|
||||
{periodes.map((p) => <option key={p.id} value={p.id}>{p.naam} ({p.start_datum} – {p.eind_datum})</option>)}
|
||||
<option value="_new">+ nieuwe periode aanmaken</option>
|
||||
</select>
|
||||
</label>
|
||||
{periodeKeuze === "_new" && (
|
||||
<>
|
||||
<label className="veld-kol"><span className="veld-kop">Periodenummer</span><input type="number" min="1" value={nieuwP.nummer || ""} onChange={(e) => { setNieuwP({ ...nieuwP, nummer: Number(e.target.value) }); setPreview(null); }} /></label>
|
||||
<label className="veld-kol"><span className="veld-kop">Van</span><input type="date" value={nieuwP.start_datum} onChange={(e) => { setNieuwP({ ...nieuwP, start_datum: e.target.value }); setPreview(null); }} /></label>
|
||||
<label className="veld-kol"><span className="veld-kop">T/m</span><input type="date" value={nieuwP.eind_datum} onChange={(e) => { setNieuwP({ ...nieuwP, eind_datum: e.target.value }); setPreview(null); }} /></label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!preview && (
|
||||
<div className="editor-actions">
|
||||
<button type="button" className="btn primary" onClick={maakPreview}>Controleren</button>
|
||||
<button type="button" className="btn" onClick={() => setVoltooiOpen(false)}>Annuleren</button>
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<div className="wm-preview">
|
||||
<p><b>{preview.toevoegen.length}</b> vak(ken) worden geplaatst voor periode {preview.van} – {preview.tot}.</p>
|
||||
{preview.verwijderen.length > 0 ? (
|
||||
<>
|
||||
<p className="fout"><b>{preview.verwijderen.length}</b> bestaande blok(ken) overlappen en worden verwijderd:</p>
|
||||
<ul className="conflict-lijst">
|
||||
{preview.verwijderen.slice(0, 30).map((b) => <li key={b.id} className="waarschuwing">{b.doel_naam} · {b.activiteit_naam} ({b.tijdslot_label})</li>)}
|
||||
{preview.verwijderen.length > 30 && <li className="muted">…en nog {preview.verwijderen.length - 30}</li>}
|
||||
</ul>
|
||||
</>
|
||||
) : <p className="muted small">Geen overlappende bestaande blokken.</p>}
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<button type="button" className="btn primary" disabled={applyBezig} onClick={bevestigVoltooien}>{applyBezig ? "Bezig…" : `Toepassen (${preview.verwijderen.length} verwijderen, ${preview.toevoegen.length} plaatsen)`}</button>
|
||||
<button type="button" className="btn" onClick={() => setPreview(null)} disabled={applyBezig}>Terug</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cel && (
|
||||
<div className="modal-overlay no-print" onClick={() => setCel(null)}>
|
||||
<form className="modal" role="dialog" aria-modal="true" aria-label="Vak toevoegen" onSubmit={voegToe} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -552,6 +552,8 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
|||
.wm-add { width: 100%; border: 1px dashed var(--line); background: transparent; color: var(--muted); border-radius: 7px; padding: 4px; cursor: pointer; font: inherit; font-size: 13px; opacity: .6; }
|
||||
.wm-cel:hover .wm-add { opacity: 1; }
|
||||
.wm-add:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-soft); }
|
||||
.wm-preview { margin: 6px 0 10px; }
|
||||
.wm-preview .conflict-lijst { background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; padding: 8px 12px; margin-top: 6px; }
|
||||
|
||||
.voorstel-klas { margin-bottom: 18px; }
|
||||
.voorstel-klas h3 { margin: 8px 0 6px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue