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>
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
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>
|
||
);
|
||
}
|