diff --git a/backend/core/apps.py b/backend/core/apps.py index b6647a1..7808263 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -32,7 +32,7 @@ class CoreConfig(AppConfig): MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12), # Submenu Organisatie: wie hoort waar. MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"), - MenuItem("Structuur", "/structuur", icon="layers", order=24, group="Organisatie"), + MenuItem("Groepsindeling", "/structuur", icon="layers", order=24, group="Organisatie"), MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Organisatie"), ), ) diff --git a/backend/modules/leerplein/apps.py b/backend/modules/leerplein/apps.py index 7c9fa9c..197da55 100644 --- a/backend/modules/leerplein/apps.py +++ b/backend/modules/leerplein/apps.py @@ -5,7 +5,7 @@ class LeerpleinConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "modules.leerplein" label = "module_leerplein" - verbose_name = "Module: Leerplein (groepsstructuur)" + verbose_name = "Module: Bovenliggende groepen" def ready(self): from plugins.registry import ModuleSpec, registry @@ -13,13 +13,13 @@ class LeerpleinConfig(AppConfig): registry.register( ModuleSpec( key="leerplein", - name="Leerplein (groepsstructuur)", + name="Bovenliggende groepen", description=( "Koppel subgroepen aan groepen en bundel groepen, subgroepen " - "en losse leerlingen onder een overkoepelend leerplein." + "en losse leerlingen onder een bovenliggende groep." ), version="0.1.0", - category="Structuur", + category="Groepsindeling", core=False, default_enabled=False, menu_items=(), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 674e841..fb08aff 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,7 +15,7 @@ const ICONS = { }; const STRUCTUUR_PADEN = new Set(["/structuur", "/leerplein", "/groepen", "/subgroepen"]); -const STRUCTUUR_ITEM = { label: "Structuur", path: "/structuur", icon: "layers", order: 24, group: "Organisatie" }; +const STRUCTUUR_ITEM = { label: "Groepsindeling", path: "/structuur", icon: "layers", order: 24, group: "Organisatie" }; const normaliseerPad = (path) => (STRUCTUUR_PADEN.has(path) ? "/structuur" : path); // Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index fd65113..a36d1a9 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -16,6 +16,9 @@ const ROLLEN = [ // Korte aanduiding van een medewerkersrol (compact in de roostervakjes). const ROL_AFK = { leerkracht: "LK", praktijkondersteuner: "PO", klassenondersteuner: "KO" }; +const BOVENGROEP_LABEL = "Bovenliggende groep"; +const BOVENGROEP_LABEL_LC = "bovenliggende groep"; +const BOVENGROEP_LABEL_MV = "Bovenliggende groepen"; const WEEKDAGEN = [ { value: 0, label: "Maandag" }, @@ -381,7 +384,7 @@ export function PersonenPage() { {leerpleinen.length > 0 && (
-
Leerpleinen:
+
{BOVENGROEP_LABEL_MV}:
{leerpleinen.map((lp) => )}
@@ -698,8 +701,8 @@ export function AfwezigheidPage() { ); } -/* -------------------------------- Leerplein ------------------------------- */ -// Gezamenlijk structuurbeheer: leerplein -> groep -> subgroep. +/* ----------------------------- Groepsindeling ----------------------------- */ +// Gezamenlijk beheer: bovenliggende groep -> groep -> subgroep. export function LeerpleinPage() { const [leerpleinen, setLeerpleinen] = useState([]); const [groepen, setGroepen] = useState([]); @@ -745,7 +748,6 @@ export function LeerpleinPage() { const toggleArray = (lijst, id) => lijst.includes(id) ? lijst.filter((x) => x !== id) : [...lijst, id]; const groepNaam = (id) => groepen.find((g) => g.id === id)?.naam || "—"; - const leerpleinNaam = (id) => leerpleinen.find((lp) => lp.id === id)?.naam || "—"; const leerpleinVanGroep = (groepId) => leerpleinen.find((lp) => (lp.groepen || []).includes(Number(groepId))); const leerpleinVanSubgroep = (subgroepId) => leerpleinen.find((lp) => (lp.subgroepen || []).includes(Number(subgroepId))); const leerlingenVanGroep = (groepId) => leerlingen.filter((l) => l.groep === Number(groepId)); @@ -820,7 +822,7 @@ export function LeerpleinPage() { } async function opslaanGroep(e) { e.preventDefault(); - if (!groepForm.leerplein) { setError("Kies een bovenliggend leerplein voor deze groep."); return; } + if (!groepForm.leerplein) { setError(`Kies een ${BOVENGROEP_LABEL_LC} voor deze groep.`); return; } try { const payload = { naam: groepForm.naam, omschrijving: groepForm.omschrijving }; const opgeslagen = editGroepId ? await updateResource("groepen", editGroepId, payload) : await createResource("groepen", payload); @@ -834,7 +836,7 @@ export function LeerpleinPage() { async function opslaanSubgroep(e) { e.preventDefault(); if (!subgroepForm.groep) { setError("Kies een groep waar deze subgroep onder valt."); return; } - if (subgroepForm.ledenBron === "leerplein" && !subgroepForm.leerplein) { setError("Kies een leerplein voor leden uit het hele leerplein."); return; } + if (subgroepForm.ledenBron === "leerplein" && !subgroepForm.leerplein) { setError(`Kies een ${BOVENGROEP_LABEL_LC} voor leden uit de volledige ${BOVENGROEP_LABEL_LC}.`); return; } try { const payload = { naam: subgroepForm.naam, @@ -852,8 +854,8 @@ export function LeerpleinPage() { } async function verwijderLeerplein(id) { const lp = leerpleinen.find((x) => x.id === id); - if ((lp?.groepen || []).length > 0) { setError("Verplaats of verwijder eerst de groepen onder dit leerplein."); return; } - if (!confirm("Dit leerplein verwijderen?")) return; + if ((lp?.groepen || []).length > 0) { setError(`Verplaats of verwijder eerst de groepen onder deze ${BOVENGROEP_LABEL_LC}.`); return; } + if (!confirm(`Deze ${BOVENGROEP_LABEL_LC} verwijderen?`)) return; try { await deleteResource("leerpleinen", id); await laadAlles(); } catch (err) { setError(err.message); } } async function verwijderGroep(id) { @@ -885,18 +887,18 @@ export function LeerpleinPage() { return (
-

Groepenstructuur

-

Beheer leerpleinen, groepen en subgroepen op één plek. Een groep hoort altijd onder een leerplein; een subgroep hoort altijd onder een groep.

+

Groepsindeling

+

Beheer bovenliggende groepen, groepen en subgroepen op één plek. Een groep hoort altijd onder een bovenliggende groep; een subgroep hoort altijd onder een groep.

-

Leerplein

+

{BOVENGROEP_LABEL}

- setLeerpleinForm({ ...leerpleinForm, naam: e.target.value })} /> + setLeerpleinForm({ ...leerpleinForm, naam: e.target.value })} /> setLeerpleinForm({ ...leerpleinForm, omschrijving: e.target.value })} />
-
Losse leerlingen op leerpleinniveau:
+
Losse leerlingen op niveau van de {BOVENGROEP_LABEL_LC}:
setLeerpleinForm((f) => ({ ...f, leerlingen: toggleArray(f.leerlingen, id) }))} />
@@ -904,7 +906,7 @@ export function LeerpleinPage() { setLeerpleinForm((f) => ({ ...f, medewerkers: toggleArray(f.medewerkers, id) }))} leeg="Geen medewerkers gevonden." />
- + {editLeerpleinId && }
@@ -913,7 +915,7 @@ export function LeerpleinPage() {

Groep

setGroepForm({ ...groepForm, naam: e.target.value })} /> @@ -944,10 +946,10 @@ export function LeerpleinPage() { setSubgroepForm({ ...subgroepForm, groep: groepId, leerplein: subgroepForm.leerplein || lp?.id || "" }); }}> - {groepen.map((g) => )} + {groepen.map((g) => )} setSubgroepForm({ ...subgroepForm, naam: e.target.value })} /> @@ -955,7 +957,7 @@ export function LeerpleinPage() {
- +
@@ -978,50 +980,67 @@ export function LeerpleinPage() { {loading ?

Laden…

: ( -
+
{leerpleinen.map((lp) => { const lpGroepen = groepen.filter((g) => (lp.groepen || []).includes(g.id)); const lpSubgroepen = subgroepen.filter((s) => (lp.subgroepen || []).includes(s.id) || lpGroepen.some((g) => g.id === s.groep)); return ( -
-
-
{lp.naam}{lp.omschrijving &&
{lp.omschrijving}
}
+
+
+
+ +
+ {lp.naam} +
{lpGroepen.length} groep(en){lp.omschrijving ? ` · ${lp.omschrijving}` : ""}
+
+
-
+
{lpGroepen.map((g) => { const groepLeerlingen = leerlingenVanGroep(g.id); const groepSubs = lpSubgroepen.filter((s) => s.groep === g.id); return ( -
-
-
{g.naam}
{groepLeerlingen.length} leerling(en)
+
+
+
+ +
+ {g.naam} +
{groepLeerlingen.length} leerling(en)
+
+
-
{groepLeerlingen.map((l) => l.volledige_naam).join(", ") || "Geen leerlingen"}
-
+
{groepLeerlingen.map((l) => l.volledige_naam).join(", ") || "Geen leerlingen"}
+
{groepSubs.map((s) => ( -
-
- {s.naam} +
+
+ +
+ {s.naam} +
{s.aantal_leden} leerling(en): {s.leden_namen.map((x) => x.naam).join(", ") || "geen"}
+
+
+
-
{s.aantal_leden} leerling(en): {s.leden_namen.map((x) => x.naam).join(", ") || "geen"}
))} - {groepSubs.length === 0 &&
Geen subgroepen.
} + {groepSubs.length === 0 &&
Geen subgroepen.
}
); })} - {lpGroepen.length === 0 &&
Nog geen groepen onder dit leerplein.
} + {lpGroepen.length === 0 &&
Nog geen groepen onder deze {BOVENGROEP_LABEL_LC}.
}
); })} - {leerpleinen.length === 0 &&
Nog geen leerpleinen.
} + {leerpleinen.length === 0 &&
Nog geen bovenliggende groepen.
} {groepen.filter((g) => !leerpleinVanGroep(g.id)).length > 0 && ( -
Er zijn groepen zonder leerplein. Bewerk ze hieronder en kies een bovenliggend leerplein: {groepen.filter((g) => !leerpleinVanGroep(g.id)).map((g) => g.naam).join(", ")}
+
Er zijn groepen zonder {BOVENGROEP_LABEL_LC}. Bewerk ze hieronder en kies een {BOVENGROEP_LABEL_LC}: {groepen.filter((g) => !leerpleinVanGroep(g.id)).map((g) => g.naam).join(", ")}
)}
)} @@ -1277,7 +1296,7 @@ export function leerlingenVanBlok(blok, ctx) { if (blok.leerling) return [blok.leerling]; return []; } -// Welke leerlingen vallen binnen een gekozen scope (klas/leerplein/leerling)? +// Welke leerlingen vallen binnen een gekozen scope (klas/bovenliggende groep/leerling)? export function scopeLeerlingen(scope, ctx) { if (scope.type === "leerling") return [scope.id]; if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id); @@ -1504,7 +1523,7 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { } /* ------------------------------- Groepsindeling --------------------------- */ -// Verdeel een hoofdgroep (of leerplein) kolomsgewijs in subgroepen: per kolom +// Verdeel een hoofdgroep (of bovenliggende groep) kolomsgewijs in subgroepen: per kolom // de subgroep met haar verantwoordelijke(n) en de vakken die er gegeven worden. function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken, onWijzig }) { const [type, setType] = useState("groep"); @@ -1544,13 +1563,13 @@ function Groepsindeling({ groepen, leerpleinen, subgroepen, medewerkers, blokken return (
-

Kies een hoofdgroep of leerplein en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.

+

Kies een hoofdgroep of bovenliggende groep en verdeel die in subgroepen. Per subgroep zie je de vakken en wie verantwoordelijk is.

setNieuw({ ...nieuw, groep: e.target.value })}> @@ -1648,7 +1667,7 @@ export function RoosterScherm({ mode }) { const [toonForm, setToonForm] = useState(false); const [tab, setTab] = useState("week"); const [error, setError] = useState(null); - // Scope-filter (ook voor gericht afdrukken): per klas/leerplein/leerling/personeel. + // Scope-filter (ook voor gericht afdrukken): per klas/bovenliggende groep/leerling/personeel. const [scopeType, setScopeType] = useState("alles"); const [scopeId, setScopeId] = useState(""); // Blok bewerken/kopiëren (alleen in de Roostermaker). @@ -1656,6 +1675,8 @@ export function RoosterScherm({ mode }) { const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; const [form, setForm] = useState(leeg); + const [splitNieuwBlok, setSplitNieuwBlok] = useState(false); + const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]); // Drag-and-drop via pointer events (werkt op muis én touch). const dragRef = useRef(null); @@ -1748,9 +1769,9 @@ export function RoosterScherm({ mode }) { function onKey(e) { const t = e.target; const typing = t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable); - if (e.key === "Escape") { setBewerkBlok(null); setToonForm(false); return; } + if (e.key === "Escape") { setBewerkBlok(null); sluitNieuwBlok(); return; } if (typing) return; - if (e.key === "n" || e.key === "N") { setTab("week"); setToonForm((v) => !v); } + if (e.key === "n" || e.key === "N") { setTab("week"); setBewerkBlok(null); setToonForm((v) => { if (v) resetNieuweSplit(); return !v; }); } else if (e.key === "ArrowLeft") { setMaandag((m) => plusDagen(m, -7)); } else if (e.key === "ArrowRight") { setMaandag((m) => plusDagen(m, 7)); } else if (e.key === "t" || e.key === "T") { setMaandag(maandagVan(new Date())); } @@ -1766,14 +1787,50 @@ export function RoosterScherm({ mode }) { setHoverSlot(blok.tijdslot); } + function resetNieuweSplit() { + setSplitNieuwBlok(false); + setSplitNieuwDoelen([]); + } + function openNieuwBlok() { + setBewerkBlok(null); + setToonForm(true); + } + function sluitNieuwBlok() { + setToonForm(false); + resetNieuweSplit(); + } + function toggleNieuwSplitDoel(id) { + setSplitNieuwDoelen((doelen) => doelen.includes(id) ? doelen.filter((x) => x !== id) : [...doelen, id]); + } + const doelOpties = form.doel_type === "groep" ? groepen : form.doel_type === "subgroep" ? subgroepen : leerlingen; + const splitNieuweOpties = form.doel_type === "groep" && form.doel + ? subgroepen.filter((s) => s.groep === Number(form.doel)) + : []; + const splitNieuweGroep = groepen.find((g) => g.id === Number(form.doel)); function toggleBegeleider(id) { setForm((f) => f.begeleiders.includes(id) ? { ...f, begeleiders: f.begeleiders.filter((x) => x !== id) } : { ...f, begeleiders: [...f.begeleiders, id] }); } async function opslaan(e) { e.preventDefault(); - const payload = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, groep: form.doel_type === "groep" ? form.doel : null, subgroep: form.doel_type === "subgroep" ? form.doel : null, leerling: form.doel_type === "leerling" ? form.doel : null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; - try { await createResource("roosterblokken", payload); setForm({ ...leeg, doel_type: form.doel_type }); setToonForm(false); await laadBlokken(); } catch (err) { setError(err.message); } + const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; + try { + if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) { + for (const subgroep of splitNieuwDoelen) { + await createResource("roosterblokken", { ...basis, groep: null, subgroep, leerling: null }); + } + } else { + await createResource("roosterblokken", { + ...basis, + groep: form.doel_type === "groep" ? form.doel : null, + subgroep: form.doel_type === "subgroep" ? form.doel : null, + leerling: form.doel_type === "leerling" ? form.doel : null, + }); + } + setForm({ ...leeg, doel_type: form.doel_type }); + sluitNieuwBlok(); + await herlaadNaBlokWijziging(); + } catch (err) { setError(err.message); } } async function verwijder(id) { if (!confirm("Dit roosterblok (alle weken) verwijderen?")) return; @@ -1880,8 +1937,8 @@ export function RoosterScherm({ mode }) {
- {leerplein ? leerplein.naam : "Leerplein"} - Leerplein → groepen + {leerplein ? leerplein.naam : BOVENGROEP_LABEL} + {BOVENGROEP_LABEL} → groepen
{kolommen.length}
@@ -1969,7 +2026,7 @@ export function RoosterScherm({ mode }) { week van {fmtDM(maandag)}
)} - {edit && schooljaar && tab === "week" && } + {edit && schooljaar && tab === "week" && } {printAan && schooljaar && (edit ? tab === "week" : true) && ( )} @@ -1981,7 +2038,7 @@ export function RoosterScherm({ mode }) { @@ -2024,44 +2081,92 @@ export function RoosterScherm({ mode }) { )} {edit && schooljaar && tab === "week" && toonForm && ( -
-
- - - -
-
- - -
-
-
Begeleiders:
-
- {medewerkers.map((m) => )} - {medewerkers.length === 0 && Nog geen medewerkers.} +
+ e.stopPropagation()}> +
+
+

Vak toevoegen

+
Maak één blok of splits direct over subgroepen.
+
+
-
-
- setForm({ ...form, opmerkingen: e.target.value })} /> - -
- +
+
+ Basis + Tijd, vak en doel +
+
+ + + + setForm({ ...form, opmerkingen: e.target.value })} /> +
+ +
+ Doelgroep +
+ + +
+
+ + {form.doel_type === "groep" && form.doel && ( +
+ Groep direct splitsen + + {splitNieuwBlok && ( + <> +
+ {splitNieuweOpties.map((s) => )} + {splitNieuweOpties.length === 0 && Nog geen subgroepen onder deze groep.} +
+ {splitNieuwDoelen.length > 0 && ( +
+
{splitNieuweGroep?.naam || "Groep"}
+
+ {splitNieuwDoelen.map((sid) => {splitNieuweOpties.find((s) => s.id === sid)?.naam || "Subgroep"})} +
+
+ )} + + )} +
+ )} + +
+ Begeleiders +
+ {medewerkers.map((m) => )} + {medewerkers.length === 0 && Nog geen medewerkers.} +
+
+ +
+ + +
+
+ +
)} {schooljaar && tab === "week" && ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 18a6268..47e338e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -229,6 +229,24 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .check-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lijst-kop { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .structuur-overzicht { display: grid; gap: 14px; margin-top: 18px; } +.structuur-tree { display: grid; gap: 14px; margin-top: 18px; } +.tree-root { border: 1px solid #bfdbfe; border-radius: 8px; background: #f8fbff; padding: 12px; } +.tree-node { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 10px 12px; } +.tree-node-main { display: flex; align-items: flex-start; gap: 10px; min-width: 0; } +.tree-node-main > div { min-width: 0; } +.tree-node-main strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tree-parent { border-color: #bfdbfe; background: #fff; } +.tree-group { border-left: 4px solid var(--brand); } +.tree-subgroup { border-left: 4px solid #93c5fd; background: #f9fafb; } +.tree-dot { width: 12px; height: 12px; border-radius: 999px; margin-top: 4px; flex: none; background: var(--line); } +.tree-dot.parent { background: var(--brand); } +.tree-dot.group { background: #38bdf8; } +.tree-dot.sub { background: #93c5fd; } +.tree-children { position: relative; display: grid; gap: 10px; margin: 10px 0 0 22px; padding-left: 16px; border-left: 2px solid #bfdbfe; } +.tree-branch { display: grid; gap: 7px; } +.tree-subchildren { margin-top: 0; border-left-color: #dbeafe; } +.tree-meta { padding-left: 16px; } +.tree-empty { padding: 8px 0 8px 12px; } .structuur-card { border: 1px solid #bfdbfe; border-radius: 8px; background: #f8fbff; padding: 12px; } .structuur-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; } .structuur-head.compact { align-items: center; margin-bottom: 6px; } @@ -305,6 +323,9 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .top-tabs-settings { margin-left: 0; border-left: 0; padding-left: 0; } .split-kolommen { grid-template-columns: 1fr; } .split-kolom { border-left: 0; padding-left: 0; } + .tree-node { flex-direction: column; align-items: stretch; } + .tree-children { margin-left: 10px; padding-left: 12px; } + .tree-meta { padding-left: 0; } } /* --- Login --- */