479 lines
17 KiB
JavaScript
479 lines
17 KiB
JavaScript
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,
|
||
} from "./pages.jsx";
|
||
|
||
const ICONS = {
|
||
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
|
||
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
|
||
away: "🌴", gear: "⚙️", door: "🚪",
|
||
};
|
||
|
||
// 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);
|
||
|
||
// 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,
|
||
};
|
||
|
||
// 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(); }, []);
|
||
|
||
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 });
|
||
}
|
||
if (user.is_staff) {
|
||
voegToe({ label: "Modulebeheer", path: "/modules", icon: "gear", order: 9999, group: "Instellingen" });
|
||
}
|
||
const items = [...perPad.values()];
|
||
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.is_staff]);
|
||
|
||
const activePad = normaliseerPad(active);
|
||
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" ? "content content-wide" : "content"}>
|
||
{error && <div className="banner error">⚠ {error}</div>}
|
||
{loading ? (
|
||
<p>Laden…</p>
|
||
) : activePad === "/modules" && user.is_staff ? (
|
||
<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>
|
||
);
|
||
}
|