roosterwijs/frontend/src/App.jsx
2026-07-04 23:23:58 +02:00

511 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <WachtwoordResetPage uid={RESET_PARAMS.uid} token={RESET_PARAMS.token} />;
if (checking) return <p className="center-msg">Laden</p>;
if (!user || !user.authenticated) {
return <LoginPage onLogin={setUser} />;
}
return <Shell user={user} onLogout={() => 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 (
<div className="login-wrap">
<form className="login-card" onSubmit={vergetenSubmit}>
<div className="brand login-brand">
<span className="brand-logo"><Logo size={36} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
{verzonden ? (
<>
<p className="muted">Als het account bestaat, is er een e-mail met een resetlink verstuurd. Controleer je mailbox.</p>
<button type="button" className="btn primary" onClick={() => { setVergeten(false); setVerzonden(false); setIdent(""); }}>Terug naar inloggen</button>
</>
) : (
<>
<p className="muted">Vul je gebruikersnaam of e-mailadres in; je ontvangt een resetlink.</p>
{error && <div className="banner error"> {error}</div>}
<input placeholder="Gebruikersnaam of e-mail" value={ident} required autoFocus onChange={(e) => setIdent(e.target.value)} />
<button className="btn primary" type="submit" disabled={busy}>{busy ? "Bezig…" : "Stuur resetlink"}</button>
<button type="button" className="link" onClick={() => { setVergeten(false); setError(null); }}>Terug naar inloggen</button>
</>
)}
</form>
</div>
);
}
return (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<div className="brand login-brand">
<span className="brand-logo"><Logo size={36} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
<p className="muted">Log in om verder te gaan.</p>
{error && <div className="banner error"> {error}</div>}
<input
placeholder="Gebruikersnaam"
value={username}
required
autoFocus
autoComplete="username"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Wachtwoord"
value={password}
required
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
/>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Bezig…" : "Inloggen"}
</button>
<button type="button" className="link" onClick={() => { setVergeten(true); setError(null); }}>Wachtwoord vergeten?</button>
</form>
</div>
);
}
/* ---------------------------------- 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 (
<button
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>
<span>{it.label}</span>
</button>
);
}
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 (
<div className={open ? "nav-menu open" : "nav-menu"} onMouseLeave={() => setOpenMenu(null)}>
<button
type="button"
className={actief ? "top-tab nav-trigger active" : "top-tab nav-trigger"}
onClick={() => setOpenMenu((m) => (m === groep ? null : groep))}
aria-haspopup="true"
aria-expanded={open}
>
<span className="ico">{ICONS[icon] || "•"}</span>
<span>{groep}</span>
<span className="caret"></span>
</button>
<div className="nav-dropdown" role="menu">
{items.map((it) => {
const pad = normaliseerPad(it.path);
return (
<button key={it.path} className={activePad === pad ? "cog-item active" : "cog-item"} role="menuitem" onClick={() => { setActive(pad); setOpenMenu(null); }}>
<span className="ico">{ICONS[it.icon] || "•"}</span><span>{it.label}</span>
</button>
);
})}
</div>
</div>
);
}
// Instellingen-cog: staat in de bovenbalk (buiten de scrollende tabbalk,
// anders knipt overflow het uitklapmenu weg).
const cogMenu = instellingenTabs.length > 0 ? (
<div className={cogOpen ? "cog-menu open" : "cog-menu"} onMouseLeave={() => setCogOpen(false)}>
<button
className="cog-btn"
onClick={() => setCogOpen((v) => !v)}
title="Instellingen"
aria-haspopup="true"
aria-expanded={cogOpen}
>
<span className="ico"></span>
</button>
<div className="cog-dropdown" role="menu">
{instellingenTabs.map((it) => {
const pad = normaliseerPad(it.path);
return (
<button
key={it.path}
className={activePad === pad ? "cog-item active" : "cog-item"}
role="menuitem"
onClick={() => { setActive(pad); setCogOpen(false); }}
>
<span className="ico">{ICONS[it.icon] || "•"}</span>
<span>{it.label}</span>
</button>
);
})}
</div>
</div>
) : null;
return (
<div className="app">
<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>
{schoolnaam && <span className="brand-schoolnaam">· {schoolnaam}</span>}
</div>
<div className="topbar-user">
<span className="muted small">{user.username}</span>
<button className="link" onClick={doLogout}>Uitloggen</button>
{cogMenu}
</div>
</div>
<nav className="top-tabs" aria-label="Hoofdnavigatie">
{navEntries.map((e) => e.type === "group"
? <NavDropdown key={"groep:" + e.groep} groep={e.groep} items={e.items} />
: <TabKnop key={e.it.path} it={e.it} />)}
</nav>
</header>
<main className={activePad === "/roostermaker" || activePad === "/rooster" ? "content content-wide" : "content"}>
{error && <div className="banner error"> {error}</div>}
{loading ? (
<p>Laden</p>
) : activePad === "/modules" && (user.is_staff || user.role === "beheerder") ? (
<ModuleManager modules={modules} onChange={load} />
) : Page ? (
<Page />
) : (
<Placeholder path={activePad} menu={alleItems} />
)}
</main>
</div>
);
}
function Placeholder({ path, menu }) {
const item = menu.find((m) => m.path === path);
return (
<section>
<h1>{item ? item.label : "Welkom"}</h1>
<p className="muted">
Dit scherm wordt in een volgende fase ingevuld. Fase 1 levert het
kerndomein: personen, groepen en subgroepen.
</p>
</section>
);
}
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 (
<section>
<h1>Modulebeheer</h1>
<p className="muted">
Schakel modules per school in of uit. De kern staat altijd aan.
</p>
{msg && <div className="banner error"> {msg}</div>}
<div className="cards">
{modules.map((m) => (
<div className={m.enabled ? "card on" : "card"} key={m.key}>
<div className="card-head">
<div>
<div className="card-title">{m.name}</div>
<div className="card-cat">{m.category} · v{m.version}</div>
</div>
<Toggle
checked={m.enabled}
disabled={m.core || busy === m.key}
onChange={() => toggle(m)}
/>
</div>
<p className="card-desc">{m.description}</p>
<div className="card-foot">
{m.core ? (
<span className="tag core">Kern · altijd aan</span>
) : (
<span className={m.enabled ? "tag on" : "tag"}>
{m.enabled ? "Ingeschakeld" : "Uitgeschakeld"}
</span>
)}
{m.depends_on.length > 0 && (
<span className="tag dep">vereist: {m.depends_on.join(", ")}</span>
)}
</div>
</div>
))}
</div>
</section>
);
}
function Toggle({ checked, disabled, onChange }) {
return (
<button
className={`toggle ${checked ? "on" : ""}`}
disabled={disabled}
onClick={onChange}
aria-pressed={checked}
>
<span className="knob" />
</button>
);
}