Files
Vorrania/web/src/pages/Settings.jsx
Scarriffle 9cc3e325bb Web: Kassenzettel-Trefferschwelle in den Einstellungen
Prozent-Feld fuer receipt_match_threshold (Default 45) analog zur Ablaufwarnung;
speichert ueber den generischen Settings-Endpunkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:26:20 +02:00

328 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
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";
const RECEIPT_KEY = "receipt_match_threshold";
export default function Settings() {
const { reload } = useSettings();
const toast = useToast();
const [days, setDays] = useState("");
const [threshold, setThreshold] = useState("");
const [dateFormat, setDateFormat] = useState("de");
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");
setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45");
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
} 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)));
await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0))));
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
await reload();
toast("Gespeichert.");
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
// 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>
<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="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>
<ApiTokensCard />
<DangerZoneCard />
</div>
);
}
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
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>
);
}