Compare commits
2 commits
cc32e0b7ef
...
f0c7b3644f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c7b3644f | ||
|
|
aa90a93648 |
15 changed files with 561 additions and 3 deletions
33
DEPLOY.md
33
DEPLOY.md
|
|
@ -1,3 +1,36 @@
|
||||||
|
# Deploy-workflow (kort)
|
||||||
|
|
||||||
|
> **Belangrijk — gebruik de scripts.** Eerdere mislukte pushes kwamen door een
|
||||||
|
> achtergebleven `.git/index.lock` die `git commit` blokkeerde (dan valt er
|
||||||
|
> niets te pushen en zegt git "up-to-date"). De scripts ruimen dat automatisch
|
||||||
|
> op en **controleren** of de push echt is doorgekomen.
|
||||||
|
|
||||||
|
**1. Op je dev-machine** (Windows / PowerShell), in de repo-map:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\push.ps1 "korte omschrijving van de wijziging"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dit verwijdert een eventuele lock, commit (alleen als er iets is), pusht naar
|
||||||
|
`origin/<branch>` en meldt **OK** of **LET OP** (met reden) — geen stille
|
||||||
|
mislukkingen meer. Krijg je "LET OP", dan staat de oorzaak in de git-uitvoer
|
||||||
|
erboven (meestal een SSH-/sleutelprobleem).
|
||||||
|
|
||||||
|
**2. Op de test-server**, in de repo-map:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Dit haalt de laatste code op (`git reset --hard origin/<branch>`), herbouwt de
|
||||||
|
containers (incl. migraties via de entrypoint) en controleert of er een nieuwe
|
||||||
|
frontend-bundel staat.
|
||||||
|
|
||||||
|
Nieuwe **modules** verschijnen daarna pas in de app nadat je ze aanzet via
|
||||||
|
**⚙ → Modulebeheer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Draaien op de test-server (Docker)
|
# Draaien op de test-server (Docker)
|
||||||
|
|
||||||
De stack bestaat uit drie containers:
|
De stack bestaat uit drie containers:
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@ volgt.
|
||||||
T (deze week), Esc (paneel/sluiten); met hint.
|
T (deze week), Esc (paneel/sluiten); met hint.
|
||||||
- [x] **Rol van een begeleider zichtbaar** in de Roostermaker: afkorting (LK/PO/KO)
|
- [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.
|
bij de begeleider in een blok en de volledige rol bij het toewijzen.
|
||||||
- [ ] **Vereenvoudigd vak/blok toevoegen** — bijvoorbeeld een **stap-voor-stap
|
- [x] **Vak-wizard** (module `blokwizard`, default uit): stap-voor-stap vak →
|
||||||
wizard** die meeschuift (activiteit → doelgroep → personeel → tijd), in
|
voor wie → wanneer/waar (+periode) → begeleiders → controle → inplannen.
|
||||||
plaats van één groot formulier.
|
- [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
|
## Fase 6 — Overzicht & navigatie
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ MODULE_APPS = [
|
||||||
"modules.leerplein", # Bovenliggende groepen
|
"modules.leerplein", # Bovenliggende groepen
|
||||||
"modules.stage", # Stage (intern/extern)
|
"modules.stage", # Stage (intern/extern)
|
||||||
"modules.uren", # Urenoverzicht
|
"modules.uren", # Urenoverzicht
|
||||||
|
"modules.blokwizard", # Vak-wizard
|
||||||
|
"modules.personenwizard", # Personen-wizard & import
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS
|
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.leerplein.urls")),
|
||||||
path("api/", include("modules.stage.urls")),
|
path("api/", include("modules.stage.urls")),
|
||||||
path("api/", include("modules.uren.urls")),
|
path("api/", include("modules.uren.urls")),
|
||||||
|
path("api/", include("modules.personenwizard.urls")),
|
||||||
path("api/", include("modules.accounts.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,
|
RoosterWeergavePage, RoostermakerPage,
|
||||||
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
||||||
LeerlingRoosterPage, OuderRoosterPage,
|
LeerlingRoosterPage, OuderRoosterPage,
|
||||||
|
VakWizardPage, PersonenWizardPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|
@ -38,6 +39,8 @@ const PAGES = {
|
||||||
"/uren": UrenPage,
|
"/uren": UrenPage,
|
||||||
"/leerlingrooster": LeerlingRoosterPage,
|
"/leerlingrooster": LeerlingRoosterPage,
|
||||||
"/ouderrooster": OuderRoosterPage,
|
"/ouderrooster": OuderRoosterPage,
|
||||||
|
"/vak-wizard": VakWizardPage,
|
||||||
|
"/personen-wizard": PersonenWizardPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
|
||||||
|
|
@ -2844,3 +2844,320 @@ export function EenvoudigRooster({ mode }) {
|
||||||
}
|
}
|
||||||
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
|
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
|
||||||
export function OuderRoosterPage() { return <EenvoudigRooster mode="ouder" />; }
|
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-kop { font-size: 13px; margin: 4px 0 6px; }
|
||||||
.ez-week .ez-dagdeel-icon { font-size: 15px; }
|
.ez-week .ez-dagdeel-icon { font-size: 15px; }
|
||||||
.ez-week .ez-dagdeel-blokken { gap: 8px; }
|
.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; }
|
||||||
|
|
|
||||||
19
scripts/deploy.sh
Normal file
19
scripts/deploy.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Op de test-server: haal de laatste code op en herbouw de containers.
|
||||||
|
# Gebruik: ./scripts/deploy.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
echo "Branch: $branch"
|
||||||
|
git fetch origin
|
||||||
|
# Forceer exact gelijk aan de remote (voorkomt 'already up to date'-verwarring).
|
||||||
|
git reset --hard "origin/$branch"
|
||||||
|
echo "Nu op commit:"
|
||||||
|
git --no-pager log --oneline -1
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "Controle: nieuwe frontend-bundel aanwezig?"
|
||||||
|
docker compose exec -T nginx sh -c 'ls /usr/share/nginx/html/assets/*.js >/dev/null 2>&1 && echo " frontend OK" || echo " GEEN frontend-bundel gevonden"'
|
||||||
|
echo "Klaar."
|
||||||
53
scripts/push.ps1
Normal file
53
scripts/push.ps1
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Veilig committen + pushen vanaf de dev-machine (Windows / PowerShell).
|
||||||
|
# Gebruik: .\scripts\push.ps1 "mijn commit-bericht"
|
||||||
|
# Lost het terugkerende push-probleem op: ruimt een achtergebleven
|
||||||
|
# index.lock op, commit alleen als er iets te committen valt, pusht, en
|
||||||
|
# CONTROLEERT daarna of de remote echt is bijgewerkt.
|
||||||
|
param([string]$Bericht = "")
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
# Ga naar de repo-root (map boven dit script).
|
||||||
|
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||||
|
|
||||||
|
# 1) Eventuele achtergebleven lock weghalen (oorzaak van mislukte commits).
|
||||||
|
if (Test-Path ".git\index.lock") {
|
||||||
|
Remove-Item ".git\index.lock" -Force
|
||||||
|
Write-Host "Verwijderd: achtergebleven .git\index.lock" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) Huidige branch bepalen.
|
||||||
|
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
||||||
|
Write-Host "Branch: $branch"
|
||||||
|
|
||||||
|
# 3) Alles toevoegen en committen (alleen als er wijzigingen zijn).
|
||||||
|
git add -A
|
||||||
|
$status = git status --porcelain
|
||||||
|
if ([string]::IsNullOrWhiteSpace($status)) {
|
||||||
|
Write-Host "Geen wijzigingen om te committen." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Bericht)) {
|
||||||
|
$Bericht = "Update " + (Get-Date -Format "yyyy-MM-dd HH:mm")
|
||||||
|
}
|
||||||
|
git commit -m $Bericht
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "Commit MISLUKT." -ForegroundColor Red; exit 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4) Pushen.
|
||||||
|
git push origin $branch
|
||||||
|
$pushExit = $LASTEXITCODE
|
||||||
|
|
||||||
|
# 5) Controleren of de remote echt gelijk is aan lokaal.
|
||||||
|
git fetch origin 2>$null | Out-Null
|
||||||
|
$lokaal = (git rev-parse HEAD).Trim()
|
||||||
|
$remote = (git rev-parse "origin/$branch").Trim()
|
||||||
|
Write-Host ""
|
||||||
|
if ($pushExit -eq 0 -and $lokaal -eq $remote) {
|
||||||
|
Write-Host "OK - origin/$branch is bijgewerkt naar $($lokaal.Substring(0,8))." -ForegroundColor Green
|
||||||
|
Write-Host "Draai nu op de test-server: ./scripts/deploy.sh"
|
||||||
|
} else {
|
||||||
|
Write-Host "LET OP - de push is NIET doorgekomen." -ForegroundColor Red
|
||||||
|
Write-Host " lokaal : $lokaal"
|
||||||
|
Write-Host " origin : $remote"
|
||||||
|
Write-Host "Bekijk de git-uitvoer hierboven (vaak een sleutel-/authenticatieprobleem)." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue