Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.
useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.
Damit wandern in die URL:
- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
(?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.
Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.
Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.
Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
14 KiB
React
295 lines
14 KiB
React
import { useEffect, useState } from "react";
|
||
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||
import { api } from "./api";
|
||
import { useAuth } from "./auth";
|
||
import Icon from "./components/Icon";
|
||
import ErrorBoundary from "./components/ErrorBoundary";
|
||
import BrandMark from "./components/BrandMark";
|
||
import Login from "./pages/Login";
|
||
import OidcCallback from "./pages/OidcCallback";
|
||
import SsoNeu from "./pages/SsoNeu";
|
||
import Dashboard from "./pages/Dashboard";
|
||
import Products from "./pages/Products";
|
||
import ItemList from "./pages/ItemList";
|
||
import Charges from "./pages/Charges";
|
||
import MinStock from "./pages/MinStock";
|
||
import ProductForm from "./pages/ProductForm";
|
||
import CheckIn from "./pages/CheckIn";
|
||
import CheckOut from "./pages/CheckOut";
|
||
import Groups from "./pages/Groups";
|
||
import Categories from "./pages/Categories";
|
||
import Shops from "./pages/Shops";
|
||
import ItemResolve from "./pages/ItemResolve";
|
||
import LocationResolve from "./pages/LocationResolve";
|
||
import Locations from "./pages/Locations";
|
||
import PackageTypes from "./pages/PackageTypes";
|
||
import Masse from "./pages/Masse";
|
||
import Units from "./pages/Units";
|
||
import Users from "./pages/Users";
|
||
import ShoppingList from "./pages/ShoppingList";
|
||
import Rezepte from "./pages/Rezepte";
|
||
import History from "./pages/History";
|
||
import Verliehen from "./pages/Verliehen";
|
||
import Transfer from "./pages/Transfer";
|
||
import Settings from "./pages/Settings";
|
||
import Konto from "./pages/Konto";
|
||
|
||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||
|
||
// 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.
|
||
const LISTEN = [
|
||
{ to: "/lebensmittel", icon: "food", label: "Lebensmittel" },
|
||
{ to: "/gegenstaende", icon: "package", label: "Gegenstände" },
|
||
{ to: "/items", icon: "tag", label: "Einzelstücke" },
|
||
{ to: "/chargen", icon: "box", label: "Chargen" },
|
||
];
|
||
const LISTEN_OFFEN_KEY = "nav:listen-offen";
|
||
|
||
function NavItem({ to, icon, label, end }) {
|
||
return (
|
||
<NavLink to={to} end={end} className={navClass}>
|
||
<Icon name={icon} size={17} />
|
||
<span className="label">{label}</span>
|
||
</NavLink>
|
||
);
|
||
}
|
||
|
||
function Sidebar() {
|
||
const { user, isAdmin, logout } = useAuth();
|
||
const navigate = useNavigate();
|
||
const { pathname } = useLocation();
|
||
const [dashboards, setDashboards] = useState([]);
|
||
// 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);
|
||
// Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die
|
||
// Spalteneinstellungen der Tabellen.
|
||
const [listenOffen, setListenOffen] = useState(
|
||
() => localStorage.getItem(LISTEN_OFFEN_KEY) === "1",
|
||
);
|
||
// 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));
|
||
|
||
function klappeListen() {
|
||
setListenOffen((alt) => {
|
||
const neu = !alt;
|
||
localStorage.setItem(LISTEN_OFFEN_KEY, neu ? "1" : "0");
|
||
return neu;
|
||
});
|
||
}
|
||
|
||
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
|
||
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
|
||
// das Ereignis, das die Dashboard-Seite dabei ausloest.
|
||
useEffect(() => {
|
||
function laden() {
|
||
api.dashboards().then((a) => setDashboards(a.dashboards)).catch(() => {});
|
||
}
|
||
laden();
|
||
window.addEventListener("dashboards-geaendert", laden);
|
||
return () => window.removeEventListener("dashboards-geaendert", laden);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {});
|
||
}, []);
|
||
|
||
function handleLogout() {
|
||
logout();
|
||
navigate("/login");
|
||
}
|
||
|
||
return (
|
||
<aside className="sidebar">
|
||
<div className="sidebar-brand">
|
||
<BrandMark />
|
||
</div>
|
||
<nav className="sidebar-nav">
|
||
{/* Ohne `end`: Der Eintrag soll auch bei /home/edit markiert bleiben.
|
||
Bei "/" war `end` noetig, weil sonst JEDER Pfad als Treffer galt. */}
|
||
<NavItem to="/home" icon="dashboard" label="Übersicht" />
|
||
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
|
||
waere sie nur eine Verdopplung von "Übersicht". */}
|
||
{dashboards.length > 1 && dashboards.map((d, index) => (
|
||
<NavLink key={d.id} to={index === 0 ? "/home" : `/d/${d.id}`}
|
||
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
|
||
<span className="label">{d.name}</span>
|
||
</NavLink>
|
||
))}
|
||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||
{/* Auf schmalen Bildschirmen wird die Leiste waagerecht; dort gibt es
|
||
keine Abschnitte (styles.css) und die Eintraege stehen flach
|
||
nebeneinander – zuklappen loest dort kein Platzproblem. */}
|
||
<button type="button" className="nav-link nav-group" onClick={klappeListen}
|
||
aria-expanded={listenOffen || aufEinerListe}>
|
||
<Icon name="chevron" size={17}
|
||
className={listenOffen || aufEinerListe ? "" : "rot-90"} />
|
||
<span className="label">Listen</span>
|
||
</button>
|
||
{LISTEN.map((e) => (
|
||
<NavLink key={e.to} to={e.to}
|
||
className={({ isActive }) => [
|
||
"nav-link nav-child",
|
||
isActive ? "active" : "",
|
||
// Nicht ausgehaengt, nur verborgen: auf schmalen Bildschirmen
|
||
// blendet die CSS sie wieder ein, dort gibt es keine Gruppe.
|
||
listenOffen || aufEinerListe ? "" : "nav-zu",
|
||
].filter(Boolean).join(" ")}>
|
||
<Icon name={e.icon} size={16} />
|
||
<span className="label">{e.label}</span>
|
||
</NavLink>
|
||
))}
|
||
{mealie && <NavItem to="/rezepte" icon="food" label="Rezepte" />}
|
||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||
<NavItem to="/history" icon="history" label="Verlauf" />
|
||
{isAdmin && (
|
||
<>
|
||
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
|
||
gepflegt – deshalb alle zusammen hier, wie in der iOS-App. */}
|
||
<div className="nav-section">Stammdaten</div>
|
||
<NavItem to="/categories" icon="package" label="Kategorien" />
|
||
<NavItem to="/groups" icon="tag" label="Gruppen" />
|
||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||
<NavItem to="/shops" icon="cart" label="Shops" />
|
||
<NavItem to="/units" icon="box" label="Einheiten" />
|
||
<NavItem to="/package-types" icon="package" label="Gebinde" />
|
||
<NavItem to="/masse" icon="split" label="Maße" />
|
||
<div className="nav-section">System</div>
|
||
<NavItem to="/users" icon="users" label="Benutzer" />
|
||
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
||
<NavItem to="/settings" icon="settings" label="Einstellungen" />
|
||
</>
|
||
)}
|
||
</nav>
|
||
<div className="sidebar-foot">
|
||
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
|
||
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
|
||
<span className="name">{user?.username}</span>
|
||
<span className="role">{isAdmin ? "Administrator" : "Benutzer"}</span>
|
||
</NavLink>
|
||
<button className="btn-icon" onClick={handleLogout} title="Abmelden">
|
||
<Icon name="logout" size={18} />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */
|
||
function StartFehler({ text, erneut }) {
|
||
return (
|
||
<div className="center" style={{ padding: 60 }}>
|
||
<div className="alert error" style={{ maxWidth: 520 }}>
|
||
<Icon name="alert" size={16} />
|
||
<div>
|
||
<div>{text}</div>
|
||
<div className="muted small" style={{ marginTop: 4 }}>
|
||
Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet,
|
||
geht es weiter.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
|
||
Erneut versuchen
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Protected({ children, adminOnly = false, wide = false }) {
|
||
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
|
||
const { pathname: pfad } = useLocation();
|
||
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
||
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||
if (!user) return <Navigate to="/login" replace />;
|
||
if (adminOnly && !isAdmin) return <Navigate to="/home" replace />;
|
||
return (
|
||
<div className="app-shell">
|
||
<Sidebar />
|
||
<div className="main">
|
||
{/* `wide` hebt die Lesebreite auf – nur fürs Dashboard-Raster sinnvoll. */}
|
||
<div className={wide ? "content content--wide" : "content"}>
|
||
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
|
||
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
|
||
zurueck, sonst haenge man nach einem Fehler dauerhaft fest.
|
||
|
||
Nur das ERSTE Pfadsegment: Sonst gilt /home/edit als andere Seite
|
||
als /home, der ganze Baum wird neu eingehaengt und laedt beim
|
||
Umschalten in den Bearbeitungsmodus alles noch einmal. */}
|
||
<ErrorBoundary key={pfad.split("/")[1] || "home"} bereich="Diese Seite">
|
||
{children}
|
||
</ErrorBoundary>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
const { user, loading } = useAuth();
|
||
|
||
return (
|
||
<Routes>
|
||
<Route
|
||
path="/login"
|
||
element={loading ? null : user ? <Navigate to="/home" replace /> : <Login />}
|
||
/>
|
||
{/* Die Startseite hat einen Namen, damit in der Adresszeile steht, wo
|
||
man ist; "/" leitet sichtbar dorthin um. Der Bearbeitungsmodus ist
|
||
ein eigener Pfad statt eines Zustands im Speicher: So ueberlebt er
|
||
ein Neuladen, laesst sich verschicken, und die Zurueck-Taste
|
||
verlaesst ihn. Den Modus gibt die Prop vor, damit der Pfad nicht ein
|
||
zweites Mal in der Komponente steht. */}
|
||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||
<Route path="/home" element={<Protected wide><Dashboard /></Protected>} />
|
||
<Route path="/home/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
|
||
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
|
||
<Route path="/d/:id/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
|
||
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||
<Route path="/lebensmittel" element={<Protected wide><Products fixedType="food" /></Protected>} />
|
||
<Route path="/gegenstaende" element={<Protected wide><Products fixedType="object" /></Protected>} />
|
||
<Route path="/products" element={<Protected wide><Products /></Protected>} />
|
||
<Route path="/items" element={<Protected wide><ItemList /></Protected>} />
|
||
<Route path="/chargen" element={<Protected wide><Charges /></Protected>} />
|
||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||
<Route path="/products/:id" element={<Protected wide><ProductForm /></Protected>} />
|
||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
|
||
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
|
||
<Route path="/rezepte/:slug" element={<Protected wide><Rezepte /></Protected>} />
|
||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||
und Auslagern, keine Stammdatenänderung. */}
|
||
<Route path="/verliehen" element={<Protected><Verliehen /></Protected>} />
|
||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
||
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
|
||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
||
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
|
||
<Route path="/masse" element={<Protected adminOnly wide><Masse /></Protected>} />
|
||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||
{/* Rueckweg vom Single-Sign-on: MUSS oeffentlich sein (der Nutzer ist in
|
||
diesem Moment noch nicht angemeldet) und MUSS vor dem Auffangmuster
|
||
unten stehen – sonst schluckt "*" die Weiterleitung samt Token. */}
|
||
<Route path="/auth/callback" element={<OidcCallback />} />
|
||
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
|
||
<Route path="/sso-neu" element={<SsoNeu />} />
|
||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||
</Routes>
|
||
);
|
||
}
|