Vernieuw navigatie en roostermaker UI

This commit is contained in:
bes-r 2026-06-15 22:09:48 +02:00
parent 10593e3cfd
commit 18043bc147
5 changed files with 137 additions and 120 deletions

View file

@ -30,12 +30,10 @@ class CoreConfig(AppConfig):
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12),
# Submenu Personen (functies zitten als tab in het Personen-scherm)
MenuItem("Personen", "/personen", icon="users", order=20, group="Personen"),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Personen"),
# Submenu Groepen
MenuItem("Groepen", "/groepen", icon="layers", order=30, group="Groepen"),
MenuItem("Subgroepen", "/subgroepen", icon="layers", order=40, group="Groepen"),
# Submenu Organisatie: wie hoort waar.
MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"),
MenuItem("Structuur", "/structuur", icon="layers", order=24, group="Organisatie"),
MenuItem("Afwezigheid", "/afwezigheid", icon="away", order=28, group="Organisatie"),
),
)
)

View file

@ -8,7 +8,7 @@ class LeerpleinConfig(AppConfig):
verbose_name = "Module: Leerplein (groepsstructuur)"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
from plugins.registry import ModuleSpec, registry
registry.register(
ModuleSpec(
@ -22,8 +22,6 @@ class LeerpleinConfig(AppConfig):
category="Structuur",
core=False,
default_enabled=False,
menu_items=(
MenuItem("Leerplein", "/leerplein", icon="layers", order=50, group="Groepen"),
),
menu_items=(),
)
)

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { getMe, login, logout, getModules, setModuleState } from "./api.js";
import Logo from "./Logo.jsx";
import {
GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage,
PersonenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage,
AfwezigheidPage, LeerpleinPage, StagePage, GebruikersPage,
@ -14,21 +14,23 @@ const ICONS = {
away: "🌴", gear: "⚙️",
};
const STRUCTUUR_PADEN = new Set(["/structuur", "/leerplein", "/groepen", "/subgroepen"]);
const STRUCTUUR_ITEM = { label: "Structuur", 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
// placeholder (worden in een latere fase ingevuld).
const PAGES = {
"/rooster": RoosterWeergavePage,
"/roostermaker": RoostermakerPage,
"/structuur": LeerpleinPage,
"/personen": PersonenPage,
"/functies": FunctiesPage,
"/groepen": GroepenPage,
"/subgroepen": SubgroepenPage,
"/activiteiten": ActiviteitenPage,
"/locaties": LocatiesPage,
"/tijdsloten": TijdslotenPage,
"/schooljaar": SchooljaarPage,
"/afwezigheid": AfwezigheidPage,
"/leerplein": LeerpleinPage,
"/stage": StagePage,
"/gebruikers": GebruikersPage,
};
@ -134,94 +136,85 @@ function Shell({ user, onLogout }) {
}
}
const [openGroups, setOpenGroups] = useState({});
// Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden.
// Items met een `group` komen onder een inklapbaar submenu. De groep
// "Instellingen" wordt apart, subtiel onderaan, getoond.
const { topItems, groups, instellingenItems, alleItems } = useMemo(() => {
const items = [];
// Verouderde structuurpaden blijven werken, maar verschijnen als één tab.
const { tabs, instellingenTabs, alleItems } = useMemo(() => {
const perPad = new Map();
const voegToe = (raw) => {
const pad = normaliseerPad(raw.path);
const item = pad === "/structuur"
? { ...raw, ...STRUCTUUR_ITEM, module: raw.module }
: { ...raw, path: pad };
const huidig = perPad.get(item.path);
if (!huidig || item.order < huidig.order) perPad.set(item.path, item);
};
for (const m of modules) {
if (!m.enabled) continue;
for (const it of m.menu_items) items.push({ ...it, module: m.key });
for (const it of m.menu_items) voegToe({ ...it, module: m.key });
}
if (user.is_staff) {
items.push({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" });
voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" });
}
const items = [...perPad.values()];
items.sort((a, b) => a.order - b.order);
const top = items.filter((i) => !i.group);
const grouped = {};
const settings = [];
for (const i of items) {
if (i.group === "Instellingen") settings.push(i);
else if (i.group) (grouped[i.group] = grouped[i.group] || []).push(i);
}
return { topItems: top, groups: grouped, instellingenItems: settings, alleItems: items };
return {
tabs: items.filter((i) => i.group !== "Instellingen"),
instellingenTabs: items.filter((i) => i.group === "Instellingen"),
alleItems: items,
};
}, [modules, user.is_staff]);
function NavKnop({ it, indent }) {
const activePad = normaliseerPad(active);
const Page = PAGES[activePad];
function TabKnop({ it, compact }) {
const pad = normaliseerPad(it.path);
return (
<button
className={(active === it.path ? "nav-item active" : "nav-item") + (indent ? " nav-sub" : "")}
onClick={() => setActive(it.path)}
className={(activePad === pad ? "top-tab active" : "top-tab") + (compact ? " compact" : "")}
onClick={() => setActive(pad)}
title={it.label}
aria-current={activePad === pad ? "page" : undefined}
>
<span className="ico">{ICONS[it.icon] || "•"}</span>
{it.label}
<span>{it.label}</span>
</button>
);
}
const Page = PAGES[active];
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span className="brand-logo"><Logo size={30} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
<nav>
{topItems.map((it) => <NavKnop key={it.path} it={it} />)}
{Object.entries(groups).map(([naam, kinderen]) => {
const open = openGroups[naam] !== false;
return (
<div className="nav-group" key={naam}>
<button className="nav-group-head" onClick={() => setOpenGroups((o) => ({ ...o, [naam]: !open }))}>
<span>{naam}</span>
<span className="nav-chev">{open ? "▾" : "▸"}</span>
</button>
{open && kinderen.map((it) => <NavKnop key={it.path} it={it} indent />)}
</div>
);
})}
</nav>
<div className="sidebar-foot">
{instellingenItems.length > 0 && (
<div className="nav-group">
<button className="nav-group-head subtiel" onClick={() => setOpenGroups((o) => ({ ...o, Instellingen: !o.Instellingen }))}>
<span> Instellingen</span>
<span className="nav-chev">{openGroups.Instellingen ? "▾" : "▸"}</span>
</button>
{openGroups.Instellingen && instellingenItems.map((it) => <NavKnop key={it.path} it={it} indent />)}
</div>
)}
<div className="sidebar-user">
<span className="muted small">👤 {user.username}</span>
<button className="link" onClick={doLogout}>uitloggen</button>
<header className="topbar no-print">
<div className="topbar-main">
<div className="brand">
<span className="brand-logo"><Logo size={30} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
<div className="topbar-user">
<span className="muted small">{user.username}</span>
<button className="link" onClick={doLogout}>Uitloggen</button>
</div>
</div>
</aside>
<nav className="top-tabs" aria-label="Hoofdnavigatie">
{tabs.map((it) => <TabKnop key={it.path} it={it} />)}
{instellingenTabs.length > 0 && (
<div className="top-tabs-settings" aria-label="Instellingen">
{instellingenTabs.map((it) => <TabKnop key={it.path} it={it} compact />)}
</div>
)}
</nav>
</header>
<main className="content">
<main className={activePad === "/roostermaker" ? "content content-wide" : "content"}>
{error && <div className="banner error"> {error}</div>}
{loading ? (
<p>Laden</p>
) : active === "/modules" && user.is_staff ? (
) : activePad === "/modules" && user.is_staff ? (
<ModuleManager modules={modules} onChange={load} />
) : Page ? (
<Page />
) : (
<Placeholder path={active} menu={alleItems} />
<Placeholder path={activePad} menu={alleItems} />
)}
</main>
</div>

View file

@ -1400,11 +1400,20 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); }
}
return (
<div className="blok-editor no-print" onClick={(e) => e.stopPropagation()}>
<div className="blok-editor-kop">
<strong>Bewerken</strong>
<button type="button" className="icon-btn" title="Sluiten" onClick={onSluit}>×</button>
</div>
<div className="modal-overlay no-print" onClick={onSluit}>
<div className="modal rooster-modal" role="dialog" aria-modal="true" aria-label="Roosterblok bewerken" onClick={(e) => e.stopPropagation()}>
<div className="modal-kop">
<div>
<h2>{blok.activiteit_naam || "Roosterblok"}</h2>
<div className="muted small">{blok.doel_naam || "Bewerken en splitsen"}</div>
</div>
<button type="button" className="icon-btn" title="Sluiten" onClick={onSluit}>×</button>
</div>
<div className="blok-editor modal-editor">
<div className="blok-editor-kop">
<strong>Vak bewerken</strong>
<span className="muted small">Wijzig, splits of kopieer dit blok</span>
</div>
<Foutmelding msg={error} />
<div className="editor-grid">
<select value={f.tijdslot} title="Tijdslot" onChange={(e) => setF({ ...f, tijdslot: e.target.value })}>
@ -1488,6 +1497,8 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
</div>
<button type="button" className="btn icon-label" disabled={kopieDoelen.length === 0} onClick={kopieer} title="Kopiëren"> <span>{kopieDoelen.length}</span></button>
</details>
</div>
</div>
</div>
);
}
@ -1695,6 +1706,10 @@ export function RoosterScherm({ mode }) {
setUitzonderingen(await listResource("blok-uitzonderingen", `?schooljaar=${schooljaar}&van=${vanISO}&tot=${totISO}`));
} catch (e) { setError(e.message); }
}
async function herlaadNaBlokWijziging() {
try { setSubgroepen(await listResource("subgroepen")); } catch { /* blokken verversen blijft leidend */ }
await laadBlokken();
}
laadRef.current = laadBlokken;
useEffect(() => { laadBlokken(); }, [schooljaar]);
useEffect(() => { laadWeek(); }, [schooljaar, vanISO]);
@ -1789,14 +1804,6 @@ export function RoosterScherm({ mode }) {
const editorCtx = { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten };
const isOpenBlok = (b) => edit && bewerkBlok && bewerkBlok.id === b.id;
const toggleOpenBlok = (b) => setBewerkBlok((huidig) => (huidig && huidig.id === b.id ? null : b));
const inlineBewerker = (b) => isOpenBlok(b) && (
<BlokBewerker
blok={b}
ctx={editorCtx}
onSluit={() => setBewerkBlok(null)}
onKlaar={async () => { setBewerkBlok(null); await laadBlokken(); }}
/>
);
function renderRoosterBlok(b, iso, kal) {
const uit = uitzOp(b.id, iso);
const vervallen = !!kal || (uit && uit.vervalt);
@ -1827,7 +1834,6 @@ export function RoosterScherm({ mode }) {
{edit && !kal && !uit && (
<button type="button" className="icon-btn danger rb-verval no-print" title="Dit vak laten vervallen" onClick={(e) => { e.stopPropagation(); laatVervallen(b, iso); }}></button>
)}
{inlineBewerker(b)}
</div>
);
}
@ -1853,7 +1859,6 @@ export function RoosterScherm({ mode }) {
{b.begeleiders_namen.length > 0 && <span>{b.begeleiders_namen.map((x) => x.naam).join(", ")}</span>}
{vervallen && <span className="rb-status">Vervalt</span>}
</div>
{inlineBewerker(b)}
</div>
);
}
@ -2009,7 +2014,6 @@ export function RoosterScherm({ mode }) {
{edit && (
<div className="tabbar no-print">
<button className={tab === "week" ? "tab on" : "tab"} title="Weekrooster" onClick={() => setTab("week")}> Week</button>
<button className={tab === "indeling" ? "tab on" : "tab"} title="Indeling" onClick={() => setTab("indeling")}> Indeling</button>
<button className={tab === "conflicten" ? "tab on" : "tab"} title="Conflicten" onClick={() => setTab("conflicten")}> Conflicten</button>
<button className={tab === "schooljaar" ? "tab on" : "tab"} title="Schooljaar" onClick={() => setTab("schooljaar")}> Jaar</button>
<button className={tab === "instellingen" ? "tab on" : "tab"} title="Instellingen" onClick={() => setTab("instellingen")}> Instellingen</button>
@ -2099,20 +2103,6 @@ export function RoosterScherm({ mode }) {
{schooljaar && tab === "conflicten" && <Conflicten schooljaar={schooljaar} />}
{edit && schooljaar && tab === "indeling" && (
<Groepsindeling
groepen={groepen}
leerpleinen={leerpleinen}
subgroepen={subgroepen}
medewerkers={medewerkers}
blokken={blokken}
onWijzig={async () => {
setSubgroepen(await listResource("subgroepen"));
await laadBlokken();
}}
/>
)}
{tab === "schooljaar" && (
<div className="instellingen">
<p className="muted">Maak hier snel een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).</p>
@ -2129,6 +2119,14 @@ export function RoosterScherm({ mode }) {
</div>
)}
{edit && bewerkBlok && (
<BlokBewerker
blok={bewerkBlok}
ctx={editorCtx}
onSluit={() => setBewerkBlok(null)}
onKlaar={async () => { setBewerkBlok(null); await herlaadNaBlokWijziging(); }}
/>
)}
{edit && ghost && <div className="drag-ghost" style={{ left: ghost.x + 10, top: ghost.y + 10 }}>{ghost.label}</div>}
</section>
);

View file

@ -12,19 +12,32 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color: var(--ink); background: var(--bg); }
.app { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; }
.app { min-height: 100vh; }
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 6px;
.topbar {
position: sticky; top: 0; z-index: 50;
background: rgba(255,255,255,.96); backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
padding: 10px 24px 8px;
}
.brand { display: flex; align-items: center; gap: 10px; margin: 4px 8px 18px; color: var(--brand); }
.topbar-main { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
.topbar-user { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.top-tabs { display: flex; align-items: center; gap: 6px; overflow-x: auto; scrollbar-width: thin; }
.top-tabs-settings { display: flex; align-items: center; gap: 6px; margin-left: auto; padding-left: 10px; border-left: 1px solid var(--line); }
.top-tab {
display: inline-flex; align-items: center; gap: 8px;
min-height: 38px; flex: 0 0 auto;
border: 1px solid transparent; border-radius: 8px;
background: transparent; color: var(--ink);
padding: 8px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.top-tab:hover { background: var(--bg); border-color: var(--line); }
.top-tab.active { background: var(--brand); color: #fff; border-color: var(--brand); font-weight: 700; }
.top-tab.compact { color: var(--muted); }
.top-tab.compact.active { color: #fff; }
.brand { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--brand); }
.brand-logo { display: inline-flex; color: var(--brand); }
.brand-name { font-weight: 800; font-size: 19px; letter-spacing: .2px; }
.brand-name { font-weight: 800; font-size: 19px; letter-spacing: 0; }
.nav-item {
display: flex; align-items: center; gap: 10px;
width: 100%; text-align: left;
@ -36,7 +49,8 @@ body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color:
.ico { width: 18px; text-align: center; }
.hint { margin-top: auto; font-size: 12px; color: var(--muted); padding: 12px 8px 0; }
.content { padding: 32px 40px; }
.content { width: 100%; padding: 22px 24px 32px; }
.content-wide { padding-left: 18px; padding-right: 18px; }
h1 { margin: 0 0 6px; font-size: 24px; }
.muted { color: var(--muted); }
@ -114,7 +128,8 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.tag.dag-vrije_dag { background: #fee2e2; color: #b91c1c; }
.tag.dag-vakantie { background: #dcfce7; color: #166534; }
.rooster-grid { display: grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap: 12px; margin-top: 20px; }
.rooster-scherm { width: 100%; }
.rooster-grid { display: grid; grid-template-columns: repeat(5, minmax(210px, 1fr)); gap: 10px; margin-top: 18px; width: 100%; }
.rooster-kolom { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 8px; min-height: 80px; }
.rooster-dag { position: relative; font-weight: 600; font-size: 13px; text-align: center; padding: 6px 32px 6px 0; border-bottom: 1px solid var(--line); margin-bottom: 8px; min-height: 36px; }
.rooster-blok { border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 8px; padding: 8px; margin-bottom: 8px; background: #fff; }
@ -175,11 +190,11 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.split-title span { font-size: 11px; color: var(--muted); }
.split-count { min-width: 22px; height: 22px; display: inline-grid; place-items: center; border-radius: 999px; background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 800; }
.split-groep { font-size: 12px; font-weight: 800; color: var(--brand); margin: 0 2px 7px; }
.split-kolommen { position: relative; display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 7px; align-items: start; padding-top: 12px; }
.split-kolommen { position: relative; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; align-items: stretch; padding-top: 12px; }
.split-kolommen::before { content: ""; position: absolute; top: 0; left: 10%; right: 10%; border-top: 2px solid #bfdbfe; }
.split-kolom { position: relative; min-width: 0; border-left: 1px solid var(--line); padding-left: 7px; }
.split-kolom { position: relative; min-width: 0; border-left: 1px solid var(--line); padding-left: 7px; height: 100%; }
.split-kolom::before { content: ""; position: absolute; top: -12px; left: 50%; width: 2px; height: 12px; background: #bfdbfe; }
.split-kolom:first-child { border-left: 0; padding-left: 0; }
.split-kolom:nth-child(odd) { border-left: 0; padding-left: 0; }
.split-kop { font-size: 11px; font-weight: 700; color: var(--muted); margin: 0 0 4px; }
.split-vak { position: relative; border: 1px solid var(--line); border-left: 4px solid var(--brand); border-radius: 6px; background: #fff; padding: 6px 6px 5px; margin-bottom: 6px; cursor: pointer; box-shadow: 0 1px 0 rgba(31,41,55,.03); }
.split-vak.open { box-shadow: 0 0 0 2px var(--brand-soft); }
@ -225,6 +240,11 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.modal { background: var(--panel); border-radius: 12px; padding: 18px 20px; width: min(560px, 100%); box-shadow: 0 20px 50px rgba(0,0,0,.3); }
.modal-kop { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.modal-kop h2 { margin: 0; font-size: 18px; }
.rooster-modal { width: min(920px, calc(100vw - 32px)); padding: 16px; }
.modal-editor { margin-top: 0; padding: 12px; background: #f9fafb; }
.modal-editor .editor-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.modal-editor .editor-grid input, .modal-editor .editor-grid select { font-size: 13px; padding: 9px 10px; }
.modal-editor .editor-detail { padding-top: 10px; margin-top: 10px; }
.modal-hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0 12px; }
.rooster-blok.klikbaar { cursor: pointer; }
.rooster-blok.klikbaar:hover { box-shadow: 0 0 0 2px var(--brand-soft); }
@ -233,7 +253,7 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
/* --- Afdrukken --- */
.print-knop { margin-left: auto; background: #fff; }
@media print {
.sidebar, .no-print, .tabbar, .rooster-balk, .banner,
.sidebar, .topbar, .no-print, .tabbar, .rooster-balk, .banner,
.rb-greep, .rb-x, .rb-verval, .icon-btn, .print-knop, .drag-ghost { display: none !important; }
body { background: #fff; }
.app { display: block; }
@ -271,12 +291,22 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
/* Touch-vriendelijk: ruimere tikdoelen op kleinere/aanraakschermen. */
@media (pointer: coarse) {
.btn, .chip, input, select { min-height: 44px; }
.nav-item { padding: 14px 12px; font-size: 15px; }
.nav-item, .top-tab { padding: 12px 12px; font-size: 15px; }
.link { padding: 8px 6px; display: inline-block; }
.rb-greep { font-size: 22px; padding: 4px 10px; }
.grid th, .grid td { padding: 14px; }
}
@media (max-width: 760px) {
.topbar { padding: 10px 12px 8px; }
.topbar-main { align-items: flex-start; }
.topbar-user { flex-direction: column; align-items: flex-end; gap: 2px; }
.content, .content-wide { padding: 18px 12px 28px; }
.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; }
}
/* --- Login --- */
.center-msg { text-align: center; margin-top: 20vh; color: var(--muted); }
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }