Bisher musste man in der Gesamtliste nach Luecken suchen. Der Endpunkt kennt jetzt ?uncategorized=true; Uebertraege bleiben aussen vor, denn sie ordnen kein Geld ein, sie verschieben es nur. Die Seite zeigt die offenen Buchungen aelteste zuerst; nach dem Zuordnen verschwindet die Zeile, sodass der Stapel sichtbar kleiner wird. Auf Wunsch wird der Zweck gleich als Schlagwort gemerkt, damit die naechste gleiche Buchung schon beim Import zugeordnet wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.8 KiB
React
150 lines
5.8 KiB
React
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
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 ChangePassword from "./pages/ChangePassword";
|
||
import Dashboard from "./pages/Dashboard";
|
||
import Accounts from "./pages/Accounts";
|
||
import Categories from "./pages/Categories";
|
||
import Transactions from "./pages/Transactions";
|
||
import Budget from "./pages/Budget";
|
||
import Import from "./pages/Import";
|
||
import Offen from "./pages/Offen";
|
||
import Users from "./pages/Users";
|
||
import Settings from "./pages/Settings";
|
||
import TwoFactor from "./pages/TwoFactor";
|
||
import Account from "./pages/Account";
|
||
|
||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||
|
||
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 { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
|
||
async function handleLogout() {
|
||
await logout();
|
||
navigate("/login");
|
||
}
|
||
|
||
return (
|
||
<aside className="sidebar">
|
||
<div className="sidebar-brand">
|
||
<BrandMark />
|
||
</div>
|
||
<nav className="sidebar-nav">
|
||
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||
<NavItem to="/offen" icon="inbox" label={t("nav.open")} />
|
||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
||
<div className="nav-section">{t("nav.system")}</div>
|
||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||
{isAdmin && <NavItem to="/users" icon="users" label={t("nav.users")} />}
|
||
</nav>
|
||
<div className="sidebar-foot">
|
||
<NavLink to="/konto" className="sidebar-user" title={t("account.title")}>
|
||
<span className="name">{user?.display_name || user?.username}</span>
|
||
<span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span>
|
||
</NavLink>
|
||
<button className="btn-icon" onClick={handleLogout} title={t("common.logout")}>
|
||
<Icon name="logout" size={18} />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */
|
||
function StartFehler({ text, erneut }) {
|
||
const { t } = useTranslation();
|
||
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 }}>
|
||
{t("server.sessionKept")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
|
||
{t("common.retry")}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Protected({ children, adminOnly = false, wide = false }) {
|
||
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
|
||
const { pathname } = useLocation();
|
||
const { t } = useTranslation();
|
||
|
||
if (loading) return <div className="center muted" style={{ padding: 60 }}>{t("common.loading")}</div>;
|
||
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||
if (!user) return <Navigate to="/login" replace />;
|
||
// Offene Pflichten der Reihe nach – dieselbe Reihenfolge erzwingt der Server
|
||
// (backend/app/deps.py, enforce_account_state), sonst liefe man in 403er.
|
||
if (user.must_change_password && pathname !== "/passwort-aendern") {
|
||
return <Navigate to="/passwort-aendern" replace />;
|
||
}
|
||
if (user.mfa_setup_required && pathname !== "/zwei-faktor") {
|
||
return <Navigate to="/zwei-faktor" replace />;
|
||
}
|
||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<Sidebar />
|
||
<div className="main">
|
||
<div className={wide ? "content content--wide" : "content"}>
|
||
<ErrorBoundary key={pathname} 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="/" replace /> : <Login />}
|
||
/>
|
||
<Route path="/passwort-aendern" element={<ChangePassword />} />
|
||
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
||
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||
<Route path="/offen" element={<Protected><Offen /></Protected>} />
|
||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||
<Route path="/konto" element={<Protected><Account /></Protected>} />
|
||
<Route path="/zwei-faktor" element={<Protected><TwoFactor /></Protected>} />
|
||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
);
|
||
}
|