Favicon aus dem Seitenleisten-Zeichen und Gefahrenbereich in den Einstellungen

Favicon: Bisher diente der Barcode-Glyph des App-Icons als Standard. Jetzt
zeigt der Browser-Tab dasselbe Zeichen wie die Seitenleiste - das "box"-Icon
aus components/Icon.jsx auf der Akzentflaeche. Die Farbe folgt der Oberflaeche:
im dunklen Modus der hellere Akzent, damit das Symbol auf beiden Untergruenden
sitzt. Ein eigenes Favicon laesst sich weiterhin hochladen und hat Vorrang.

Gefahrenbereich (Einstellungen, nur Admin) mit drei Stufen, damit man nicht
mehr wegwirft als noetig:

1. Alle Bestaende loeschen - Chargen und Bewegungsverlauf. Der Katalog bleibt
   vollstaendig erhalten, der Bestand steht ueberall auf null.
2. Alle Artikel loeschen - zusaetzlich der Artikelkatalog samt seiner
   EAN-Codes. Gruppen, Kategorien, Lagerorte und Einheiten bleiben. Die Codes,
   die an Gruppen haengen, bleiben ebenfalls.
3. Alles zuruecksetzen - zusaetzlich Gruppen, Kategorien, Lagerorte, Einheiten.

Benutzer, API-Tokens, Einstellungen und das eigene Logo bleiben in allen drei
Stufen erhalten. Anders liesse man sich aussperren und muesste externe Zugriffe
wie Home Assistant neu einrichten. Nach Stufe 3 werden die eingebauten
Einheiten und die Kategorie-Startliste sofort neu angelegt - ohne Einheiten
liesse sich nichts mehr einlagern, die Installation waere unbrauchbar.

Absicherung: Jede Aktion nennt vorher mit Zahlen, was genau verschwindet
("Betrifft 43 Chargen und 12 Bewegungen"), und laeuft erst, wenn das Wort
LOESCHEN eingetippt wurde. Ein Fehlklick kann nichts anrichten. Der Abschnitt
ist rot abgesetzt und weist auf das JSON-Backup hin.

Getestet: 57 pytest-Tests gruen, 3 davon neu (Bestaende loeschen laesst den
Katalog stehen, Artikel loeschen laesst Gruppen, Lagerorte und Einheiten sowie
den Gruppen-Code stehen, Startkategorien kommen zurueck). Gegen die laufende
API alle drei Stufen nacheinander durchgespielt und die Zahlen nach jedem
Schritt geprueft; Benutzer bleiben, Einheiten sind danach wieder da, ohne
Adminrechte antwortet der Endpunkt mit 401. Das Favicon wurde gerendert und
mit dem Zeichen der Seitenleiste verglichen. Web-Build laeuft durch.
Die Oberflaeche habe ich nicht selbst bedient - insbesondere habe ich keine
der Loeschaktionen ueber die Web-Oberflaeche ausgeloest.

Nebenbei die deutschen Anfuehrungszeichen im ganzen Projekt vereinheitlicht
(schliessendes Zeichen typografisch statt gerade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 21:21:52 +02:00
parent 158196f67b
commit 48cc2646e8
12 changed files with 358 additions and 7 deletions

View File

@@ -1 +1,18 @@
<svg fill="#6b78ff" id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m4 6h2v12h-2zm4 12h2v-12h-2zm12-12h-2v12h2zm-9 12h3v-12h-3zm-9 1v-3h-2v3c0 1.654 1.346 3 3 3h3v-2h-3c-.551 0-1-.449-1-1zm20 0c0 .551-.449 1-1 1h-3v2h3c1.654 0 3-1.346 3-3v-3h-2zm-1-17h-3v2h3c.551 0 1 .449 1 1v3h2v-3c0-1.654-1.346-3-3-3zm-21 3v3h2v-3c0-.551.449-1 1-1h3v-2h-3c-1.654 0-3 1.346-3 3z"/><path d="m16 6h-1v12h1z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Project-Good">
<!-- Dasselbe Zeichen wie in der Seitenleiste: das "box"-Icon aus
components/Icon.jsx auf der Akzentflaeche. Die Farbe folgt der
Oberflaeche - im dunklen Modus der hellere Akzent, damit das Symbol
im Browser-Tab auf beiden Untergruenden gut sitzt. -->
<style>
.plate { fill: #4d5cd4; }
@media (prefers-color-scheme: dark) {
.plate { fill: #7c8ae8; }
}
</style>
<rect class="plate" width="64" height="64" rx="15" />
<g transform="translate(12 12) scale(1.6667)"
fill="none" stroke="#ffffff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 862 B

View File

@@ -186,6 +186,12 @@ export const api = {
},
deleteBranding: (kind) => request(`/branding/${kind}`, { method: "DELETE" }),
// Gefahrenbereich (nur Admin)
maintenanceSummary: () => request("/maintenance/summary"),
resetStock: () => request("/maintenance/reset/stock", { method: "POST" }),
resetProducts: () => request("/maintenance/reset/products", { method: "POST" }),
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
// Einstellungen
listSettings: () => request("/settings"),
setSetting: (key, value) =>

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import Icon from "./Icon";
const BESTAETIGUNG = "LÖSCHEN";
/**
* Eine unwiderrufliche Aktion im Gefahrenbereich.
*
* Absichtlich umständlich: Der Text nennt vorher, was genau verschwindet, und
* ausgelöst wird erst, wenn das Wort getippt wurde. Ein Fehlklick soll hier
* nichts anrichten können.
*/
export default function DangerAction({ title, description, impact, buttonLabel, onRun }) {
const [wort, setWort] = useState("");
const [busy, setBusy] = useState(false);
const bereit = wort.trim().toUpperCase() === BESTAETIGUNG;
async function run() {
if (!bereit) return;
setBusy(true);
try {
await onRun();
setWort("");
} finally {
setBusy(false);
}
}
return (
<div className="danger-action">
<div className="strong">{title}</div>
<p className="muted small mt-0">{description}</p>
<p className="small mt-0"><strong>{impact}</strong></p>
<div className="field-inline" style={{ marginBottom: 0 }}>
<label className="grow" style={{ margin: 0 }}>
<input value={wort} placeholder={`Zum Bestätigen ${BESTAETIGUNG} tippen`}
onChange={(e) => setWort(e.target.value)} />
</label>
<button type="button" className="btn danger" disabled={!bereit || busy} onClick={run}>
<Icon name="trash" size={16} />{busy ? "Löscht…" : buttonLabel}
</button>
</div>
</div>
);
}

View File

@@ -73,7 +73,7 @@ export default function Categories() {
<div>
<h1>Kategorien</h1>
<div className="sub">
Ordnen die Artikelliste für den Überblick zeig mir alle Süßwaren".
Ordnen die Artikelliste für den Überblick zeig mir alle Süßwaren.
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das
übernehmen die Gruppen.
</div>

View File

@@ -70,7 +70,7 @@ export default function Groups() {
<div>
<h1>Gruppen</h1>
<div className="sub">
Bestände mehrerer Marken zusammenzählen z.B. Mehl von Rewe, Aldi und Migros als 5 kg Mehl". Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
Bestände mehrerer Marken zusammenzählen z.B. Mehl von Rewe, Aldi und Migros als 5 kg Mehl. Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
</div>
</div>
</div>

View File

@@ -97,8 +97,8 @@ export default function ProductForm() {
setInfo(
"Daten von Open Food Facts übernommen." +
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt bitte Packungsgröße prüfen.)") +
(res.category_name ? ` Kategorie „${res.category_name}" vorgeschlagen.` : "") +
(res.group_name ? ` Gruppe „${res.group_name}" über den EAN-Code erkannt.` : "")
(res.category_name ? ` Kategorie „${res.category_name} vorgeschlagen.` : "") +
(res.group_name ? ` Gruppe „${res.group_name} über den EAN-Code erkannt.` : "")
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
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";
@@ -101,6 +102,7 @@ export default function Settings() {
</form>
<ApiTokensCard />
<DangerZoneCard />
</div>
);
}
@@ -210,3 +212,88 @@ rest:
</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 [zahlen, setZahlen] = useState(null);
const [error, setError] = useState(null);
const [info, setInfo] = 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);
setInfo(null);
try {
setZahlen(await aktion());
setInfo(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>}
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</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>
);
}

View File

@@ -305,6 +305,12 @@ td select { width: auto; min-width: 132px; max-width: 100%; }
.precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; }
.precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; }
/* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */
/* Gefahrenbereich: deutlich abgesetzt, damit man nicht versehentlich hineinruscht */
.danger-zone { border-color: var(--danger); }
.danger-zone .card-head .icon { color: var(--danger); }
.danger-action { padding: var(--sp-4) 0; border-top: 1px solid var(--border); }
.danger-action:first-of-type { border-top: none; }
.assign-box { margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.assign-note {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;