Modules: Vak-wizard en Personen-wizard met bulk/CSV-import

This commit is contained in:
bes-r 2026-06-17 14:20:56 +02:00
parent cc32e0b7ef
commit aa90a93648
12 changed files with 456 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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")),
]

View file

View 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),
),
)
)

View 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),
),
)
)

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("personen-import/", views.personen_import, name="personen-import"),
]

View 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})

View file

@ -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() {

View file

@ -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>
);
}

View file

@ -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; }