Modules: Vak-wizard en Personen-wizard met bulk/CSV-import
This commit is contained in:
parent
cc32e0b7ef
commit
aa90a93648
12 changed files with 456 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
||||
|
|
|
|||
0
backend/modules/blokwizard/__init__.py
Normal file
0
backend/modules/blokwizard/__init__.py
Normal file
29
backend/modules/blokwizard/apps.py
Normal file
29
backend/modules/blokwizard/apps.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
0
backend/modules/personenwizard/__init__.py
Normal file
0
backend/modules/personenwizard/__init__.py
Normal file
29
backend/modules/personenwizard/apps.py
Normal file
29
backend/modules/personenwizard/apps.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
7
backend/modules/personenwizard/urls.py
Normal file
7
backend/modules/personenwizard/urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("personen-import/", views.personen_import, name="personen-import"),
|
||||
]
|
||||
45
backend/modules/personenwizard/views.py
Normal file
45
backend/modules/personenwizard/views.py
Normal file
|
|
@ -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})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -2844,3 +2844,320 @@ export function EenvoudigRooster({ mode }) {
|
|||
}
|
||||
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
|
||||
export function OuderRoosterPage() { return <EenvoudigRooster mode="ouder" />; }
|
||||
|
||||
/* -------------------------------- 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 (
|
||||
<section className="wizard">
|
||||
<h1>Vak-wizard</h1>
|
||||
<div className="banner ok">✓ Het vak is ingepland.</div>
|
||||
<div className="form-row"><button className="btn primary" onClick={opnieuw}>Nog een vak inplannen</button></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const STAPPEN = ["Vak", "Voor wie", "Wanneer & waar", "Begeleiders", "Controle"];
|
||||
return (
|
||||
<section className="wizard">
|
||||
<h1>Vak-wizard</h1>
|
||||
<Foutmelding msg={error} />
|
||||
<ol className="wizard-stappen">
|
||||
{STAPPEN.map((label, i) => <li key={i} className={stap === i + 1 ? "on" : stap > i + 1 ? "done" : ""}>{i + 1}. {label}</li>)}
|
||||
</ol>
|
||||
|
||||
{stap === 1 && (
|
||||
<div className="wizard-stap">
|
||||
<h2>Welk vak?</h2>
|
||||
{schooljaren.length > 1 && (
|
||||
<select value={f.schooljaar} onChange={(e) => setF({ ...f, schooljaar: e.target.value })}>
|
||||
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div className="keuze-grid">
|
||||
{activiteiten.map((a) => (
|
||||
<button type="button" key={a.id} className={Number(f.activiteit) === a.id ? "keuze on" : "keuze"} style={{ borderColor: a.kleur }} onClick={() => setF({ ...f, activiteit: a.id })}>
|
||||
{a.pictogram ? <img src={a.pictogram} alt="" className="keuze-picto" /> : <span className="keuze-emoji">{a.icoon || vakIcoon(a.naam)}</span>}
|
||||
<span style={{ color: a.kleur }}>{a.naam}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stap === 2 && (
|
||||
<div className="wizard-stap">
|
||||
<h2>Voor wie?</h2>
|
||||
<div className="form-row">
|
||||
<select value={f.doel_type} onChange={(e) => setF({ ...f, doel_type: e.target.value, doel: "" })}>
|
||||
<option value="groep">Groep</option>
|
||||
<option value="subgroep">Subgroep</option>
|
||||
<option value="leerling">Individuele leerling</option>
|
||||
</select>
|
||||
<select value={f.doel} onChange={(e) => setF({ ...f, doel: e.target.value })}>
|
||||
<option value="">— kies {f.doel_type} —</option>
|
||||
{doelOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stap === 3 && (
|
||||
<div className="wizard-stap">
|
||||
<h2>Wanneer & waar?</h2>
|
||||
<div className="form-row">
|
||||
<select value={f.tijdslot} onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
|
||||
<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>)}
|
||||
</select>
|
||||
<select value={f.lokaal} onChange={(e) => setF({ ...f, lokaal: e.target.value })}>
|
||||
<option value="">— lokaal (optioneel) —</option>
|
||||
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
|
||||
</select>
|
||||
<select value={f.locatie} onChange={(e) => setF({ ...f, locatie: e.target.value })}>
|
||||
<option value="">— locatie (optioneel) —</option>
|
||||
{locaties.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} /></label>
|
||||
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stap === 4 && (
|
||||
<div className="wizard-stap">
|
||||
<h2>Wie begeleidt? <span className="muted small">(optioneel)</span></h2>
|
||||
<div className="chips compact">
|
||||
{medewerkers.map((m) => <button type="button" key={m.id} className={f.begeleiders.includes(m.id) ? "chip on" : "chip"} onClick={() => toggleBeg(m.id)}>{m.volledige_naam}{m.rol_label ? ` · ${m.rol_label}` : ""}</button>)}
|
||||
</div>
|
||||
<input placeholder="Opmerking (optioneel)" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
{stap === 5 && (
|
||||
<div className="wizard-stap">
|
||||
<h2>Klopt het zo?</h2>
|
||||
<ul className="wizard-overzicht">
|
||||
<li><b>Vak:</b> {naamVan(activiteiten, f.activiteit)}</li>
|
||||
<li><b>Voor:</b> {naamVan(doelOpties, f.doel)} ({f.doel_type})</li>
|
||||
<li><b>Wanneer:</b> {tslabel(f.tijdslot)}</li>
|
||||
<li><b>Waar:</b> {f.lokaal ? naamVan(lokalen, f.lokaal) : (f.locatie ? naamVan(locaties, f.locatie) : "—")}</li>
|
||||
<li><b>Begeleiding:</b> {f.begeleiders.length ? f.begeleiders.map((id) => naamVan(medewerkers, id)).join(", ") : "—"}</li>
|
||||
{(f.geldig_van || f.geldig_tot) && <li><b>Periode:</b> {f.geldig_van || "…"} t/m {f.geldig_tot || "…"}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="wizard-nav">
|
||||
{stap > 1 && <button type="button" className="btn" onClick={() => setStap(stap - 1)}>◀ Vorige</button>}
|
||||
{stap < 5 && <button type="button" className="btn primary" disabled={!magVerder} onClick={() => setStap(stap + 1)}>Volgende ▶</button>}
|
||||
{stap === 5 && <button type="button" className="btn primary" onClick={opslaan}>✓ Vak inplannen</button>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- 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 (
|
||||
<section className="wizard">
|
||||
<h1>Personen toevoegen</h1>
|
||||
<p className="muted">Voeg leerlingen of medewerkers toe — één voor één of in bulk (plakken of CSV-bestand).</p>
|
||||
<Foutmelding msg={error} />
|
||||
{melding && <div className="banner ok">✓ {melding}</div>}
|
||||
<div className="form-row">
|
||||
<select value={rol} onChange={(e) => setRol(e.target.value)}>
|
||||
{PWROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
<div className="segmented">
|
||||
<button type="button" className={modus === "enkel" ? "seg on" : "seg"} onClick={() => setModus("enkel")}>Eén toevoegen</button>
|
||||
<button type="button" className={modus === "bulk" ? "seg on" : "seg"} onClick={() => setModus("bulk")}>Bulk / import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modus === "enkel" && (
|
||||
<form className="form-card" onSubmit={bewaarEnkel}>
|
||||
<div className="form-row">
|
||||
<input placeholder="Voornaam" value={form.voornaam} required onChange={(e) => setForm({ ...form, voornaam: e.target.value })} />
|
||||
<input placeholder="Achternaam" value={form.achternaam} required onChange={(e) => setForm({ ...form, achternaam: e.target.value })} />
|
||||
{isLeerling && (
|
||||
<select value={form.groep} onChange={(e) => setForm({ ...form, groep: e.target.value })}>
|
||||
<option value="">— groep (optioneel) —</option>
|
||||
{groepen.map((g) => <option key={g.id} value={g.id}>{g.naam}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button className="btn primary" type="submit">Toevoegen</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{modus === "bulk" && (
|
||||
<div className="form-card">
|
||||
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam{isLeerling ? ", groep" : ""}</b> (komma, puntkomma of tab). Een koprij is optioneel.</p>
|
||||
<div className="form-row">
|
||||
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const fl = e.target.files[0]; if (fl) leesBestand(fl); e.target.value = ""; }} /></label>
|
||||
{isLeerling && <label className="veld"><input type="checkbox" checked={groepAanmaken} onChange={(e) => setGroepAanmaken(e.target.checked)} /> onbekende groep automatisch aanmaken</label>}
|
||||
</div>
|
||||
<textarea className="bulk-invoer" rows={6} placeholder={isLeerling ? "Jan;Jansen;Groep 1\nPiet;Pietersen;Groep 2" : "Lea;Kracht\nMo;Hassan"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
|
||||
{rijen.length > 0 && (
|
||||
<>
|
||||
<div className="muted small">Voorbeeld ({rijen.length} {isLeerling ? "leerlingen" : "medewerkers"}):</div>
|
||||
<table className="grid">
|
||||
<thead><tr><th>Voornaam</th><th>Achternaam</th>{isLeerling && <th>Groep</th>}</tr></thead>
|
||||
<tbody>
|
||||
{rijen.slice(0, 8).map((r, i) => <tr key={i}><td>{r.voornaam}</td><td>{r.achternaam}</td>{isLeerling && <td className="muted">{r.groep || "—"}</td>}</tr>)}
|
||||
{rijen.length > 8 && <tr><td colSpan={isLeerling ? 3 : 2} className="muted small">… en {rijen.length - 8} meer</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<button className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} ${isLeerling ? "leerlingen" : "medewerkers"}`}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -469,3 +469,21 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
|||
.ez-week .ez-dagdeel-kop { font-size: 13px; margin: 4px 0 6px; }
|
||||
.ez-week .ez-dagdeel-icon { font-size: 15px; }
|
||||
.ez-week .ez-dagdeel-blokken { gap: 8px; }
|
||||
|
||||
/* Wizards */
|
||||
.wizard-stappen { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 0; margin: 0 0 16px; }
|
||||
.wizard-stappen li { font-size: 13px; color: var(--muted); background: var(--bg); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; }
|
||||
.wizard-stappen li.on { background: var(--brand); color: #fff; border-color: var(--brand); font-weight: 700; }
|
||||
.wizard-stappen li.done { color: var(--brand); border-color: var(--brand); }
|
||||
.wizard-stap { margin-bottom: 16px; }
|
||||
.wizard-stap h2 { margin-top: 0; }
|
||||
.wizard-nav { display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid var(--line); padding-top: 12px; }
|
||||
.wizard-overzicht { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.wizard-overzicht li { padding: 8px 10px; background: var(--bg); border-radius: 8px; }
|
||||
.keuze-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin-top: 10px; }
|
||||
.keuze { display: flex; flex-direction: column; align-items: center; gap: 6px; border: 3px solid var(--line); border-radius: 12px; padding: 12px 8px; background: var(--panel); cursor: pointer; font: inherit; }
|
||||
.keuze.on { box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
.keuze-picto { width: 48px; height: 48px; object-fit: contain; }
|
||||
.keuze-emoji { font-size: 40px; line-height: 1; }
|
||||
.bulk-invoer { width: 100%; font-family: monospace; font-size: 13px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.banner.ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; padding: 10px 14px; border-radius: 8px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue