feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)

Grundgerüst für Certain Finance:
- Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode),
  argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin,
  Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer.
- Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus,
  inkl. SPA-Deeplink-Fallback.
- Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN,
  Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter,
  Benutzer- und Einstellungsseiten.
- Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:27:42 +02:00
co-authored by Claude Opus 4.8
commit 2c795a57c8
59 changed files with 6419 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
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 Users from "./pages/Users";
import Settings from "./pages/Settings";
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="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && (
<>
<div className="nav-section">{t("nav.system")}</div>
<NavItem to="/users" icon="users" label={t("nav.users")} />
</>
)}
</nav>
<div className="sidebar-foot">
<div className="sidebar-user">
<span className="name">{user?.display_name || user?.username}</span>
<span className="role">{isAdmin ? t("common.admin") : t("common.user")}</span>
</div>
<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 />;
// Vom Admin gesetztes Temporär-Passwort: erst ändern, dann weiter.
if (user.must_change_password && pathname !== "/passwort-aendern") {
return <Navigate to="/passwort-aendern" 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="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+127
View File
@@ -0,0 +1,127 @@
// Zentraler API-Client. Alles läuft über /api (im Dev leitet der Vite-Proxy an
// das Backend, in Produktion liefert derselbe FastAPI-Prozess API und SPA aus).
// Die Anmeldung liegt in einem HttpOnly-Cookie – deshalb IMMER credentials:"include"
// und kein Token im JavaScript.
const API_BASE = "/api";
// Wird bei einer abgelaufenen/ungültigen Sitzung (HTTP 401) gerufen, damit die
// Oberfläche nicht „angemeldet" bleibt, während jede Abfrage scheitert.
let beiAbmeldung = null;
export function setUnauthorizedHandler(fn) {
beiAbmeldung = fn;
}
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(path, { method = "GET", body, form, formData, skipAuthHandler = false } = {}) {
const headers = {};
let payload;
if (formData) {
payload = formData; // Content-Type setzt der Browser selbst
} else if (form) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
payload = new URLSearchParams(form).toString();
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
const resp = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: payload,
credentials: "include",
});
// Abgelaufene Sitzung zentral behandeln – aber nicht beim Login selbst und
// nicht bei der stillen Startprüfung (dort ist 401 = „noch nicht angemeldet").
if (resp.status === 401 && path !== "/auth/login" && !skipAuthHandler) {
beiAbmeldung?.();
}
if (resp.status === 204) return null;
let data = null;
const text = await resp.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!resp.ok) {
let detail;
if (data && data.detail) {
detail = Array.isArray(data.detail) ? data.detail.map((d) => d.msg).join(", ") : data.detail;
} else if (resp.status === 502 || resp.status === 503 || resp.status === 504) {
detail = `Server nicht erreichbar (${resp.status}).`;
} else if (typeof data === "string" && data.trim().startsWith("<")) {
detail = `Server-Fehler (${resp.status}).`;
} else if (typeof data === "string" && data) {
detail = data;
} else {
detail = `Fehler ${resp.status}`;
}
throw new ApiError(detail, resp.status);
}
return data;
}
/** Ortszeit als YYYY-MM-DD_HHMM für Dateinamen. */
function zeitstempel() {
const jetzt = new Date();
const zwei = (n) => String(n).padStart(2, "0");
return (
`${jetzt.getFullYear()}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}` +
`_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`
);
}
/** Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten. */
export async function downloadFile(path, filename) {
const resp = await fetch(`${API_BASE}${path}`, { credentials: "include" });
if (!resp.ok) {
throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export { zeitstempel };
export const api = {
// Auth
login: (username, password) =>
request("/auth/login", { method: "POST", form: { username, password } }),
logout: () => request("/auth/logout", { method: "POST" }),
me: (opts) => request("/auth/me", { skipAuthHandler: opts?.silent }),
changePassword: (current_password, new_password) =>
request("/auth/change-password", { method: "POST", body: { current_password, new_password } }),
version: () => request("/version"),
// Benutzer (Admin)
listUsers: () => request("/users"),
createUser: (body) => request("/users", { method: "POST", body }),
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
// Einstellungen (je Benutzer)
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
};
+98
View File
@@ -0,0 +1,98 @@
import { createContext, useContext, useEffect, useState } from "react";
import { api, ApiError, setUnauthorizedHandler } from "./api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Sitzung war da, ist aber abgelaufen – der Login-Bildschirm sagt warum.
const [abgelaufen, setAbgelaufen] = useState(false);
// Start fehlgeschlagen, ABER nicht wegen der Anmeldung (Server/Netz weg).
const [startFehler, setStartFehler] = useState(null);
async function pruefen() {
setStartFehler(null);
try {
// Still prüfen: ein 401 hier heißt „nicht angemeldet", nicht „abgelaufen".
const me = await api.me({ silent: true });
setUser(me);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
setUser(null);
} else if (err instanceof ApiError) {
setStartFehler(err.message);
} else {
setStartFehler("Server nicht erreichbar.");
}
} finally {
setLoading(false);
}
}
useEffect(() => {
pruefen();
}, []);
// Läuft die Sitzung während der Nutzung ab, scheitert ab da JEDE Abfrage.
// Dieser Griff meldet dann sauber ab, statt leere Seiten zu zeigen.
useEffect(() => {
setUnauthorizedHandler(() => {
setUser(null);
setAbgelaufen(true);
});
return () => setUnauthorizedHandler(null);
}, []);
async function login(username, password) {
const me = await api.login(username, password);
setUser(me);
setAbgelaufen(false);
setStartFehler(null);
return me;
}
async function logout() {
try {
await api.logout();
} catch {
/* Auch wenn der Server-Aufruf scheitert: lokal abmelden. */
}
setUser(null);
setAbgelaufen(false);
}
async function erneutVersuchen() {
setLoading(true);
await pruefen();
}
// Nach einer Passwortänderung o. Ä. den Benutzer aktualisieren.
function aktualisieren(next) {
setUser(next);
}
const isAdmin = user?.role === "admin";
return (
<AuthContext.Provider
value={{
user,
isAdmin,
loading,
login,
logout,
aktualisieren,
abgelaufen,
startFehler,
erneutVersuchen,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+4
View File
@@ -0,0 +1,4 @@
// Zentraler Anzeigename der Anwendung (Frontend-Seite).
// Dies ist – neben backend/app/branding.py – die EINZIGE Stelle mit dem
// Produktnamen. Umbenennen = hier (und dort) eine Konstante ändern.
export const APP_NAME = "Certain Finance";
+30
View File
@@ -0,0 +1,30 @@
import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG
// (aufsteigende Trendlinie) im Akzentton – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) {
return (
<div className="brand-mark">
<svg
width={size}
height={size}
viewBox="0 0 32 32"
role="img"
aria-label={APP_NAME}
focusable="false"
>
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
<path
d="M8 21 L14 15 L18 18 L24 10"
fill="none"
stroke="#ffffff"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="24" cy="10" r="2.1" fill="#ffffff" />
</svg>
{showName && <span className="brand-name">{APP_NAME}</span>}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Component } from "react";
import Icon from "./Icon";
/**
* Fängt Fehler beim Rendern ab.
*
* Ohne so eine Grenze hängt React den kompletten Baum aus, sobald irgendwo eine
* Ausnahme fliegt – die Seite wird schlagartig weiß und nur ein Neuladen hilft.
* Man sieht dann NICHT, was passiert ist, und kann es folglich auch nicht
* melden. Genau das war hier das Problem.
*
* Die Grenze zeigt deshalb die Meldung selbst an, nicht nur ein „Ups". Wer sie
* einmal sieht, kann sie weitergeben, und dann ist die Ursache in einer Minute
* gefunden statt in einer Stunde.
*
* `bereich` beschreibt, was gekapselt ist – für die Meldung.
*/
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { fehler: null, info: null };
}
static getDerivedStateFromError(fehler) {
return { fehler };
}
componentDidCatch(fehler, info) {
// React schreibt selbst schon in die Konsole; der Komponentenpfad hilft
// zusätzlich beim Einordnen.
console.error("Fehler beim Rendern:", fehler, info?.componentStack);
this.setState({ info });
}
render() {
const { fehler, info } = this.state;
if (!fehler) return this.props.children;
return (
<div style={{ padding: "var(--sp-4)" }}>
<div className="alert error" style={{ alignItems: "flex-start" }}>
<Icon name="alert" size={16} />
<div style={{ minWidth: 0 }}>
<div className="strong">
{this.props.bereich ? `${this.props.bereich} konnte nicht angezeigt werden.` : "Etwas ist schiefgelaufen."}
</div>
<div className="small" style={{ marginTop: 4, wordBreak: "break-word" }}>
{fehler.message || String(fehler)}
</div>
</div>
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
<button className="btn primary" onClick={() => this.setState({ fehler: null, info: null })}>
Nochmal versuchen
</button>
<button className="btn" onClick={() => window.location.reload()}>
Seite neu laden
</button>
</div>
{/* Aufgeklappt nur auf Wunsch: die Angaben sind für einen Fehlerbericht
gedacht, nicht für den Alltag. */}
<details style={{ marginTop: "var(--sp-3)" }}>
<summary className="muted small">Technische Angaben</summary>
<pre className="muted small" style={{ whiteSpace: "pre-wrap", overflowX: "auto" }}>
{fehler.stack || String(fehler)}
{info?.componentStack || ""}
</pre>
</details>
</div>
);
}
}
+178
View File
@@ -0,0 +1,178 @@
// Schlankes Inline-SVG-Icon-Set (Lucide-Stil). Bewusst KEINE Emojis.
// Verwendung: <Icon name="wallet" /> oder <Icon name="trash" size={16} />
// Weitere Kategorie-/Kontotyp-Icons kommen in späteren Phasen dazu.
const PATHS = {
// --- App-Rahmen ---------------------------------------------------------
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</>
),
users: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
settings: (
<>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</>
),
logout: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</>
),
// --- Rückmeldungen / Aktionen ------------------------------------------
alert: (
<>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4M12 17h.01" />
</>
),
check: <path d="M20 6 9 17l-5-5" />,
close: <path d="M18 6 6 18M6 6l12 12" />,
edit: (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
),
plus: <path d="M5 12h14M12 5v14" />,
trash: (
<>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</>
),
download: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M7 10l5 5 5-5M12 15V3" />
</>
),
"chevron-right": <path d="m9 18 6-6-6-6" />,
"refresh-ccw": (
<>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</>
),
// --- Auth / Passwort ----------------------------------------------------
key: (
<>
<circle cx="7.5" cy="15.5" r="5.5" />
<path d="m21 2-9.6 9.6" />
<path d="m15.5 7.5 3 3L22 7l-3-3" />
</>
),
eye: (
<>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</>
),
"eye-off": (
<>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</>
),
// --- Finance-Grundausstattung ------------------------------------------
wallet: (
<>
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4" />
<path d="M4 6v12c0 1.1.9 2 2 2h14v-4" />
<path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z" />
</>
),
landmark: (
<>
<line x1="3" x2="21" y1="22" y2="22" />
<line x1="6" x2="6" y1="18" y2="11" />
<line x1="10" x2="10" y1="18" y2="11" />
<line x1="14" x2="14" y1="18" y2="11" />
<line x1="18" x2="18" y1="18" y2="11" />
<polygon points="12 2 20 7 4 7" />
</>
),
list: (
<>
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" />
<line x1="3" x2="3.01" y1="12" y2="12" />
<line x1="3" x2="3.01" y1="18" y2="18" />
</>
),
"pie-chart": (
<>
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
<path d="M22 12A10 10 0 0 0 12 2v10z" />
</>
),
"arrow-left-right": (
<>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</>
),
coins: (
<>
<circle cx="8" cy="8" r="6" />
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
<path d="M7 6h1v4" />
<path d="m16.71 13.88.7.71-2.82 2.82" />
</>
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
const path = PATHS[name];
if (!path) return null;
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{path}
</svg>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import Icon from "./components/Icon";
/**
* Rückfragen innerhalb der Anwendung statt über den Browser.
*
* `window.confirm` reißt den Benutzer aus der Oberfläche, zeigt den Hostnamen
* und lässt sich weder gestalten noch beschriften. Der Dialog hier sieht aus wie
* der Rest der Anwendung, kann eine Aktion benennen ("Löschen" statt "OK") und
* gefährliche Schritte rot hervorheben.
*
* Aufruf bleibt so einfach wie vorher:
*
* const confirm = useConfirm();
* if (!(await confirm({ title: "Gruppe löschen?", message: "…" }))) return;
*
* Mit `prompt: true` wird daraus eine Eingabe; aufgeloest wird dann der
* eingetippte Text (oder `null` beim Abbrechen). Auch dafuer gilt der Grund von
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
*
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
*/
const ConfirmContext = createContext(async () => false);
export function ConfirmProvider({ children }) {
const [frage, setFrage] = useState(null);
const [eingabe, setEingabe] = useState("");
// Die Zusage des offenen Dialogs, damit `await confirm(...)` funktioniert.
const antwort = useRef(null);
const confirm = useCallback((optionen) => {
const gestellt = typeof optionen === "string" ? { message: optionen } : optionen;
setFrage(gestellt);
setEingabe(gestellt.defaultValue ?? "");
return new Promise((resolve) => {
antwort.current = resolve;
});
}, []);
function schliessen(ergebnis) {
setFrage(null);
antwort.current?.(ergebnis);
antwort.current = null;
}
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
function bestaetigen() {
if (!frage?.prompt) { schliessen(true); return; }
const text = eingabe.trim();
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
schliessen(text);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{frage && (
<div className="modal-backdrop"
onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head">
<Icon name={frage.prompt ? "edit" : "alert"} size={18}
className={frage.danger ? "icon-danger" : ""} />
<h2>{frage.title || "Bitte bestätigen"}</h2>
</div>
{frage.message && <p className="modal-body">{frage.message}</p>}
{frage.prompt && (
<div className="modal-body">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") bestaetigen();
if (e.key === "Escape") schliessen(null);
}} />
</div>
)}
<div className="btn-pair">
<button type="button" className="btn" autoFocus={!frage.prompt}
onClick={() => schliessen(frage.prompt ? null : false)}>
{frage.cancelLabel || "Abbrechen"}
</button>
<button type="button"
className={frage.danger ? "btn danger-solid" : "btn primary"}
disabled={frage.prompt && !eingabe.trim()}
onClick={bestaetigen}>
{frage.confirmLabel || "Fortfahren"}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
);
}
export function useConfirm() {
return useContext(ConfirmContext);
}
+65
View File
@@ -0,0 +1,65 @@
/* Eigene Stil-Ergänzungen für Certain Finance – bewusst getrennt von der aus
der Vorlage übernommenen styles.css, damit Basis und Ergänzung sauber bleiben. */
.brand-mark {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.brand-name {
font-weight: 650;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--text);
}
/* Beträge: Vorzeichenfarbe. Grün = Einnahme/im Budget, Rot = Ausgabe/über Budget. */
.amount.pos { color: var(--ok); }
.amount.neg { color: var(--danger); }
.amount.muted { color: var(--muted); }
/* Umgerechneter Zweitbetrag (Originalwährung -> Hauptwährung) klein und gedämpft. */
.amount-converted {
color: var(--muted);
font-size: 0.82em;
margin-left: 4px;
}
/* Schlichtes zentriertes Layout für Login / Passwort ändern. */
.auth-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-5);
background: var(--bg);
}
.auth-card {
width: 100%;
max-width: 380px;
}
.auth-card .brand-mark {
justify-content: center;
margin-bottom: var(--sp-4);
}
/* Eingabefeld mit angehängtem Icon-Knopf (z. B. Passwort ein-/ausblenden). */
.input-with-button {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.input-with-button > input {
flex: 1 1 auto;
}
/* Leerer Zustand (z. B. Dashboard ohne Karten). */
.empty-state {
text-align: center;
color: var(--muted);
padding: var(--sp-6) var(--sp-4);
}
.empty-state .icon {
opacity: 0.5;
margin-bottom: var(--sp-3);
}
+33
View File
@@ -0,0 +1,33 @@
// Formatierung von Beträgen, Zahlen und Daten – währungs-/gebietsspezifisch.
// EUR im deutschen Format (1.234,56 €), CHF im Schweizer Format (CHF 1'234.56).
// Datum als TT.MM.JJJJ. Beträge liegen intern als Ganzzahl in Cent/Rappen vor.
export function moneyLocale(currency) {
return currency === "CHF" ? "de-CH" : "de-DE";
}
/** Formatiert einen Cent-Betrag als Währungstext (z. B. 123456 -> "1.234,56 €"). */
export function formatMoney(cents, currency = "EUR") {
const value = (Number(cents) || 0) / 100;
return new Intl.NumberFormat(moneyLocale(currency), {
style: "currency",
currency,
}).format(value);
}
/** Formatiert einen Cent-Betrag ohne Währungssymbol (für Eingaben/Tabellen). */
export function formatAmount(cents, currency = "EUR") {
const value = (Number(cents) || 0) / 100;
return new Intl.NumberFormat(moneyLocale(currency), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
/** ISO-Datum (YYYY-MM-DD oder ISO-Zeitstempel) als TT.MM.JJJJ. */
export function formatDate(iso, locale = "de") {
if (!iso) return "";
const d = new Date(String(iso).length <= 10 ? `${iso}T00:00:00` : iso);
if (Number.isNaN(d.getTime())) return String(iso);
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE").format(d);
}
+20
View File
@@ -0,0 +1,20 @@
// Mehrsprachigkeit (Deutsch Standard, Englisch optional) via react-i18next.
// Die aktive Sprache wird nach dem Login aus der Benutzer-Einstellung "locale"
// gesetzt (siehe settings.jsx); vor dem Login gilt Deutsch.
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import de from "./locales/de.json";
import en from "./locales/en.json";
i18n.use(initReactI18next).init({
resources: {
de: { translation: de },
en: { translation: en },
},
lng: "de",
fallbackLng: "de",
interpolation: { escapeValue: false },
});
export default i18n;
+85
View File
@@ -0,0 +1,85 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"create": "Anlegen",
"edit": "Bearbeiten",
"loading": "Lädt…",
"logout": "Abmelden",
"admin": "Administrator",
"user": "Benutzer",
"close": "Schließen",
"retry": "Erneut versuchen",
"actions": "Aktionen"
},
"nav": {
"overview": "Übersicht",
"accounts": "Konten",
"categories": "Kategorien",
"transactions": "Buchungen",
"budget": "Budget",
"import": "Import",
"system": "System",
"users": "Benutzer",
"settings": "Einstellungen"
},
"login": {
"subtitle": "Bitte anmelden",
"username": "Benutzername",
"password": "Passwort",
"submit": "Anmelden",
"error": "Benutzername oder Passwort falsch",
"expired": "Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
},
"changepw": {
"title": "Passwort ändern",
"forcedHint": "Bitte lege ein eigenes Passwort fest, bevor du fortfährst.",
"current": "Aktuelles Passwort",
"new": "Neues Passwort",
"repeat": "Neues Passwort wiederholen",
"submit": "Passwort speichern",
"mismatch": "Die neuen Passwörter stimmen nicht überein.",
"saved": "Passwort geändert."
},
"users": {
"title": "Benutzer",
"new": "Neuer Benutzer",
"username": "Benutzername",
"displayName": "Anzeigename",
"password": "Temporär-Passwort",
"passwordHint": "Der Benutzer wird beim ersten Login zum Ändern aufgefordert.",
"role": "Rolle",
"status": "Status",
"active": "Aktiv",
"inactive": "Deaktiviert",
"resetPassword": "Passwort zurücksetzen",
"deleteConfirm": "Diesen Benutzer wirklich löschen?",
"created": "Benutzer angelegt.",
"you": "(du)"
},
"settings": {
"title": "Einstellungen",
"baseCurrency": "Hauptwährung",
"baseCurrencyHint": "Währung für Budget-Summen und Dashboard-Gesamtwerte.",
"locale": "Sprache",
"theme": "Darstellung",
"themeSystem": "Automatisch (System)",
"themeLight": "Hell",
"themeDark": "Dunkel",
"budgetStartDay": "Budget-Monat beginnt am",
"budgetStartDayHint": "Tag, an dem dein Budget-Monat startet (z. B. 25.).",
"dateFormat": "Datumsformat",
"saved": "Gespeichert.",
"languageDe": "Deutsch",
"languageEn": "Englisch"
},
"dashboard": {
"title": "Übersicht",
"empty": "Noch keine Karten. Das Dashboard wird in einer späteren Phase ausgebaut."
},
"server": {
"unreachable": "Server nicht erreichbar.",
"sessionKept": "Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet, geht es weiter."
}
}
+85
View File
@@ -0,0 +1,85 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"loading": "Loading…",
"logout": "Log out",
"admin": "Administrator",
"user": "User",
"close": "Close",
"retry": "Try again",
"actions": "Actions"
},
"nav": {
"overview": "Overview",
"accounts": "Accounts",
"categories": "Categories",
"transactions": "Transactions",
"budget": "Budget",
"import": "Import",
"system": "System",
"users": "Users",
"settings": "Settings"
},
"login": {
"subtitle": "Please sign in",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"error": "Wrong username or password",
"expired": "Your session has expired. Please sign in again."
},
"changepw": {
"title": "Change password",
"forcedHint": "Please set your own password before continuing.",
"current": "Current password",
"new": "New password",
"repeat": "Repeat new password",
"submit": "Save password",
"mismatch": "The new passwords do not match.",
"saved": "Password changed."
},
"users": {
"title": "Users",
"new": "New user",
"username": "Username",
"displayName": "Display name",
"password": "Temporary password",
"passwordHint": "The user will be asked to change it on first login.",
"role": "Role",
"status": "Status",
"active": "Active",
"inactive": "Disabled",
"resetPassword": "Reset password",
"deleteConfirm": "Really delete this user?",
"created": "User created.",
"you": "(you)"
},
"settings": {
"title": "Settings",
"baseCurrency": "Base currency",
"baseCurrencyHint": "Currency for budget totals and dashboard aggregates.",
"locale": "Language",
"theme": "Appearance",
"themeSystem": "Automatic (system)",
"themeLight": "Light",
"themeDark": "Dark",
"budgetStartDay": "Budget month starts on",
"budgetStartDayHint": "Day your budget month starts (e.g. the 25th).",
"dateFormat": "Date format",
"saved": "Saved.",
"languageDe": "German",
"languageEn": "English"
},
"dashboard": {
"title": "Overview",
"empty": "No cards yet. The dashboard will be built out in a later phase."
},
"server": {
"unreachable": "Server unreachable.",
"sessionKept": "Your session is still valid – once the server responds again, it will continue."
}
}
+30
View File
@@ -0,0 +1,30 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./i18n";
import App from "./App";
import { AuthProvider } from "./auth";
import { SettingsProvider } from "./settings";
import { ConfirmProvider } from "./confirm";
import { ToastProvider } from "./toast";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles.css";
import "./finance.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<SettingsProvider>
<ConfirmProvider>
<ToastProvider>
<App />
</ToastProvider>
</ConfirmProvider>
</SettingsProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>
);
+85
View File
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../auth";
import { api, ApiError } from "../api";
import { useToast } from "../toast";
import BrandMark from "../components/BrandMark";
import Icon from "../components/Icon";
export default function ChangePassword() {
const { user, aktualisieren } = useAuth();
const { t } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
if (!user) return <Navigate to="/login" replace />;
const forced = user.must_change_password;
async function onSubmit(e) {
e.preventDefault();
setFehler(null);
if (next !== repeat) {
setFehler(t("changepw.mismatch"));
return;
}
setBusy(true);
try {
const updated = await api.changePassword(current, next);
aktualisieren(updated);
toast(t("changepw.saved"));
navigate("/");
} catch (err) {
setFehler(err instanceof ApiError ? err.message : t("server.unreachable"));
} finally {
setBusy(false);
}
}
return (
<div className="auth-screen">
<form className="card auth-card" onSubmit={onSubmit}>
<BrandMark size={40} />
<h1 style={{ textAlign: "center", marginTop: 0 }}>{t("changepw.title")}</h1>
{forced && (
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="key" size={16} />
<span>{t("changepw.forcedHint")}</span>
</div>
)}
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{fehler}</span>
</div>
)}
<label>
<span>{t("changepw.current")}</span>
<input type="password" autoComplete="current-password" value={current}
onChange={(e) => setCurrent(e.target.value)} required />
</label>
<label>
<span>{t("changepw.new")}</span>
<input type="password" autoComplete="new-password" value={next}
onChange={(e) => setNext(e.target.value)} required />
</label>
<label>
<span>{t("changepw.repeat")}</span>
<input type="password" autoComplete="new-password" value={repeat}
onChange={(e) => setRepeat(e.target.value)} required />
</label>
<button className="btn primary" type="submit" disabled={busy}
style={{ width: "100%", marginTop: "var(--sp-2)" }}>
{busy ? t("common.loading") : t("changepw.submit")}
</button>
</form>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useTranslation } from "react-i18next";
import Icon from "../components/Icon";
// Platzhalter: das konfigurierbare Karten-Dashboard wird in Phase 3 aufgebaut.
export default function Dashboard() {
const { t } = useTranslation();
return (
<div>
<h1>{t("dashboard.title")}</h1>
<div className="card">
<div className="empty-state">
<Icon name="pie-chart" size={40} />
<p>{t("dashboard.empty")}</p>
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../auth";
import { ApiError } from "../api";
import BrandMark from "../components/BrandMark";
import Icon from "../components/Icon";
export default function Login() {
const { login, abgelaufen } = useAuth();
const { t } = useTranslation();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [zeigePw, setZeigePw] = useState(false);
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setFehler(null);
setBusy(true);
try {
await login(username, password);
navigate("/");
} catch (err) {
if (err instanceof ApiError && err.status === 401) setFehler(t("login.error"));
else setFehler(err.message || t("server.unreachable"));
} finally {
setBusy(false);
}
}
return (
<div className="auth-screen">
<form className="card auth-card" onSubmit={onSubmit}>
<BrandMark size={40} />
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>{t("login.subtitle")}</p>
{abgelaufen && (
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{t("login.expired")}</span>
</div>
)}
{fehler && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{fehler}</span>
</div>
)}
<label className="field">
<span>{t("login.username")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus autoComplete="username" value={username}
onChange={(e) => setUsername(e.target.value)} required />
</label>
<label className="field">
<span>{t("login.password")}</span>
<div className="input-with-button">
<input type={zeigePw ? "text" : "password"} autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} required />
<button type="button" className="btn-icon" onClick={() => setZeigePw((v) => !v)}
title={zeigePw ? t("common.close") : ""} tabIndex={-1}>
<Icon name={zeigePw ? "eye-off" : "eye"} size={17} />
</button>
</div>
</label>
<button className="btn primary" type="submit" disabled={busy}
style={{ width: "100%", marginTop: "var(--sp-3)" }}>
{busy ? t("common.loading") : t("login.submit")}
</button>
</form>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useToast } from "../toast";
export default function Settings() {
const { t } = useTranslation();
const s = useSettings();
const toast = useToast();
async function set(key, value) {
try {
await s.update(key, value);
toast(t("settings.saved"));
} catch (err) {
toast(err.message, "error");
}
}
return (
<div>
<h1>{t("settings.title")}</h1>
<div className="card" style={{ maxWidth: 520 }}>
<label>
<span>{t("settings.baseCurrency")}</span>
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
<option value="EUR">Euro (EUR)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p>
<label>
<span>{t("settings.locale")}</span>
<select value={s.locale} onChange={(e) => set("locale", e.target.value)}>
<option value="de">{t("settings.languageDe")}</option>
<option value="en">{t("settings.languageEn")}</option>
</select>
</label>
<label>
<span>{t("settings.theme")}</span>
<select value={s.theme} onChange={(e) => set("theme", e.target.value)}>
<option value="system">{t("settings.themeSystem")}</option>
<option value="light">{t("settings.themeLight")}</option>
<option value="dark">{t("settings.themeDark")}</option>
</select>
</label>
<label>
<span>{t("settings.budgetStartDay")}</span>
<input
type="number"
min="1"
max="31"
value={s.budget_start_day}
onChange={(e) => {
const day = Math.max(1, Math.min(31, Number(e.target.value) || 1));
set("budget_start_day", String(day));
}}
/>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
function randomPassword(len = 12) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
const arr = new Uint32Array(len);
crypto.getRandomValues(arr);
return Array.from(arr, (n) => chars[n % chars.length]).join("");
}
export default function Users() {
const { t } = useTranslation();
const { user: self } = useAuth();
const confirm = useConfirm();
const toast = useToast();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ username: "", display_name: "", password: "", role: "user" });
const [fehler, setFehler] = useState(null);
const [busy, setBusy] = useState(false);
async function laden() {
try {
setUsers(await api.listUsers());
} catch (err) {
setFehler(err.message);
}
}
useEffect(() => {
laden();
}, []);
async function anlegen(e) {
e.preventDefault();
setFehler(null);
setBusy(true);
try {
await api.createUser({
username: form.username.trim(),
display_name: form.display_name.trim() || null,
password: form.password,
role: form.role,
});
toast(t("users.created"));
setForm({ username: "", display_name: "", password: "", role: "user" });
laden();
} catch (err) {
setFehler(err instanceof ApiError ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function passwortZuruecksetzen(u) {
const neu = await confirm({
title: t("users.resetPassword"),
message: u.username,
prompt: true,
defaultValue: randomPassword(),
confirmLabel: t("common.save"),
});
if (!neu) return;
try {
await api.updateUser(u.id, { password: neu });
toast(t("settings.saved"));
} catch (err) {
toast(err.message, "error");
}
}
async function aktivUmschalten(u) {
try {
await api.updateUser(u.id, { is_active: !u.is_active });
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function loeschen(u) {
if (!(await confirm({ title: t("common.delete"), message: t("users.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return;
}
try {
await api.deleteUser(u.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
return (
<div>
<h1>{t("users.title")}</h1>
{fehler && (
<div className="alert error">
<Icon name="alert" size={16} />
<span>{fehler}</span>
</div>
)}
<div className="card" style={{ marginBottom: "var(--sp-4)" }}>
<h2>{t("users.new")}</h2>
<form onSubmit={anlegen}>
<div className="row">
<label className="grow">
<span>{t("users.username")}</span>
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
</label>
<label className="grow">
<span>{t("users.displayName")}</span>
<input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} />
</label>
</div>
<div className="row">
<label className="grow">
<span>{t("users.password")}</span>
<div className="input-with-button">
<input value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<button type="button" className="btn sm" onClick={() => setForm({ ...form, password: randomPassword() })}>
<Icon name="refresh-ccw" size={15} />
</button>
</div>
</label>
<label style={{ minWidth: 160 }}>
<span>{t("users.role")}</span>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="user">{t("common.user")}</option>
<option value="admin">{t("common.admin")}</option>
</select>
</label>
</div>
<p className="muted small">{t("users.passwordHint")}</p>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? t("common.loading") : t("common.create")}
</button>
</form>
</div>
<div className="card">
<table className="table">
<thead>
<tr>
<th>{t("users.username")}</th>
<th>{t("users.role")}</th>
<th>{t("users.status")}</th>
<th style={{ textAlign: "right" }}>{t("common.actions")}</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<strong>{u.display_name || u.username}</strong>
{u.display_name && <span className="muted small"> · {u.username}</span>}
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
</td>
<td>
<span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>
</td>
<td>
<span className={`badge ${u.is_active ? "ok" : ""}`}>
{u.is_active ? t("users.active") : t("users.inactive")}
</span>
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
<Icon name="trash" size={15} />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import i18n from "./i18n";
import { api } from "./api";
import { useAuth } from "./auth";
import { applyTheme } from "./theme";
import { formatMoney as fmtMoney, formatDate as fmtDate } from "./format";
const DEFAULTS = {
base_currency: "EUR",
locale: "de",
theme: "system",
date_format: "de",
budget_start_day: "1",
};
const SettingsContext = createContext({
...DEFAULTS,
formatMoney: (cents, currency) => fmtMoney(cents, currency),
formatDate: (iso) => fmtDate(iso, "de"),
update: async () => {},
reload: async () => {},
});
export function SettingsProvider({ children }) {
const { user } = useAuth();
const [values, setValues] = useState(DEFAULTS);
// Sprache und Thema anwenden, sobald sich die Werte ändern.
useEffect(() => {
if (i18n.language !== values.locale) i18n.changeLanguage(values.locale);
}, [values.locale]);
useEffect(() => {
applyTheme(values.theme);
}, [values.theme]);
const reload = useCallback(async () => {
try {
const rows = await api.listSettings();
const map = { ...DEFAULTS };
for (const r of rows) {
if (r.value != null) map[r.key] = r.value;
}
setValues(map);
} catch {
/* Einstellungen sind optional – Standardwerte bleiben bestehen. */
}
}, []);
useEffect(() => {
if (user) reload();
else setValues(DEFAULTS); // nach Logout auf Standard zurück
}, [user, reload]);
// Eine Einstellung setzen: erst lokal (sofort sichtbar), dann speichern.
const update = useCallback(async (key, value) => {
setValues((prev) => ({ ...prev, [key]: value }));
await api.setSetting(key, value);
}, []);
const value = {
...values,
baseCurrency: values.base_currency,
formatMoney: (cents, currency) => fmtMoney(cents, currency ?? values.base_currency),
formatDate: (iso) => fmtDate(iso, values.locale),
update,
reload,
};
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}
export function useSettings() {
return useContext(SettingsContext);
}
+1142
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
// Hell/Dunkel-Umschaltung. "system" folgt der Betriebssystem-Einstellung
// (kein data-theme gesetzt -> die @media-Regel in styles.css greift), "light"/
// "dark" erzwingen das jeweilige Thema über das data-theme-Attribut am <html>.
export function applyTheme(theme) {
const root = document.documentElement;
if (theme === "light" || theme === "dark") {
root.dataset.theme = theme;
} else {
delete root.dataset.theme;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import Icon from "./components/Icon";
/**
* Kurze Rückmeldungen als Overlay am unteren Rand.
*
* Vorher standen sie als Hinweisleiste mitten in der Seite – dadurch sprang
* beim Speichern das ganze Layout ein Stück nach unten. Ein Overlay liegt über
* dem Inhalt und verschiebt nichts.
*
* Bewusst nur für *Erfolgsmeldungen*: Fehler bleiben als Leiste an ihrer Stelle
* stehen. Eine Fehlermeldung, die nach fünf Sekunden verschwindet, ist genau
* dann weg, wenn man sie noch braucht.
*
* const toast = useToast();
* toast("Gespeichert.");
*/
const ToastContext = createContext(() => {});
const DAUER = 5000;
export function ToastProvider({ children }) {
const [meldungen, setMeldungen] = useState([]);
const uhren = useRef(new Map());
const entfernen = useCallback((id) => {
setMeldungen((alt) => alt.filter((m) => m.id !== id));
const uhr = uhren.current.get(id);
if (uhr) {
clearTimeout(uhr);
uhren.current.delete(id);
}
}, []);
const zeigen = useCallback((text, art = "ok") => {
if (!text) return;
const id = `${Date.now()}-${Math.random()}`;
setMeldungen((alt) => [...alt, { id, text, art }]);
uhren.current.set(id, setTimeout(() => entfernen(id), DAUER));
}, [entfernen]);
// Beim Verlassen der Seite laufende Uhren abräumen.
useEffect(() => {
const laufend = uhren.current;
return () => laufend.forEach((uhr) => clearTimeout(uhr));
}, []);
return (
<ToastContext.Provider value={zeigen}>
{children}
{/* aria-live: Bildschirmleser sagen die Meldung an, ohne den Fokus zu stehlen. */}
<div className="toasts" role="status" aria-live="polite">
{meldungen.map((m) => (
<div key={m.id} className={`toast ${m.art}`}>
<Icon name={m.art === "ok" ? "check" : "alert"} size={16} />
<span className="toast-text">{m.text}</span>
<button className="btn-icon" onClick={() => entfernen(m.id)} title="Ausblenden">
<Icon name="close" size={15} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
return useContext(ToastContext);
}