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 (
);
}
return (
);
}
/* ---------------------------------- 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 (
setActive(pad)}
title={it.label}
aria-current={activePad === pad ? "page" : undefined}
>
{ICONS[it.icon] || "•"}
{it.label}
);
}
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)}>
setOpenMenu((m) => (m === groep ? null : groep))}
aria-haspopup="true"
aria-expanded={open}
>
{ICONS[icon] || "•"}
{groep}
▾
{items.map((it) => {
const pad = normaliseerPad(it.path);
return (
{ setActive(pad); setOpenMenu(null); }}>
{ICONS[it.icon] || "•"} {it.label}
);
})}
);
}
// Instellingen-cog: staat in de bovenbalk (buiten de scrollende tabbalk,
// anders knipt overflow het uitklapmenu weg).
const cogMenu = instellingenTabs.length > 0 ? (
setCogOpen(false)}>
setCogOpen((v) => !v)}
title="Instellingen"
aria-haspopup="true"
aria-expanded={cogOpen}
>
⚙️
{instellingenTabs.map((it) => {
const pad = normaliseerPad(it.path);
return (
{ setActive(pad); setCogOpen(false); }}
>
{ICONS[it.icon] || "•"}
{it.label}
);
})}
) : null;
return (
{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 (
);
}