Files
Vorrania/web/src/pages/Settings.jsx
Scarriffle 97a6e94ea1 Kurzmeldungen als Overlay, Liniendiagramm fuellt die Karte, Zeitraum ab 1 Tag
Meldungen:
"Gespeichert", "Anordnung gespeichert" und die uebrigen Erfolgsmeldungen standen
als Leiste mitten in der Seite - beim Speichern sprang dadurch das ganze Layout
ein Stueck nach unten. Sie erscheinen jetzt als Overlay am unteren Rand und
verschwinden nach fuenf Sekunden von selbst; ein Kreuz blendet sie sofort aus.
Umgestellt in Uebersicht, Einstellungen, Gefahrenbereich, Benutzer,
Produktformular sowie Ein- und Auslagern.

Bewusst NICHT umgestellt: Fehlermeldungen. Ein Fehler, der nach fuenf Sekunden
verschwindet, ist genau dann weg, wenn man ihn noch braucht - der bleibt an
seiner Stelle stehen.

Rollbalken beim Liniendiagramm:
Den hatte ich mir mit der Zeitraum-Auswahl selbst eingebaut. Das Diagramm hatte
eine feste Hoehe von 200 px; zusammen mit der Auswahl darueber passte das nicht
mehr in die Karte. Jetzt wird auch die Hoehe gemessen, das Diagramm fuellt den
verbleibenden Platz.

Zeitraum "1 Tag":
Ein einzelner Tag ergibt genau einen Messpunkt - eine Linie aus einem Punkt
waere unsinnig. Das Diagramm zeigt in dem Fall die blanke Zahl. Die
Backend-Begrenzung laesst jetzt einen Tag zu.

Geprueft: "npm run build" laeuft durch; kein Verweis mehr auf die entfernten
Meldungszustaende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:23:53 +02:00

310 lines
11 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";
export default function Settings() {
const { reload } = useSettings();
const toast = useToast();
const [days, setDays] = 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");
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(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="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>
);
}