From aa90a936484fbf57608632b542d92aa2c64ecc50 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:20:56 +0200 Subject: [PATCH] Modules: Vak-wizard en Personen-wizard met bulk/CSV-import --- ROADMAP.md | 8 +- backend/config/settings.py | 2 + backend/config/urls.py | 1 + backend/modules/blokwizard/__init__.py | 0 backend/modules/blokwizard/apps.py | 29 ++ backend/modules/personenwizard/__init__.py | 0 backend/modules/personenwizard/apps.py | 29 ++ backend/modules/personenwizard/urls.py | 7 + backend/modules/personenwizard/views.py | 45 +++ frontend/src/App.jsx | 3 + frontend/src/pages.jsx | 317 +++++++++++++++++++++ frontend/src/styles.css | 18 ++ 12 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 backend/modules/blokwizard/__init__.py create mode 100644 backend/modules/blokwizard/apps.py create mode 100644 backend/modules/personenwizard/__init__.py create mode 100644 backend/modules/personenwizard/apps.py create mode 100644 backend/modules/personenwizard/urls.py create mode 100644 backend/modules/personenwizard/views.py diff --git a/ROADMAP.md b/ROADMAP.md index 658d12f..9da0883 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,9 +19,11 @@ volgt. T (deze week), Esc (paneel/sluiten); met hint. - [x] **Rol van een begeleider zichtbaar** in de Roostermaker: afkorting (LK/PO/KO) bij de begeleider in een blok en de volledige rol bij het toewijzen. -- [ ] **Vereenvoudigd vak/blok toevoegen** — bijvoorbeeld een **stap-voor-stap - wizard** die meeschuift (activiteit → doelgroep → personeel → tijd), in - plaats van één groot formulier. +- [x] **Vak-wizard** (module `blokwizard`, default uit): stap-voor-stap vak → + voor wie → wanneer/waar (+periode) → begeleiders → controle → inplannen. +- [x] **Personen-wizard + import** (module `personenwizard`, default uit): + leerling/medewerker enkel toevoegen óf in bulk via plakken/CSV (komma, + puntkomma of tab; koprij optioneel) met preview; endpoint /api/personen-import/. ## Fase 6 — Overzicht & navigatie diff --git a/backend/config/settings.py b/backend/config/settings.py index 624702e..b1ae71c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -75,6 +75,8 @@ MODULE_APPS = [ "modules.leerplein", # Bovenliggende groepen "modules.stage", # Stage (intern/extern) "modules.uren", # Urenoverzicht + "modules.blokwizard", # Vak-wizard + "modules.personenwizard", # Personen-wizard & import ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS diff --git a/backend/config/urls.py b/backend/config/urls.py index 2f0c244..540ddff 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("api/", include("modules.leerplein.urls")), path("api/", include("modules.stage.urls")), path("api/", include("modules.uren.urls")), + path("api/", include("modules.personenwizard.urls")), path("api/", include("modules.accounts.urls")), ] diff --git a/backend/modules/blokwizard/__init__.py b/backend/modules/blokwizard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/blokwizard/apps.py b/backend/modules/blokwizard/apps.py new file mode 100644 index 0000000..92a85ad --- /dev/null +++ b/backend/modules/blokwizard/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class BlokwizardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.blokwizard" + label = "module_blokwizard" + verbose_name = "Module: Vak-wizard" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="blokwizard", + name="Vak-wizard", + description=( + "Een stap-voor-stap hulpje om snel een vak (roosterblok) in te " + "plannen: kies vak, voor wie, wanneer/waar en de begeleiders." + ), + version="0.1.0", + category="Hulpmiddelen", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Vak-wizard", "/vak-wizard", icon="puzzle", order=40), + ), + ) + ) diff --git a/backend/modules/personenwizard/__init__.py b/backend/modules/personenwizard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/personenwizard/apps.py b/backend/modules/personenwizard/apps.py new file mode 100644 index 0000000..eba63b6 --- /dev/null +++ b/backend/modules/personenwizard/apps.py @@ -0,0 +1,29 @@ +from django.apps import AppConfig + + +class PersonenwizardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.personenwizard" + label = "module_personenwizard" + verbose_name = "Module: Personen-wizard & import" + + def ready(self): + from plugins.registry import MenuItem, ModuleSpec, registry + + registry.register( + ModuleSpec( + key="personenwizard", + name="Personen-wizard & import", + description=( + "Voeg leerlingen en medewerkers stap voor stap toe, of in bulk " + "via plakken of een CSV-bestand (komma, puntkomma of tab)." + ), + version="0.1.0", + category="Hulpmiddelen", + core=False, + default_enabled=False, + menu_items=( + MenuItem("Personen toevoegen", "/personen-wizard", icon="users", order=42), + ), + ) + ) diff --git a/backend/modules/personenwizard/urls.py b/backend/modules/personenwizard/urls.py new file mode 100644 index 0000000..0cf4a56 --- /dev/null +++ b/backend/modules/personenwizard/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("personen-import/", views.personen_import, name="personen-import"), +] diff --git a/backend/modules/personenwizard/views.py b/backend/modules/personenwizard/views.py new file mode 100644 index 0000000..91f7a45 --- /dev/null +++ b/backend/modules/personenwizard/views.py @@ -0,0 +1,45 @@ +"""Bulk-import van personen (leerlingen/medewerkers). Geen eigen model.""" +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from core.models import Groep, Persoon, Rol + +GELDIGE_ROLLEN = {r.value for r in Rol} + + +@api_view(["POST"]) +def personen_import(request): + rol = request.data.get("rol") + personen = request.data.get("personen") or [] + groep_aanmaken = bool(request.data.get("groep_aanmaken")) + if rol not in GELDIGE_ROLLEN: + return Response({"detail": "Onbekende rol."}, status=400) + if not isinstance(personen, list): + return Response({"detail": "personen moet een lijst zijn."}, status=400) + + groep_cache = {g.naam.lower(): g for g in Groep.objects.all()} + aangemaakt = 0 + meldingen = [] + for i, p in enumerate(personen): + vn = str(p.get("voornaam") or "").strip() + an = str(p.get("achternaam") or "").strip() + if not vn and not an: + continue + if not an: + meldingen.append({"regel": i + 1, "melding": "achternaam ontbreekt — overgeslagen"}) + continue + obj = Persoon(voornaam=vn, achternaam=an, rol=rol) + if rol == Rol.LEERLING: + gnaam = str(p.get("groep") or "").strip() + if gnaam: + g = groep_cache.get(gnaam.lower()) + if not g and groep_aanmaken: + g = Groep.objects.create(naam=gnaam) + groep_cache[gnaam.lower()] = g + if g: + obj.groep = g + else: + meldingen.append({"regel": i + 1, "melding": f"groep '{gnaam}' niet gevonden — leerling zonder groep aangemaakt"}) + obj.save() + aangemaakt += 1 + return Response({"aangemaakt": aangemaakt, "meldingen": meldingen}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index de741d8..9fcba9c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import { RoosterWeergavePage, RoostermakerPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, + VakWizardPage, PersonenWizardPage, } from "./pages.jsx"; const ICONS = { @@ -38,6 +39,8 @@ const PAGES = { "/uren": UrenPage, "/leerlingrooster": LeerlingRoosterPage, "/ouderrooster": OuderRoosterPage, + "/vak-wizard": VakWizardPage, + "/personen-wizard": PersonenWizardPage, }; export default function App() { diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index f03a8f3..c924c90 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -2844,3 +2844,320 @@ export function EenvoudigRooster({ mode }) { } export function LeerlingRoosterPage() { return ; } export function OuderRoosterPage() { return ; } + +/* -------------------------------- Vak-wizard ------------------------------ */ +export function VakWizardPage() { + const [stap, setStap] = useState(1); + const [schooljaren, setSchooljaren] = useState([]); + const [activiteiten, setActiviteiten] = useState([]); + const [groepen, setGroepen] = useState([]); + const [subgroepen, setSubgroepen] = useState([]); + const [leerlingen, setLeerlingen] = useState([]); + const [medewerkers, setMedewerkers] = useState([]); + const [tijdsloten, setTijdsloten] = useState([]); + const [locaties, setLocaties] = useState([]); + const [lokalen, setLokalen] = useState([]); + const [error, setError] = useState(null); + const [klaar, setKlaar] = useState(false); + const leeg = { schooljaar: "", activiteit: "", doel_type: "groep", doel: "", tijdslot: "", locatie: "", lokaal: "", begeleiders: [], geldig_van: "", geldig_tot: "", opmerkingen: "" }; + const [f, setF] = useState(leeg); + + useEffect(() => { + listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setF((x) => ({ ...x, schooljaar: String(j[0].id) })); }).catch((e) => setError(e.message)); + listResource("activiteiten").then(setActiviteiten).catch(() => {}); + listResource("groepen").then(setGroepen).catch(() => {}); + listResource("subgroepen").then(setSubgroepen).catch(() => {}); + listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); + listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); + listResource("tijdsloten").then(setTijdsloten).catch(() => {}); + listResource("locaties").then(setLocaties).catch(() => {}); + listResource("lokalen").then(setLokalen).catch(() => setLokalen([])); + }, []); + + const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepen : leerlingen; + const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] }); + const naamVan = (lijst, id) => { const o = lijst.find((x) => x.id === Number(id)); return o ? (o.naam || o.volledige_naam) : "—"; }; + const tslabel = (id) => { const t = tijdsloten.find((x) => x.id === Number(id)); return t ? `${t.dag_label} ${(t.start_tijd || "").slice(0, 5)} ${t.naam}` : "—"; }; + + const magVerder = + (stap === 1 && f.activiteit) || + (stap === 2 && f.doel) || + (stap === 3 && f.tijdslot) || + (stap === 4) || + stap === 5; + + async function opslaan() { + try { + await createResource("roosterblokken", { + schooljaar: f.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, + locatie: f.locatie || null, lokaal: f.lokaal || null, + groep: f.doel_type === "groep" ? f.doel : null, + subgroep: f.doel_type === "subgroep" ? f.doel : null, + leerling: f.doel_type === "leerling" ? f.doel : null, + begeleiders: f.begeleiders, opmerkingen: f.opmerkingen, + geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null, + }); + setKlaar(true); + } catch (e) { setError(e.message); } + } + function opnieuw() { setF({ ...leeg, schooljaar: f.schooljaar }); setStap(1); setKlaar(false); } + + if (klaar) { + return ( +
+

Vak-wizard

+
✓ Het vak is ingepland.
+
+
+ ); + } + const STAPPEN = ["Vak", "Voor wie", "Wanneer & waar", "Begeleiders", "Controle"]; + return ( +
+

Vak-wizard

+ +
    + {STAPPEN.map((label, i) =>
  1. i + 1 ? "done" : ""}>{i + 1}. {label}
  2. )} +
+ + {stap === 1 && ( +
+

Welk vak?

+ {schooljaren.length > 1 && ( + + )} +
+ {activiteiten.map((a) => ( + + ))} +
+
+ )} + {stap === 2 && ( +
+

Voor wie?

+
+ + +
+
+ )} + {stap === 3 && ( +
+

Wanneer & waar?

+
+ + + +
+
+ + +
+
+ )} + {stap === 4 && ( +
+

Wie begeleidt? (optioneel)

+
+ {medewerkers.map((m) => )} +
+ setF({ ...f, opmerkingen: e.target.value })} /> +
+ )} + {stap === 5 && ( +
+

Klopt het zo?

+
    +
  • Vak: {naamVan(activiteiten, f.activiteit)}
  • +
  • Voor: {naamVan(doelOpties, f.doel)} ({f.doel_type})
  • +
  • Wanneer: {tslabel(f.tijdslot)}
  • +
  • Waar: {f.lokaal ? naamVan(lokalen, f.lokaal) : (f.locatie ? naamVan(locaties, f.locatie) : "—")}
  • +
  • Begeleiding: {f.begeleiders.length ? f.begeleiders.map((id) => naamVan(medewerkers, id)).join(", ") : "—"}
  • + {(f.geldig_van || f.geldig_tot) &&
  • Periode: {f.geldig_van || "…"} t/m {f.geldig_tot || "…"}
  • } +
+
+ )} + +
+ {stap > 1 && } + {stap < 5 && } + {stap === 5 && } +
+
+ ); +} + +/* ----------------------------- Personen-wizard ---------------------------- */ +const PWROLLEN = [ + { value: "leerling", label: "Leerling" }, + { value: "leerkracht", label: "Leerkracht" }, + { value: "praktijkondersteuner", label: "Praktijkondersteuner" }, + { value: "klassenondersteuner", label: "Klassenondersteuner" }, +]; +function detecteerScheiding(regel) { + const tel = (c) => (regel.match(new RegExp("\\" + c, "g")) || []).length; + const t = (regel.match(/\t/g) || []).length; + const kand = [[";", tel(";")], [",", tel(",")], ["\t", t]]; + kand.sort((a, b) => b[1] - a[1]); + return kand[0][1] > 0 ? kand[0][0] : ","; +} +function parsePersonen(tekst, rol) { + const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean); + if (regels.length === 0) return []; + const sep = detecteerScheiding(regels[0]); + const splits = (r) => r.split(sep).map((c) => c.trim()); + let kolommen = null; + const eerste = splits(regels[0]).map((c) => c.toLowerCase()); + const heeftHeader = eerste.some((c) => ["voornaam", "achternaam", "naam", "groep", "rol"].includes(c)); + const rows = []; + const start = heeftHeader ? 1 : 0; + if (heeftHeader) kolommen = eerste; + for (let i = start; i < regels.length; i++) { + const cellen = splits(regels[i]); + let voornaam = "", achternaam = "", groep = ""; + if (kolommen) { + const idx = (n) => kolommen.indexOf(n); + if (idx("voornaam") >= 0) voornaam = cellen[idx("voornaam")] || ""; + if (idx("achternaam") >= 0) achternaam = cellen[idx("achternaam")] || ""; + if (idx("groep") >= 0) groep = cellen[idx("groep")] || ""; + if (!voornaam && !achternaam && idx("naam") >= 0) { + const delen = (cellen[idx("naam")] || "").split(" "); + voornaam = delen.shift() || ""; achternaam = delen.join(" "); + } + } else if (cellen.length === 1) { + const delen = cellen[0].split(" "); + voornaam = delen.shift() || ""; achternaam = delen.join(" "); + } else { + voornaam = cellen[0] || ""; achternaam = cellen[1] || ""; groep = cellen[2] || ""; + } + if (voornaam || achternaam) rows.push(rol === "leerling" ? { voornaam, achternaam, groep } : { voornaam, achternaam }); + } + return rows; +} + +export function PersonenWizardPage() { + const [rol, setRol] = useState("leerling"); + const [modus, setModus] = useState("enkel"); + const [groepen, setGroepen] = useState([]); + const [error, setError] = useState(null); + const [melding, setMelding] = useState(null); + // enkel + const leeg = { voornaam: "", achternaam: "", groep: "" }; + const [form, setForm] = useState(leeg); + // bulk + const [tekst, setTekst] = useState(""); + const [groepAanmaken, setGroepAanmaken] = useState(false); + const [bezig, setBezig] = useState(false); + + useEffect(() => { listResource("groepen").then(setGroepen).catch(() => {}); }, []); + const isLeerling = rol === "leerling"; + const rijen = modus === "bulk" ? parsePersonen(tekst, rol) : []; + + async function bewaarEnkel(e) { + e.preventDefault(); + setError(null); setMelding(null); + try { + await createResource("personen", { voornaam: form.voornaam, achternaam: form.achternaam, rol, groep: isLeerling && form.groep ? Number(form.groep) : null }); + setMelding(`${form.voornaam} ${form.achternaam} toegevoegd.`); + setForm(leeg); + } catch (err) { setError(err.message); } + } + function leesBestand(file) { + const reader = new FileReader(); + reader.onload = () => setTekst(String(reader.result || "")); + reader.readAsText(file); + } + async function importeer() { + setError(null); setMelding(null); setBezig(true); + try { + const res = await createResource("personen-import", { rol, personen: rijen, groep_aanmaken: groepAanmaken }); + let m = `${res.aangemaakt} ${isLeerling ? "leerling(en)" : "medewerker(s)"} geïmporteerd.`; + if (res.meldingen && res.meldingen.length) m += ` ${res.meldingen.length} aandachtspunt(en).`; + setMelding(m); + setTekst(""); + } catch (err) { setError(err.message); } + finally { setBezig(false); } + } + + return ( +
+

Personen toevoegen

+

Voeg leerlingen of medewerkers toe — één voor één of in bulk (plakken of CSV-bestand).

+ + {melding &&
✓ {melding}
} +
+ +
+ + +
+
+ + {modus === "enkel" && ( +
+
+ setForm({ ...form, voornaam: e.target.value })} /> + setForm({ ...form, achternaam: e.target.value })} /> + {isLeerling && ( + + )} + +
+
+ )} + + {modus === "bulk" && ( +
+

Plak rijen of upload een CSV. Kolommen: voornaam, achternaam{isLeerling ? ", groep" : ""} (komma, puntkomma of tab). Een koprij is optioneel.

+
+ + {isLeerling && } +
+