In den Einstellungen legt der Administrator fest, was es im Haushalt ueberhaupt gibt; in "Mein Konto" blendet jeder zusaetzlich aus, was er selbst nicht braucht. Angehakt heisst sichtbar - auch wenn der Server das Gegenteil speichert. Was der Administrator ausgeblendet hat, steht beim Konto abgehakt und gesperrt da: Die eigene Auswahl addiert nur. Uebersicht und Einstellungen fehlen in der Liste mit Absicht - dort landen alle Anmeldewege bzw. es ist der einzige Weg zurueck. Wer beides verstecken koennte, sperrte sich aus. Ausgeblendet wird ueber einen Kontext statt per CSS-Klasse: Unter 768px blendet die CSS die Listen-Kinder wieder ein, eine Klasse bisse sich damit. Faellt dabei die ganze Listen-Gruppe weg, verschwindet auch ihr Aufklapp-Knopf, und die Ueberschrift "Stammdaten" steht nur noch, solange etwas darunter ist. Die Leiste zieht sofort nach, ueber dasselbe Ereignis-Muster wie bei den Dashboards - kein Neuladen noetig. Versteckt wird nur der Eintrag, nicht die Seite: /shopping bleibt per Lesezeichen und ueber die Dashboard-Karten erreichbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
24 KiB
React
612 lines
24 KiB
React
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";
|
||
import { DATE_FORMAT_KEY, useSettings } from "../settings";
|
||
import { DATE_FORMATS, formatDate } from "../units";
|
||
import { applyFavicon } from "../branding";
|
||
|
||
const EXPIRY_KEY = "expiry_warning_days";
|
||
// Haltbarkeit nach dem Anbrechen. Der Wert ganz oben in der Kette: Artikel >
|
||
// Kategorie (naechster Vorfahr) > hier. Weil er die letzte Station ist, darf er
|
||
// nicht leer sein - sonst haette eine angebrochene Packung kein Verbrauchsdatum.
|
||
const OPEN_DAYS_KEY = "open_days";
|
||
const RECEIPT_KEY = "receipt_match_threshold";
|
||
const SESSION_KEY = "session_days";
|
||
|
||
// Feste Stufen statt eines Zahlenfeldes: "wie lange bleibe ich angemeldet" ist
|
||
// eine Entscheidung, keine Rechenaufgabe. 0 heisst unbegrenzt.
|
||
const SITZUNGSDAUERN = [
|
||
{ value: "7", label: "7 Tage" },
|
||
{ value: "30", label: "30 Tage" },
|
||
{ value: "90", label: "3 Monate" },
|
||
{ value: "180", label: "6 Monate" },
|
||
{ value: "365", label: "1 Jahr" },
|
||
{ value: "0", label: "Unbegrenzt – nie abmelden" },
|
||
];
|
||
|
||
export default function Settings() {
|
||
const { reload } = useSettings();
|
||
const toast = useToast();
|
||
const [days, setDays] = useState("");
|
||
const [openDays, setOpenDays] = useState("");
|
||
const [threshold, setThreshold] = useState("");
|
||
const [dateFormat, setDateFormat] = useState("de");
|
||
const [sessionDays, setSessionDays] = useState("30");
|
||
const [error, setError] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function load() {
|
||
try {
|
||
const settings = await api.listSettings();
|
||
setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
|
||
setOpenDays(settings.find((s) => s.key === OPEN_DAYS_KEY)?.value ?? "3");
|
||
setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45");
|
||
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
|
||
setSessionDays(settings.find((s) => s.key === SESSION_KEY)?.value ?? "30");
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
async function save(e) {
|
||
e.preventDefault();
|
||
setError(null); setBusy(true);
|
||
try {
|
||
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
|
||
// Mindestens 1: "0 Tage" waere keine Frist, sondern ein Widerspruch.
|
||
await api.setSetting(OPEN_DAYS_KEY, String(Math.max(1, parseInt(openDays, 10) || 1)));
|
||
await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0))));
|
||
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
|
||
await api.setSetting(SESSION_KEY, sessionDays);
|
||
await reload();
|
||
toast("Gespeichert.");
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
// Ein ueber die API gesetzter Zwischenwert (z.B. 60) steht nicht in der Liste.
|
||
// Ohne diesen Zusatz zeigte die Auswahl nichts an und das Speichern haette den
|
||
// Wert klammheimlich veraendert.
|
||
const dauerAuswahl = SITZUNGSDAUERN.some((d) => d.value === sessionDays)
|
||
? SITZUNGSDAUERN
|
||
: [...SITZUNGSDAUERN, { value: sessionDays, label: `${sessionDays} Tage` }];
|
||
|
||
// Beispiel mit einem festen Datum, damit man die Wirkung sofort sieht.
|
||
const preview = formatDate("2026-12-23", dateFormat);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Einstellungen</h1>
|
||
<div className="sub">Allgemeine Konfiguration der Anwendung</div>
|
||
</div>
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<form className="card form-narrow" onSubmit={save}>
|
||
<div className="card-head"><Icon name="clock" /><h2>Ablaufwarnung</h2></div>
|
||
<label>
|
||
Vorwarnzeit (Tage)
|
||
<input type="number" min="0" step="1" value={days}
|
||
onChange={(e) => setDays(e.target.value)} style={{ maxWidth: 160 }} />
|
||
</label>
|
||
<p className="muted small mt-0">
|
||
Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder
|
||
bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend“.
|
||
</p>
|
||
|
||
<label>
|
||
Nach Öffnung haltbar (Tage)
|
||
<input type="number" min="1" step="1" value={openDays}
|
||
onChange={(e) => setOpenDays(e.target.value)} style={{ maxWidth: 160 }} />
|
||
</label>
|
||
<p className="muted small mt-0">
|
||
Wie lange eine angebrochene Packung noch gilt, wenn weder der Artikel
|
||
noch eine seiner Kategorien etwas anderes sagt. Dieser Wert ist die
|
||
letzte Station der Kette und deshalb Pflicht – ohne ihn bekäme eine
|
||
offene Packung kein „zu verbrauchen bis“. Abweichungen stehen bei den
|
||
Kategorien unter „Maße & Frist“.
|
||
</p>
|
||
|
||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||
<Icon name="cart" /><h2>Kassenzettel-Scan</h2>
|
||
</div>
|
||
<label>
|
||
Trefferschwelle (%)
|
||
<input type="number" min="0" max="100" step="1" value={threshold}
|
||
onChange={(e) => setThreshold(e.target.value)} style={{ maxWidth: 160 }} />
|
||
</label>
|
||
<p className="muted small mt-0">
|
||
Beim Scannen eines Kassenzettels (iOS) werden je Zeile nur Artikel vorgeschlagen,
|
||
deren Übereinstimmung mindestens so hoch ist. Höher = strenger (weniger, dafür
|
||
sicherere Vorschläge), niedriger = mehr Vorschläge.
|
||
</p>
|
||
|
||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||
<Icon name="users" /><h2>Anmeldung</h2>
|
||
</div>
|
||
<label>
|
||
Angemeldet bleiben für
|
||
<select value={sessionDays} onChange={(e) => setSessionDays(e.target.value)}>
|
||
{dauerAuswahl.map((d) => (
|
||
<option key={d.value} value={d.value}>{d.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<p className="muted small mt-0">
|
||
Gilt für die Website und die App gleichermaßen. Die Frist zählt ab dem
|
||
letzten Besuch – wer regelmäßig vorbeischaut, wird also gar nicht
|
||
abgemeldet. Geändert wird sie beim nächsten Seitenaufruf übernommen.
|
||
</p>
|
||
{sessionDays === "0" && (
|
||
<p className="muted small mt-0">
|
||
<Icon name="alert" size={14} /> Unbegrenzt heißt wirklich unbegrenzt:
|
||
Eine einmal erteilte Anmeldung lässt sich nicht mehr einzeln zurückziehen.
|
||
Wer alle Geräte aussperren will, muss JWT_SECRET in der .env ändern und
|
||
den Server neu starten.
|
||
</p>
|
||
)}
|
||
|
||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||
<Icon name="settings" /><h2>Darstellung</h2>
|
||
</div>
|
||
<label>
|
||
Datumsformat
|
||
<select value={dateFormat} onChange={(e) => setDateFormat(e.target.value)}>
|
||
{DATE_FORMATS.map((f) => (
|
||
<option key={f.value} value={f.value}>{f.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<BrandingUpload
|
||
kind="logo"
|
||
title="Eigenes Logo"
|
||
empfehlung="etwa 400 × 72 px, PNG oder SVG mit durchsichtigem Hintergrund
|
||
(höchstens 512 KB)"
|
||
hint="Ersetzt Zeichen und Schriftzug oben in der Seitenleiste. Dargestellt wird
|
||
es mit 36 px Höhe und höchstens 200 px Breite – die doppelte Größe sorgt
|
||
für ein scharfes Bild auf hochauflösenden Bildschirmen. Ohne Angabe bleibt
|
||
das eingebaute Zeichen stehen."
|
||
/>
|
||
<BrandingUpload
|
||
kind="favicon"
|
||
title="Eigenes Favicon"
|
||
empfehlung="quadratisch, 64 × 64 px, PNG, SVG oder ICO (höchstens 512 KB)"
|
||
hint="Das Symbol im Browser-Tab. Ohne Angabe wird das Standardzeichen verwendet.
|
||
Änderungen werden nach einem Neuladen der Seite sichtbar."
|
||
onChanged={applyFavicon}
|
||
/>
|
||
<p className="muted small mt-0">Beispiel: {preview}</p>
|
||
|
||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||
</form>
|
||
|
||
<SeitenleisteCard />
|
||
<MealieCard />
|
||
<ApiTokensCard />
|
||
<DangerZoneCard />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div className="card">
|
||
<div className="card-head"><Icon name="dashboard" /><h2>Seitenleiste</h2></div>
|
||
<p className="muted small">
|
||
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.
|
||
</p>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
{versteckt == null
|
||
? <p className="muted small">Wird geladen…</p>
|
||
: <fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0 }}>
|
||
<NavigationAuswahl versteckt={versteckt} onChange={speichern} />
|
||
</fieldset>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
|
||
/**
|
||
* Verbindung zu einer selbst gehosteten Mealie-Instanz.
|
||
*
|
||
* Das Passwort wird nicht gespeichert: Vorrania meldet sich damit einmal an,
|
||
* laesst sich von Mealie ein Dauertoken ausstellen und vergisst es. Deshalb
|
||
* wird das Feld nach dem Speichern geleert und nie wieder gefuellt – es gibt
|
||
* nichts, was man zurueckladen koennte.
|
||
*/
|
||
function MealieCard() {
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const [status, setStatus] = useState(null);
|
||
const [url, setUrl] = useState("");
|
||
const [benutzer, setBenutzer] = useState("");
|
||
const [passwort, setPasswort] = useState("");
|
||
const [token, setToken] = useState("");
|
||
const [mitToken, setMitToken] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [fehler, setFehler] = useState(null);
|
||
// Das Formular steht nur offen, solange nichts eingerichtet ist – oder wenn
|
||
// man es ausdruecklich wieder aufmacht. Ein Dauerzustand gehoert nicht in ein
|
||
// Eingabeformular, sonst sieht man nie, ob es geklappt hat.
|
||
const [aendern, setAendern] = useState(false);
|
||
|
||
async function laden(pruefen = false) {
|
||
try {
|
||
const s = await api.mealieStatus(pruefen);
|
||
setStatus(s);
|
||
if (s.base_url) setUrl(s.base_url);
|
||
} catch (err) { setFehler(err.message); }
|
||
}
|
||
useEffect(() => { laden(); }, []);
|
||
|
||
const verbunden = !!status?.configured;
|
||
|
||
async function verbinden(e) {
|
||
e.preventDefault();
|
||
// Eigene Pruefung vor dem Absenden: Der Server antwortet sonst mit der
|
||
// rohen Schema-Meldung („String should have at least 1 character"), und
|
||
// die erklaert niemandem, welches Feld gemeint ist.
|
||
if (!url.trim()) { setFehler("Bitte die Adresse von Mealie eintragen."); return; }
|
||
if (mitToken && !token.trim()) { setFehler("Bitte das Token aus Mealie einfügen."); return; }
|
||
if (!mitToken && !benutzer.trim()) { setFehler("Bitte den Benutzernamen in Mealie eintragen."); return; }
|
||
if (!mitToken && !passwort) { setFehler("Bitte das Passwort eingeben."); return; }
|
||
setBusy(true);
|
||
setFehler(null);
|
||
try {
|
||
const s = mitToken
|
||
? await api.mealieToken({ base_url: url, token })
|
||
: await api.mealieKonfigurieren({ base_url: url, username: benutzer, password: passwort });
|
||
setStatus(s);
|
||
setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert
|
||
setToken("");
|
||
setAendern(false); // Formular zu, der Zustand oben sagt jetzt alles
|
||
toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht.");
|
||
} catch (err) { setFehler(err.message); } finally { setBusy(false); }
|
||
}
|
||
|
||
async function trennen() {
|
||
const ok = await confirm({
|
||
title: "Verbindung zu Mealie lösen?",
|
||
message: "Adresse und Token werden hier entfernt. In Mealie selbst bleibt das "
|
||
+ "Token bestehen – dort unter Profil ▸ API-Tokens löschbar.",
|
||
confirmLabel: "Trennen", danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.mealieTrennen();
|
||
setStatus({ configured: false, base_url: "", token_set: false });
|
||
setUrl(""); setBenutzer(""); setPasswort(""); setToken("");
|
||
setAendern(false);
|
||
toast("Verbindung gelöst.");
|
||
} catch (err) { setFehler(err.message); }
|
||
}
|
||
|
||
return (
|
||
<form className="card form-narrow" onSubmit={verbinden} style={{ marginTop: "var(--sp-4)" }}>
|
||
<div className="card-head"><Icon name="food" /><h2>Mealie (Rezepte)</h2></div>
|
||
<p className="muted small mt-0">
|
||
Verbindet Vorrania mit einer selbst gehosteten Mealie-Instanz, um Rezepte
|
||
gegen den Bestand zu prüfen. Dein Passwort wird <strong>nicht</strong>{" "}
|
||
gespeichert: Vorrania meldet sich damit einmal an, lässt sich ein
|
||
Dauertoken ausstellen und vergisst es.
|
||
</p>
|
||
|
||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||
|
||
{verbunden && !aendern ? (
|
||
<>
|
||
<div className={`alert ${status.reachable === false ? "error" : "ok"}`}>
|
||
<Icon name={status.reachable === false ? "alert" : "check"} size={16} />
|
||
<span>
|
||
{status.reachable === false
|
||
? `Nicht erreichbar: ${status.error || "unbekannter Grund"}`
|
||
: "Verbunden"}
|
||
</span>
|
||
</div>
|
||
<dl className="kv">
|
||
<dt>Adresse</dt><dd>{status.base_url}</dd>
|
||
{status.version && (<><dt>Mealie-Version</dt><dd>{status.version}</dd></>)}
|
||
<dt>Zugang</dt>
|
||
<dd>Dauertoken hinterlegt – dein Passwort ist nicht gespeichert.</dd>
|
||
</dl>
|
||
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
|
||
<button type="button" className="btn" onClick={() => laden(true)}>
|
||
<Icon name="check" size={16} />Verbindung prüfen
|
||
</button>
|
||
<button type="button" className="btn" onClick={() => setAendern(true)}>
|
||
<Icon name="edit" size={16} />Zugang ändern
|
||
</button>
|
||
<button type="button" className="btn danger" onClick={trennen}>
|
||
Trennen
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<label>
|
||
Adresse
|
||
<input value={url} placeholder="https://mealie.example.com"
|
||
onChange={(e) => setUrl(e.target.value)} />
|
||
</label>
|
||
|
||
{!mitToken ? (
|
||
<div className="row">
|
||
<label className="grow">
|
||
Benutzername in Mealie
|
||
<input value={benutzer} onChange={(e) => setBenutzer(e.target.value)} />
|
||
</label>
|
||
<label className="grow">
|
||
Passwort
|
||
<input type="password" value={passwort} autoComplete="new-password"
|
||
onChange={(e) => setPasswort(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
) : (
|
||
<label>
|
||
Dauertoken aus Mealie
|
||
<input value={token} onChange={(e) => setToken(e.target.value)}
|
||
placeholder="in Mealie unter Profil ▸ API-Tokens erzeugen" />
|
||
</label>
|
||
)}
|
||
|
||
<label className="check-inline">
|
||
<input type="checkbox" checked={mitToken}
|
||
onChange={(e) => setMitToken(e.target.checked)} />
|
||
Ich gebe lieber selbst ein Token ein
|
||
</label>
|
||
|
||
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
|
||
<button className="btn primary" disabled={busy}>
|
||
<Icon name="check" size={16} />{busy ? "Verbinde…" : "Verbinden"}
|
||
</button>
|
||
{verbunden && (
|
||
<button type="button" className="btn" onClick={() => { setAendern(false); setFehler(null); }}>
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|
||
|
||
|
||
function ApiTokensCard() {
|
||
const confirm = useConfirm();
|
||
const [tokens, setTokens] = useState([]);
|
||
const [name, setName] = useState("");
|
||
const [fresh, setFresh] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
async function load() {
|
||
try {
|
||
setTokens(await api.listApiTokens());
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
async function create(e) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
try {
|
||
const created = await api.createApiToken({ name });
|
||
setFresh(created.token);
|
||
setName("");
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function remove(token) {
|
||
const ok = await confirm({
|
||
title: `Token „${token.name}“ widerrufen?`,
|
||
message: "Zugriffe mit diesem Token funktionieren sofort nicht mehr.",
|
||
confirmLabel: "Widerrufen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.deleteApiToken(token.id);
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="users" /><h2>API-Zugriff (z.B. Home Assistant)</h2></div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<p className="muted small mt-0">
|
||
Mit einem Token lässt sich die Einkaufsliste dauerhaft abfragen, ohne dass eine
|
||
Anmeldung abläuft. Der Token wird nur einmal angezeigt – danach ist nur noch
|
||
sein Fingerabdruck gespeichert.
|
||
</p>
|
||
|
||
{fresh && (
|
||
<div className="alert ok">
|
||
<Icon name="check" size={16} />
|
||
<span>
|
||
Neuer Token – bitte jetzt kopieren:
|
||
<div><code className="strong" style={{ wordBreak: "break-all" }}>{fresh}</code></div>
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<ul className="simple-list">
|
||
{tokens.map((t) => (
|
||
<li key={t.id}>
|
||
<span>
|
||
<strong>{t.name}</strong>
|
||
<span className="muted small">
|
||
{" "}· angelegt {new Date(t.created_at).toLocaleDateString("de-DE")}
|
||
{t.last_used_at
|
||
? ` · zuletzt genutzt ${new Date(t.last_used_at).toLocaleDateString("de-DE")}`
|
||
: " · noch nicht genutzt"}
|
||
</span>
|
||
</span>
|
||
<button className="btn-icon danger" onClick={() => remove(t)} title="Widerrufen">
|
||
<Icon name="trash" size={16} />
|
||
</button>
|
||
</li>
|
||
))}
|
||
{tokens.length === 0 && <li className="muted small">Noch keine Tokens.</li>}
|
||
</ul>
|
||
|
||
<form className="field-inline" onSubmit={create}>
|
||
<input placeholder="Name, z.B. Home Assistant" value={name}
|
||
onChange={(e) => setName(e.target.value)} required />
|
||
<button className="btn"><Icon name="plus" size={16} />Token erzeugen</button>
|
||
</form>
|
||
|
||
<details style={{ marginTop: "var(--sp-3)" }}>
|
||
<summary className="muted small">Beispiel für Home Assistant</summary>
|
||
<pre style={{ overflowX: "auto", fontSize: "0.78rem" }}>{`# configuration.yaml
|
||
rest:
|
||
- resource: http://DEIN-SERVER:8080/api/shopping-list
|
||
scan_interval: 900
|
||
headers:
|
||
Authorization: "Bearer DEIN_TOKEN"
|
||
sensor:
|
||
- name: "Einkaufsliste Anzahl"
|
||
value_template: "{{ value_json | length }}"
|
||
json_attributes_path: "$"
|
||
- name: "Einkaufsliste"
|
||
value_template: "{{ value_json | map(attribute='name') | join(', ') }}"`}</pre>
|
||
</details>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
|
||
/**
|
||
* Gefahrenbereich: Daten in großem Stil löschen.
|
||
*
|
||
* Drei Stufen, damit man nicht mehr wegwirft als nötig. Benutzer, API-Tokens,
|
||
* Einstellungen und das eigene Logo bleiben in allen Stufen erhalten.
|
||
*/
|
||
function DangerZoneCard() {
|
||
const toast = useToast();
|
||
const [zahlen, setZahlen] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
async function load() {
|
||
try {
|
||
setZahlen(await api.maintenanceSummary());
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
function run(aktion, meldung) {
|
||
return async () => {
|
||
setError(null);
|
||
try {
|
||
setZahlen(await aktion());
|
||
toast(meldung);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
};
|
||
}
|
||
|
||
if (!zahlen) return null;
|
||
|
||
const n = (wert, eins, mehr) => `${wert} ${wert === 1 ? eins : mehr}`;
|
||
|
||
return (
|
||
<section className="card danger-zone">
|
||
<div className="card-head">
|
||
<Icon name="alert" /><h2>Gefahrenbereich</h2>
|
||
</div>
|
||
<p className="muted small mt-0">
|
||
Diese Aktionen lassen sich nicht rückgängig machen. Lege vorher ein Backup an
|
||
(Import / Export → Backup als JSON). Benutzer, API-Tokens, Einstellungen und
|
||
dein Logo bleiben in allen Fällen erhalten.
|
||
</p>
|
||
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<DangerAction
|
||
title="Alle Bestände löschen"
|
||
description="Entfernt sämtliche Chargen und den Bewegungsverlauf. Die Artikel
|
||
selbst bleiben mit allen Stammdaten erhalten – der Bestand steht
|
||
danach überall auf null."
|
||
impact={`Betrifft ${n(zahlen.lots, "Charge", "Chargen")} und ${n(zahlen.movements, "Bewegung", "Bewegungen")}.`}
|
||
buttonLabel="Bestände löschen"
|
||
onRun={run(api.resetStock, "Alle Bestände wurden gelöscht.")}
|
||
/>
|
||
|
||
<DangerAction
|
||
title="Alle Artikel löschen"
|
||
description="Entfernt zusätzlich den gesamten Artikelkatalog samt seiner
|
||
EAN-Codes. Gruppen, Kategorien, Lagerorte und Einheiten bleiben."
|
||
impact={`Betrifft ${n(zahlen.products, "Artikel", "Artikel")} samt ${n(zahlen.lots, "Charge", "Chargen")}.`}
|
||
buttonLabel="Artikel löschen"
|
||
onRun={run(api.resetProducts, "Alle Artikel und Bestände wurden gelöscht.")}
|
||
/>
|
||
|
||
<DangerAction
|
||
title="Alles zurücksetzen"
|
||
description="Entfernt zusätzlich Gruppen, Kategorien, Lagerorte und Einheiten.
|
||
Die eingebauten Einheiten und die Kategorie-Startliste werden
|
||
danach sofort neu angelegt, damit die Installation benutzbar bleibt."
|
||
impact={`Betrifft ${n(zahlen.products, "Artikel", "Artikel")}, ${n(zahlen.groups, "Gruppe", "Gruppen")}, ${n(zahlen.categories, "Kategorie", "Kategorien")}, ${n(zahlen.locations, "Lagerort", "Lagerorte")} und ${n(zahlen.units, "Einheit", "Einheiten")}.`}
|
||
buttonLabel="Alles zurücksetzen"
|
||
onRun={run(api.resetAll, "Alles wurde zurückgesetzt. Einheiten und Startkategorien sind wieder angelegt.")}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|