roosterwijs/frontend/src/App.jsx

343 lines
11 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 } from "./api.js";
import Logo from "./Logo.jsx";
import {
PersonenPage, FunctiesPage,
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage,
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage,
VakWizardPage, PersonenWizardPage,
} from "./pages.jsx";
const ICONS = {
calendar: "📅", users: "👥", layers: "🗂️", printer: "🖨️",
key: "🔑", puzzle: "🧩", badge: "🪪", book: "📚", pin: "📍", clock: "🕘",
away: "🌴", gear: "⚙️", door: "🚪",
};
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,
"/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,
};
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 (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);
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
onLogin(await login(username, password));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
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>
</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);
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];
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>
);
}
// 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>
</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">
{tabs.map((it) => <TabKnop key={it.path} it={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>
);
}