Files
Vorrania/web/src/pages/Settings.jsx
T
ScarriffleandClaude Opus 5 757d06100a Seitenleiste: Eintraege ausblenden, als Vorgabe und je Konto
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>
2026-10-28 20:31:50 +01:00

612 lines
24 KiB
React
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 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 &amp; 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>
);
}