import { useEffect, useMemo, useState } from "react"; import { getMe, login, logout, getModules, setModuleState, wachtwoordVergeten, getHuisstijl } from "./api.js"; import Logo from "./Logo.jsx"; import { PersonenPage, FunctiesPage, ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage, RoosterWeergavePage, RoostermakerPage, PeriodeRoosterPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, HuisstijlPage, AuditLogPage, ImportExportPage, VervangingPage, PortalPage, } from "./pages.jsx"; const ICONS = { calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️", key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘", away: "🌴", gear: "⚙️", door: "🚪", audit: "🧾", }; // Iconen voor de groep-dropdowns in de bovenbalk. const GROEP_ICON = { Rooster: "calendar", Organisatie: "users", Overzicht: "clock", Hulpmiddelen: "puzzle", Toegang: "key" }; const GROEPSINDELING_PADEN = new Set(["/groepsindeling", "/structuur", "/leerplein", "/groepen", "/subgroepen"]); const GROEPSINDELING_ITEM = { label: "Groepsindeling", path: "/groepsindeling", icon: "layers", order: 24, group: "Organisatie" }; const normaliseerPad = (path) => (GROEPSINDELING_PADEN.has(path) ? "/groepsindeling" : path); const ROLE_NAV = { roostermaker: new Set(["/rooster", "/perioderooster", "/roostermaker", "/vervanging", "/afwezigheid", "/auto-rooster", "/vak-wizard"]), directie: null, zorgcoordinator: new Set(["/rooster", "/groepsindeling", "/personen", "/afwezigheid", "/stage", "/uren", "/portaal"]), stagebegeleider: new Set(["/rooster", "/stage", "/afwezigheid", "/portaal"]), medewerker: new Set(["/rooster", "/groepsindeling", "/afwezigheid", "/stage", "/uren", "/portaal"]), kijker: new Set(["/rooster", "/uren", "/portaal"]), ouder: new Set(["/portaal", "/ouderrooster"]), leerling: new Set(["/portaal", "/leerlingrooster"]), }; function magMenuZien(user, item) { if (user.is_staff || user.role === "beheerder" || user.role === "directie") return true; const allowed = ROLE_NAV[user.role]; if (!allowed) return true; return allowed.has(normaliseerPad(item.path)); } // Koppel een menu-pad aan een echt scherm. Onbekende paden tonen een // placeholder (worden in een latere fase ingevuld). const PAGES = { "/rooster": RoosterWeergavePage, "/perioderooster": PeriodeRoosterPage, "/roostermaker": RoostermakerPage, "/groepsindeling": GroepsindelingPage, "/personen": PersonenPage, "/functies": FunctiesPage, "/activiteiten": ActiviteitenPage, "/locaties": LocatiesPage, "/lokalen": LokalenPage, "/tijdsloten": TijdslotenPage, "/schooljaar": SchooljaarPage, "/afwezigheid": AfwezigheidPage, "/stage": StagePage, "/gebruikers": GebruikersPage, "/uren": UrenPage, "/leerlingrooster": LeerlingRoosterPage, "/ouderrooster": OuderRoosterPage, "/vak-wizard": VakWizardPage, "/personen-wizard": PersonenWizardPage, "/auto-rooster": AutoRoosterPage, "/vormgeving": HuisstijlPage, "/auditlog": AuditLogPage, "/import-export": ImportExportPage, "/vervanging": VervangingPage, "/portaal": PortalPage, }; // Thema (vormgeving) app-breed toepassen via CSS-variabelen. function hexNaarRgba(hex, a) { if (!hex) return ""; const m = hex.replace("#", ""); const v = m.length === 3 ? m.split("").map((c) => c + c).join("") : m; if (v.length < 6) return ""; const r = parseInt(v.slice(0, 2), 16), g = parseInt(v.slice(2, 4), 16), b = parseInt(v.slice(4, 6), 16); if ([r, g, b].some(Number.isNaN)) return ""; return `rgba(${r}, ${g}, ${b}, ${a})`; } export function pasThemaToe(h) { const root = document.documentElement; if (h && h.primaire_kleur) { root.style.setProperty("--brand", h.primaire_kleur); const soft = hexNaarRgba(h.primaire_kleur, 0.14); if (soft) root.style.setProperty("--brand-soft", soft); } if (h && h.accent_kleur) root.style.setProperty("--accent", h.accent_kleur); } const RESET_PARAMS = (() => { try { const q = new URLSearchParams(window.location.search); if (q.get("reset") && q.get("uid") && q.get("token")) return { uid: q.get("uid"), token: q.get("token") }; } catch (e) { /* geen window */ } return null; })(); export default function App() { // null = nog aan het controleren; {authenticated:false} = login tonen. const [user, setUser] = useState(null); const [checking, setChecking] = useState(true); useEffect(() => { getMe() .then(setUser) .catch(() => setUser({ authenticated: false })) .finally(() => setChecking(false)); }, []); if (RESET_PARAMS) return ; if (checking) return

Laden…

; if (!user || !user.authenticated) { return ; } return setUser({ authenticated: false })} />; } /* --------------------------------- Login ---------------------------------- */ function LoginPage({ onLogin }) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [vergeten, setVergeten] = useState(false); const [ident, setIdent] = useState(""); const [verzonden, setVerzonden] = useState(false); async function submit(e) { e.preventDefault(); setBusy(true); setError(null); try { onLogin(await login(username, password)); } catch (err) { setError(err.message); } finally { setBusy(false); } } async function vergetenSubmit(e) { e.preventDefault(); setBusy(true); setError(null); try { const r = await wachtwoordVergeten(ident); setVerzonden(true); setError(null); void r; } catch (err) { setError(err.message); } finally { setBusy(false); } } if (vergeten) { return (
Roosterwijs
{verzonden ? ( <>

Als het account bestaat, is er een e-mail met een resetlink verstuurd. Controleer je mailbox.

) : ( <>

Vul je gebruikersnaam of e-mailadres in; je ontvangt een resetlink.

{error &&
⚠ {error}
} setIdent(e.target.value)} /> )}
); } return (
Roosterwijs

Log in om verder te gaan.

{error &&
⚠ {error}
} setUsername(e.target.value)} /> setPassword(e.target.value)} />
); } /* ---------------------------------- Shell --------------------------------- */ function Shell({ user, onLogout }) { const [modules, setModules] = useState([]); const [active, setActive] = useState("/rooster"); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [cogOpen, setCogOpen] = useState(false); const [openMenu, setOpenMenu] = useState(null); const [schoolnaam, setSchoolnaam] = useState(""); useEffect(() => { getHuisstijl().then((h) => { pasThemaToe(h); setSchoolnaam(h && h.schoolnaam ? h.schoolnaam : ""); }).catch(() => {}); }, []); async function load() { setLoading(true); try { setModules(await getModules()); setError(null); } catch (e) { setError(e.message); } finally { setLoading(false); } } useEffect(() => { load(); }, []); useEffect(() => { const onNav = (event) => { if (event.detail) setActive(normaliseerPad(event.detail)); }; window.addEventListener("roosterwijs:navigate", onNav); return () => window.removeEventListener("roosterwijs:navigate", onNav); }, []); async function doLogout() { try { await logout(); } finally { onLogout(); } } // Bouw de navigatie op uit de menu-items die ACTIEVE modules aanbieden. // Verouderde groepspaden 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 === "/groepsindeling" ? { ...raw, ...GROEPSINDELING_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) voegToe({ ...it, module: m.key }); } const isBeheerder = user.is_staff || user.role === "beheerder"; if (isBeheerder) { voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" }); } const items = [...perPad.values()].filter((item) => magMenuZien(user, item)); items.sort((a, b) => a.order - b.order); return { tabs: items.filter((i) => i.group !== "Instellingen"), instellingenTabs: items.filter((i) => i.group === "Instellingen"), alleItems: items, }; }, [modules, user]); const eerstePad = alleItems[0]?.path || "/rooster"; const activePad = normaliseerPad( [...alleItems].some((item) => normaliseerPad(item.path) === normaliseerPad(active)) ? active : eerstePad ); const Page = PAGES[activePad]; // Groepeer de hoofdtabs: groepen met meerdere items worden een dropdown, // groepen met één item en losse items blijven directe tabs. const navEntries = (() => { const directe = []; const groepMap = new Map(); for (const it of tabs) { if (it.group) { if (!groepMap.has(it.group)) groepMap.set(it.group, []); groepMap.get(it.group).push(it); } else directe.push(it); } const entries = directe.map((it) => ({ type: "tab", order: it.order, it })); for (const [groep, items] of groepMap) { if (items.length === 1) entries.push({ type: "tab", order: items[0].order, it: items[0] }); else entries.push({ type: "group", order: Math.min(...items.map((i) => i.order)), groep, items }); } entries.sort((a, b) => a.order - b.order); return entries; })(); function TabKnop({ it, compact }) { const pad = normaliseerPad(it.path); return ( ); } function NavDropdown({ groep, items }) { const open = openMenu === groep; const actief = items.some((i) => normaliseerPad(i.path) === activePad); const icon = GROEP_ICON[groep] || items[0].icon; return (
setOpenMenu(null)}>
{items.map((it) => { const pad = normaliseerPad(it.path); return ( ); })}
); } // Instellingen-cog: staat in de bovenbalk (buiten de scrollende tabbalk, // anders knipt overflow het uitklapmenu weg). const cogMenu = instellingenTabs.length > 0 ? (
setCogOpen(false)}>
{instellingenTabs.map((it) => { const pad = normaliseerPad(it.path); return ( ); })}
) : null; return (
Roosterwijs {schoolnaam && · {schoolnaam}}
{user.username} {cogMenu}
{error &&
⚠ {error}
} {loading ? (

Laden…

) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? ( ) : Page ? ( ) : ( )}
); } function Placeholder({ path, menu }) { const item = menu.find((m) => m.path === path); return (

{item ? item.label : "Welkom"}

Dit scherm wordt in een volgende fase ingevuld. Fase 1 levert het kerndomein: personen, groepen en subgroepen.

); } function ModuleManager({ modules, onChange }) { const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); async function toggle(m) { setBusy(m.key); setMsg(null); try { await setModuleState(m.key, !m.enabled); await onChange(); } catch (e) { setMsg(e.message); } finally { setBusy(null); } } return (

Modulebeheer

Schakel modules per school in of uit. De kern staat altijd aan.

{msg &&
⚠ {msg}
}
{modules.map((m) => (
{m.name}
{m.category} · v{m.version}
toggle(m)} />

{m.description}

{m.core ? ( Kern · altijd aan ) : ( {m.enabled ? "Ingeschakeld" : "Uitgeschakeld"} )} {m.depends_on.length > 0 && ( vereist: {m.depends_on.join(", ")} )}
))}
); } function Toggle({ checked, disabled, onChange }) { return ( ); }