diff --git a/web/src/App.jsx b/web/src/App.jsx index aca0152..c7574f6 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { api } from "./api"; import { useAuth } from "./auth"; +import { gruppenPfade } from "./nav"; import Icon from "./components/Icon"; import ErrorBoundary from "./components/ErrorBoundary"; import BrandMark from "./components/BrandMark"; @@ -36,6 +37,11 @@ import Konto from "./pages/Konto"; const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); +// Ausgeblendete Eintraege stehen hier drin. Ueber einen Kontext und nicht als +// Eigenschaft an jedem NavItem: So bleiben die zwanzig Zeilen der Leiste +// unveraendert lesbar. +const VersteckteNav = createContext(new Set()); + // Die vier Bestandslisten stehen unter einer zuklappbaren Gruppe. Sie sind der // laengste Block der Leiste und wird im Alltag selten gebraucht – wer einlagert, // nutzt die Knoepfe darueber. @@ -48,6 +54,9 @@ const LISTEN = [ const LISTEN_OFFEN_KEY = "nav:listen-offen"; function NavItem({ to, icon, label, end }) { + // Gar nicht erst rendern statt per CSS verbergen: Unter 768px blendet die + // CSS die Listen-Kinder wieder ein, eine Klasse bisse sich damit. + if (useContext(VersteckteNav).has(to)) return null; return ( @@ -64,6 +73,9 @@ function Sidebar() { // Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler // hier darf die Leiste nicht stoeren, deshalb still abfangen. const [mealie, setMealie] = useState(false); + // Vorgabe des Administrators plus eigene Auswahl, vom Server schon + // zusammengerechnet. Ein Fehler darf die Leiste nicht leeren. + const [versteckt, setVersteckt] = useState(() => new Set()); // Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die // Spalteneinstellungen der Tabellen. const [listenOffen, setListenOffen] = useState( @@ -71,7 +83,11 @@ function Sidebar() { ); // Steht man auf einer dieser Seiten, ist die Gruppe offen – ohne den // gemerkten Zustand zu ueberschreiben. Sonst saehe man nicht, wo man ist. - const aufEinerListe = LISTEN.some((e) => pathname.startsWith(e.to)); + const sichtbareListen = LISTEN.filter((e) => !versteckt.has(e.to)); + const aufEinerListe = sichtbareListen.some((e) => pathname.startsWith(e.to)); + // Eine Gruppe ohne Kinder waere ein Knopf, der nichts aufklappt; eine + // Ueberschrift ohne Eintraege darunter genauso sinnlos. + const zeigtGruppe = (gruppe) => gruppenPfade(gruppe).some((t) => !versteckt.has(t)); function klappeListen() { setListenOffen((alt) => { @@ -93,6 +109,20 @@ function Sidebar() { return () => window.removeEventListener("dashboards-geaendert", laden); }, []); + // Dasselbe Spiel fuer die ausgeblendeten Eintraege: Einstellungen und + // Kontoseite loesen beim Speichern ein Ereignis aus, statt die Seite neu zu + // laden. + useEffect(() => { + function laden() { + api.navigation() + .then((a) => setVersteckt(new Set(a.versteckt || []))) + .catch(() => {}); + } + laden(); + window.addEventListener("navigation-geaendert", laden); + return () => window.removeEventListener("navigation-geaendert", laden); + }, []); + useEffect(() => { api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {}); }, []); @@ -107,6 +137,7 @@ function Sidebar() {
+ +
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */} diff --git a/web/src/api.js b/web/src/api.js index 94da712..3b721c2 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -437,6 +437,14 @@ export const api = { request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }), // Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/); // nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist. + // Seitenleiste: Vorgabe des Admins, eigene Auswahl und die fertige + // Vereinigung – alles in einem Zug, das Zusammenrechnen macht der Server. + navigation: () => request("/navigation"), + setzeEigeneNavigation: (versteckt) => + request("/navigation", { method: "PUT", body: { versteckt } }), + setzeNavigationsVorgabe: (versteckt) => + request("/navigation/vorgabe", { method: "PUT", body: { versteckt } }), + etikettenCsv: (id, name) => downloadFile( `/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`, diff --git a/web/src/components/NavigationAuswahl.jsx b/web/src/components/NavigationAuswahl.jsx new file mode 100644 index 0000000..c2b82f6 --- /dev/null +++ b/web/src/components/NavigationAuswahl.jsx @@ -0,0 +1,48 @@ +import { NAV_AUSBLENDBAR } from "../nav"; +import { useAuth } from "../auth"; + +/** + * Häkchenliste für die Seitenleiste – an zwei Stellen benutzt: in den + * Einstellungen für die Vorgabe des Administrators, auf der Kontoseite für die + * eigene Auswahl. + * + * Angehakt heisst **sichtbar**. Das ist herum, wie man es erwartet, auch wenn + * der Server das Gegenteil speichert (die Liste der versteckten Pfade). + * + * `gesperrt` sind Einträge, die der Administrator schon ausgeblendet hat: Sie + * stehen abgehakt und unveränderlich da, denn die eigene Auswahl addiert nur – + * sie nimmt nichts weg. + */ +export default function NavigationAuswahl({ versteckt, gesperrt = [], onChange }) { + const { isAdmin } = useAuth(); + const aus = new Set(versteckt); + const fest = new Set(gesperrt); + // Stammdaten und System sieht ohnehin nur ein Administrator. + const eintraege = NAV_AUSBLENDBAR.filter( + (e) => isAdmin || !["stammdaten", "system"].includes(e.gruppe), + ); + + function umschalten(to, sichtbar) { + const neu = new Set(aus); + if (sichtbar) neu.delete(to); + else neu.add(to); + onChange([...neu]); + } + + return ( +
+ {eintraege.map((e) => { + const blockiert = fest.has(e.to); + return ( + + ); + })} +
+ ); +} diff --git a/web/src/nav.js b/web/src/nav.js new file mode 100644 index 0000000..23369fb --- /dev/null +++ b/web/src/nav.js @@ -0,0 +1,42 @@ +/** + * Die Einträge der Seitenleiste, die sich ausblenden lassen. + * + * Als Schlüssel dient der Routenpfad – der ist ohnehin eindeutig, eine zweite + * Benennung wäre nur eine weitere Stelle, die auseinanderlaufen kann. + * + * **Übersicht und Einstellungen fehlen hier mit Absicht:** Auf „/home" landen + * alle Anmeldewege und das Auffangmuster unbekannter Adressen, und über + * „/settings" schaltet man Ausgeblendetes wieder ein. Wer beides verstecken + * könnte, sperrte sich aus. „Mein Konto" steht in der Fusszeile und ist gar + * kein Eintrag der Leiste. + * + * Versteckt wird nur der Eintrag, nicht die Seite: Lesezeichen, + * Dashboard-Karten und Lagerort-QR verweisen weiter auf diese Routen. + */ +export const NAV_AUSBLENDBAR = [ + { to: "/checkin", label: "Einlagern" }, + { to: "/checkout", label: "Auslagern" }, + { to: "/lebensmittel", label: "Lebensmittel", gruppe: "listen" }, + { to: "/gegenstaende", label: "Gegenstände", gruppe: "listen" }, + { to: "/items", label: "Einzelstücke", gruppe: "listen" }, + { to: "/chargen", label: "Chargen", gruppe: "listen" }, + { to: "/rezepte", label: "Rezepte" }, + { to: "/shopping", label: "Einkaufsliste" }, + { to: "/mindestbestaende", label: "Mindestbestände" }, + { to: "/verliehen", label: "Verliehen" }, + { to: "/history", label: "Verlauf" }, + { to: "/categories", label: "Kategorien", gruppe: "stammdaten" }, + { to: "/groups", label: "Gruppen", gruppe: "stammdaten" }, + { to: "/locations", label: "Lagerorte", gruppe: "stammdaten" }, + { to: "/shops", label: "Shops", gruppe: "stammdaten" }, + { to: "/units", label: "Einheiten", gruppe: "stammdaten" }, + { to: "/package-types", label: "Gebinde", gruppe: "stammdaten" }, + { to: "/masse", label: "Maße", gruppe: "stammdaten" }, + { to: "/users", label: "Benutzer", gruppe: "system" }, + { to: "/transfer", label: "Import / Export", gruppe: "system" }, +]; + +/** Pfade einer Gruppe – für die Abschnittsüberschriften der Leiste. */ +export function gruppenPfade(gruppe) { + return NAV_AUSBLENDBAR.filter((e) => e.gruppe === gruppe).map((e) => e.to); +} diff --git a/web/src/pages/Konto.jsx b/web/src/pages/Konto.jsx index dbdfaf2..412d2e5 100644 --- a/web/src/pages/Konto.jsx +++ b/web/src/pages/Konto.jsx @@ -4,6 +4,7 @@ import { useAuth } from "../auth"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; +import NavigationAuswahl from "../components/NavigationAuswahl"; // Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`). const SSO_FEHLERTEXTE = { @@ -209,6 +210,60 @@ export default function Konto() { )}
+ + + + ); +} + +/** + * Die eigene Auswahl: was ICH in der Leiste nicht brauche. + * + * Was der Administrator ausgeblendet hat, steht gesperrt da – die eigene + * Auswahl addiert nur, sie nimmt nichts weg. + */ +function SeitenleisteKarte() { + const toast = useToast(); + const [daten, setDaten] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.navigation().then(setDaten).catch((e) => setError(e.message)); + }, []); + + async function speichern(neu) { + setDaten((alt) => ({ ...alt, eigen: neu })); + setBusy(true); + setError(null); + try { + await api.setzeEigeneNavigation(neu); + window.dispatchEvent(new Event("navigation-geaendert")); + toast("Seitenleiste gespeichert."); + } catch (err) { setError(err.message); } finally { setBusy(false); } + } + + return ( +
+

Meine Seitenleiste

+

+ Abgewählte Einträge verschwinden nur bei dir. Die Seiten bleiben + erreichbar – Lesezeichen und Links funktionieren weiter. +

+ {error &&
{error}
} + {daten == null + ?

Wird geladen…

+ : <> +
+ +
+ {(daten.vorgabe || []).length > 0 && ( +

+ Ausgegraute Einträge hat der Administrator für alle ausgeblendet. +

+ )} + }
); } diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index f7a2861..bb3ca78 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import NavigationAuswahl from "../components/NavigationAuswahl"; import { useToast } from "../toast"; import BrandingUpload from "../components/BrandingUpload"; import DangerAction from "../components/DangerAction"; @@ -192,6 +193,7 @@ export default function Settings() { + @@ -199,6 +201,53 @@ export default function Settings() { ); } +/** + * Welche Einträge der Seitenleiste es im Haushalt überhaupt gibt. + * + * Was hier abgewählt ist, sieht niemand – auch kein Administrator. Jeder kann + * in seinem Konto zusätzlich eigene Einträge ausblenden, aber nichts davon + * wieder einblenden. + */ +function SeitenleisteCard() { + const toast = useToast(); + const [versteckt, setVersteckt] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.navigation().then((a) => setVersteckt(a.vorgabe || [])).catch((e) => setError(e.message)); + }, []); + + async function speichern(neu) { + setVersteckt(neu); + setBusy(true); + setError(null); + try { + await api.setzeNavigationsVorgabe(neu); + // Die Leiste horcht darauf, statt dass man neu laden muss. + window.dispatchEvent(new Event("navigation-geaendert")); + toast("Seitenleiste gespeichert."); + } catch (err) { setError(err.message); } finally { setBusy(false); } + } + + return ( +
+

Seitenleiste

+

+ Was hier abgewählt ist, verschwindet für alle aus der Leiste. Die Seiten + selbst bleiben erreichbar – Lesezeichen und Links funktionieren weiter. + Übersicht und Einstellungen lassen sich nicht ausblenden. +

+ {error &&
{error}
} + {versteckt == null + ?

Wird geladen…

+ :
+ +
} +
+ ); +} + /** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */ /** * Verbindung zu einer selbst gehosteten Mealie-Instanz. diff --git a/web/src/styles.css b/web/src/styles.css index 1e5a749..886641b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1264,3 +1264,11 @@ tr.row-active { background: var(--surface-2); } /* "Foto machen"/"Galerie" sind