import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import Icon from "../components/Icon"; import { CardSkeleton } from "../components/Skeleton"; import ShoppingNeedText from "../components/ShoppingNeedText"; import BewegungIcon from "../components/BewegungIcon"; import { FristBadge, verleihRowClass } from "../components/Verleih"; import { useSettings } from "../settings"; import { articlePrimary, articleSecondary, fmt, relativeExpiry, } from "../units"; import Bars from "./charts/Bars"; import Donut from "./charts/Donut"; import Line from "./charts/Line"; import Wasserfall from "./charts/Wasserfall"; import { istDunkel, mitFarben, tokenFarbe } from "./palette"; /** * Verzeichnis aller Karten der Startseite. * * Eine Karte ist ein Eintrag hier – mehr braucht es nicht, um sie im * Hinzufügen-Menü und im Raster verfügbar zu machen. Jede Karte lädt ihre * eigenen Daten; so kostet eine nicht genutzte Karte auch keine Abfrage. */ // ---------------------------------------------------------------- Helfer function useDaten(laden, abhaengig = []) { const [daten, setDaten] = useState(null); const [fehler, setFehler] = useState(null); useEffect(() => { let aktiv = true; laden() .then((d) => aktiv && setDaten(d)) .catch((e) => aktiv && setFehler(e.message)); return () => { aktiv = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, abhaengig); return { daten, fehler }; } function Zustand({ fehler, daten, children, leer = "Keine Daten.", skelett = "liste" }) { if (fehler) return
{fehler}
; if (daten == null) return ; if (Array.isArray(daten) && daten.length === 0) return
{leer}
; return children; } /** * Beschriftung der Zeitachse. Bei kurzen Zeiträumen tastet der Server stündlich * ab – dann ist die Uhrzeit die nützliche Angabe, sonst das Datum. */ function zeitLabel(iso, tage) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); if (tage <= 2) return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); if (tage <= 14) { return d.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit" }); } return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }); } // ---------------------------------------------------------------- Aktionen /** * Artikelsuche mit Tastaturbedienung, wie sie die Aktionskarten brauchen. * Erst ab zwei Zeichen wird gesucht – sonst laedt jede Karte beim Oeffnen die * halbe Artikelliste. */ function Artikelsuche({ gewaehlt, onWaehlen }) { const [text, setText] = useState(""); const [treffer, setTreffer] = useState([]); useEffect(() => { if (gewaehlt || text.trim().length < 2) { setTreffer([]); return undefined; } let aktiv = true; const zeit = setTimeout(() => { api.listProducts(text.trim()) .then((liste) => aktiv && setTreffer(liste.slice(0, 6))) .catch(() => aktiv && setTreffer([])); }, 250); return () => { aktiv = false; clearTimeout(zeit); }; }, [text, gewaehlt]); if (gewaehlt) { return (
{gewaehlt.name}
); } return (
setText(e.target.value)} placeholder="Artikel suchen…" onMouseDown={(e) => e.stopPropagation()} /> {treffer.length > 0 && ( )}
); } /** * Ein- und Auslagern ohne Seitenwechsel: Artikel suchen, Menge eintragen, * fertig. MHD und Lagerort bleiben bewusst weg – wer die braucht, ist auf der * vollen Seite besser aufgehoben; hier zaehlt der schnelle Alltagsfall. */ function KarteSchnellbuchung({ richtung }) { const einlagern = richtung === "in"; const [artikel, setArtikel] = useState(null); const [menge, setMenge] = useState("1"); const [meldung, setMeldung] = useState(null); const [fehler, setFehler] = useState(null); const [laeuft, setLaeuft] = useState(false); const zahl = Number(String(menge).replace(",", ".")); const bereit = artikel && zahl > 0 && !laeuft; async function buchen() { if (!bereit) return; setLaeuft(true); setFehler(null); setMeldung(null); try { // Die Menge zaehlt in Artikeleinheiten: Gibt es ein Gebinde, ist das eine // Packung, sonst die Basiseinheit. Der Server kennt kein "article". const unit = artikel.package_size > 0 ? "package" : artikel.base_unit; if (einlagern) { await api.checkInBatch({ product_id: artikel.id, unit, lines: [{ quantity: zahl }], }); } else { await api.checkOut({ product_id: artikel.id, quantity: zahl, unit }); } setMeldung(`${fmt(zahl)} ${einlagern ? "eingelagert" : "ausgelagert"}.`); setMenge("1"); } catch (err) { setFehler(err.message); } finally { setLaeuft(false); } } return (
{ setArtikel(p); setMeldung(null); setFehler(null); }} />
e.stopPropagation()} onChange={(e) => setMenge(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") buchen(); }} />
{meldung &&
{meldung}
} {fehler &&
{fehler}
}
); } /** * Ein fest eingestellter Artikel im Blick: Bestand jetzt, mit Hinweis auf den * Mindestbestand. Welcher Artikel gemeint ist, steht in den Karten-Props und * wird deshalb mitgespeichert. */ function KarteArtikel({ props: karteProps }) { const produktId = karteProps?.product_id; const { daten, fehler } = useDaten( () => (produktId ? api.getProduct(produktId) : Promise.resolve(null)), [produktId], ); if (!produktId) { return
Kein Artikel gewählt – über „Anordnen“ einstellen.
; } return ( {daten && (
{daten.name}
{articlePrimary(daten)}
{articleSecondary(daten)}
)}
); } function KarteAktionen() { return (
Einlagern Auslagern
); } // ---------------------------------------------------------------- Kennzahlen function Kennzahl({ wert, label, icon, ton }) { return (
{label}
{wert}
); } function KarteStatus() { const { daten, fehler } = useDaten(() => api.dashboardStats()); return (
); } function KarteArtikelzahl() { const { daten, fehler } = useDaten(() => api.dashboardStats()); return ( ); } function KarteEinheiten() { const { daten, fehler } = useDaten(() => api.dashboardStats()); return ( ); } // ---------------------------------------------------------------- Listen /** * Ablaufliste in drei Ausprägungen. * * "soon" zeigt bewusst NUR das, was noch läuft: Eine Karte mit der Überschrift * „Bald ablaufend“, in der bereits Abgelaufenes steht, führt in die Irre. * Wer beides zusammen sehen will, nimmt "alle". */ const ABLAUF_LEER = { soon: "Nichts läuft demnächst ab.", expired: "Nichts ist abgelaufen.", alle: "Nichts läuft demnächst ab und nichts ist abgelaufen.", }; function KarteAblauf({ modus = "alle" }) { const { formatBestBefore } = useSettings(); const { daten, fehler } = useDaten(() => api.expiring()); const zeilen = (daten || []).filter((e) => { if (modus === "expired") return e.days_left < 0; if (modus === "soon") return e.days_left >= 0; return true; }); return ( {zeilen.length === 0 ? (
{ABLAUF_LEER[modus]}
) : (
{zeilen.map((it) => ( ))}
ProduktMengeMHDFrist
{it.product_name} {articlePrimary(it)} {articleSecondary(it) &&
{articleSecondary(it)}
}
{formatBestBefore(it.best_before, it.best_before_precision)} {relativeExpiry(it.days_left)}
)}
); } /** * Einkaufsliste mit Gesamt-Bedarf UND Bedarf je Lagerort. Über die Karten-Konfig * lässt sich ein Lagerort festlegen: leer = alle Orte, sonst nur dieser. */ function KarteEinkaufsliste({ props: karteProps }) { const ortId = karteProps?.location_id || ""; const [erledigt, setErledigt] = useState(null); // null = noch nicht geladen const [eintraege, setEintraege] = useState([]); // Ein Aufruf statt drei, und er bringt beides mit: die gerechneten Bedarfe // UND die selbst draufgesetzten Eintraege samt dauerhaft gesetzter Haken. // Vorher kannte die Karte nur die gerechnete Seite - was aus einem Rezept // uebernommen wurde, stand nur im Tab und hier nicht. const { daten, fehler } = useDaten(async () => { const alles = await api.shoppingAll(); return { produkte: alles.products || [], gruppen: alles.groups || [], orte: alles.by_location || [], eintraege: alles.entries || [], checked: alles.checked || [], }; }); useEffect(() => { if (!daten) return; setErledigt(Object.fromEntries((daten.checked || []).map((k) => [k, true]))); setEintraege(daten.eintraege || []); }, [daten]); if (fehler) return
{fehler}
; if (!daten || erledigt == null) return ; const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte; const gesamtLeer = !daten.produkte.length && !daten.gruppen.length; const orteLeer = orte.every((o) => !o.products.length && !o.groups.length); const offeneEintraege = eintraege.filter((e) => !e.done); if (gesamtLeer && orteLeer && eintraege.length === 0) { return
Alle Mindestbestände erreicht.
; } // Der Haken wandert sofort und wird im Hintergrund gespeichert - genau wie auf // der vollen Seite, mit denselben Schluesseln. Schlaegt es fehl, springt er // zurueck, statt eine Erledigung vorzutaeuschen. const abhaken = async (key, val) => { setErledigt((e) => ({ ...e, [key]: val })); try { if (val) await api.setShoppingCheck(key); else await api.clearShoppingCheck(key); } catch { setErledigt((e) => ({ ...e, [key]: !val })); } }; const eintragAbhaken = async (eintrag, val) => { setEintraege((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: val } : e))); try { await api.updateShoppingEntry(eintrag.id, { done: val }); } catch { setEintraege((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: !val } : e))); } }; // Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite. // `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die // Bedarfszeile kommt vom Server fertig aufbereitet (`need`) und ist für // Produkte wie Gruppen, gesamt wie je Ort identisch aufgebaut. const abschnitt = (gruppen, produkte, prefix) => ( ); return (
{offeneEintraege.length > 0 && (
Selbst draufgesetzt
    {offeneEintraege.map((e) => (
  • {[e.recipe_name ? `für ${e.recipe_name}` : "", e.menge_text] .filter(Boolean).join(" · ")}
  • ))}
)} {!gesamtLeer && (
Überall
{abschnitt(daten.gruppen, daten.produkte, "")}
)} {orte.map((o) => ((o.products.length || o.groups.length) ? (
{o.location_name}
{abschnitt(o.groups, o.products, `l${o.location_id}`)}
) : null))}
); } /** * Verliehene Einzelstücke – wer hat was, und ist die Rückgabe schon fällig? * * „modus" trennt die beiden nützlichen Blickwinkel: alles Draußene oder nur das * Überfällige. Stücke ohne Rückgabedatum sind nie fällig und tauchen deshalb im * Modus „ueberfaellig" nicht auf. */ function KarteVerleih({ modus = "alle" }) { const { daten, fehler } = useDaten(() => api.loans()); const zeilen = (daten || []).filter( (v) => (modus === "ueberfaellig" ? !!v.days_overdue : true), ); const leer = modus === "ueberfaellig" ? "Nichts überfällig." : "Gerade ist nichts verliehen."; return ( {zeilen.length === 0 ? (
{leer}
) : (
{zeilen.map((v) => ( ))}
StückVerliehen anZurück
{v.product_name || v.item_uid} {v.product_name &&
{v.item_uid}
}
{v.borrower} {v.reason &&
{v.reason}
}
{v.due_on ? : offen}
)}
); } function KarteBewegungen() { const { daten, fehler } = useDaten(() => api.listMovements(20)); return ( {/* Kachel links, Zeitpunkt ueber Produkt. Die Bewegungsart trug frueher eine eigene Spalte mit dem Wort „Ausgelagert" – als Sinnbild kostet sie keine Zeile mehr und bleibt auf einen Blick erkennbar. */}
{(daten || []).map((m) => (
{new Date(m.created_at).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", })}
{m.product_name}
))}
); } // ---------------------------------------------------------------- Diagramme function KarteAblaufRing({ props: karteProps }) { const { daten, fehler } = useDaten(() => api.dashboardExpirySplit()); // Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – Karten-Einstellung. const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true; if (fehler) return
{fehler}
; if (!daten) return ; // Zustandsfarben, nie Serienfarben – und immer mit Beschriftung. const segmente = [ { name: "In Ordnung", value: daten.ok, color: tokenFarbe("--ok", "#2e7d55") }, { name: "Bald ablaufend", value: daten.soon, color: tokenFarbe("--warn", "#b06f14") }, { name: "Abgelaufen", value: daten.expired, color: tokenFarbe("--danger", "#c0392b") }, ]; if (mitOhneMhd) { segmente.push({ name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") }); } return ; } function KarteKategorienRing({ props: karteProps }) { // Tiefe: bis zu welcher Kategorie-Stufe zusammengefasst wird (leer = feinste). const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined; const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]); if (fehler) return
{fehler}
; if (!daten) return ; // Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“. const segmente = mitFarben( daten.map((k) => ({ name: k.name, value: k.article_units })), istDunkel(), { alle: !!karteProps?.detail }, ); return ; } function KarteKategorienSaeulen() { const { daten, fehler } = useDaten(() => api.dashboardByCategory()); if (fehler) return
{fehler}
; if (!daten) return ; const zeilen = daten.slice(0, 8).map((k) => ({ name: k.name, teile: [ { key: "ok", value: k.ok, label: "In Ordnung", color: tokenFarbe("--ok", "#2e7d55") }, { key: "soon", value: k.soon, label: "Bald ablaufend", color: tokenFarbe("--warn", "#b06f14") }, { key: "expired", value: k.expired, label: "Abgelaufen", color: tokenFarbe("--danger", "#c0392b") }, { key: "no_date", value: k.no_date, label: "Ohne MHD", color: tokenFarbe("--muted", "#6b7480") }, ], })); return ( <>
    {[["In Ordnung", "--ok"], ["Bald ablaufend", "--warn"], ["Abgelaufen", "--danger"], ["Ohne MHD", "--muted"]] .map(([name, token]) => (
  • {name}
  • ))}
); } /** * Auswahlmöglichkeiten für die Verlaufskarten. Die Auswahl selbst sitzt im * Kartenkopf (siehe Dashboard) – so bleibt die ganze Kartenhöhe dem Diagramm. */ export const ZEITRAEUME = [ // 1 Tag wird stündlich abgetastet; man sieht also, wann am Tag gelagert wurde. { tage: 1, label: "24 Stunden" }, { tage: 2, label: "2 Tage" }, { tage: 7, label: "7 Tage" }, { tage: 30, label: "30 Tage" }, { tage: 90, label: "90 Tage" }, { tage: 180, label: "6 Monate" }, { tage: 365, label: "1 Jahr" }, ]; function KarteBestandsverlauf({ tage = 90 }) { const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]); if (fehler) return
{fehler}
; if (!daten) return ; return ( zeitLabel(p.at, tage))} serien={[{ name: "Artikeleinheiten", color: tokenFarbe("--accent", "#3f51b5"), werte: daten.map((p) => p.article_units), }]} einheit="Einheiten" /> ); } /** * Verlauf eines Artikels. Welcher gemeint ist, steht in den Karten-Props und * ist damit gespeichert - frueher lag die Wahl nur in useState und war nach * jedem Neuladen wieder weg, weshalb sich damit nichts beobachten liess. */ function KarteArtikelverlauf({ tage = 90, props: karteProps }) { const gewaehlt = karteProps?.product_id ?? ""; const { daten, fehler } = useDaten( () => (gewaehlt ? api.dashboardTimeline(tage, gewaehlt) : Promise.resolve([])), [gewaehlt, tage], ); if (!gewaehlt) { return
Kein Artikel gewählt – über „Anordnen“ einstellen.
; } return (
{fehler ? (
{fehler}
) : ( zeitLabel(p.at, tage))} serien={[{ name: "Bestand", color: tokenFarbe("--accent", "#3f51b5"), werte: (daten || []).map((p) => p.article_units), }]} einheit="Einheiten" /> )}
); } /** * Ein- und Auslagerungen als Brücke: die Bestandslinie plus die Balken, die * jeden ihrer Sprünge erklären. Die Mengen kommen deshalb in Artikeleinheiten * und nicht als Anzahl Vorgänge – sonst lägen Balken und Linie auf zwei Skalen. */ function KarteBewegungsverlauf({ tage = 30 }) { const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]); if (fehler) return
{fehler}
; if (!daten) return ; return ( ({ ...p, label: zeitLabel(p.at, tage) }))} einheit="Einheiten" /> ); } // ---------------------------------------------------------------- Verzeichnis /** * Höhen sind Rasterzeilen à 40 px (plus 16 px Abstand je weiterer Zeile). * Die Mindesthöhe ist bewusst so gewählt, dass der Inhalt ohne Rollbalken * hineinpasst – vorher war sie gleich der Standardhöhe und beide zu klein. * * nutzbare Höhe = h * 40 + (h - 1) * 16 − 41 (Kopf) − 32 (Innenabstand) * h=3 → 79 px h=4 → 119 px h=6 → 199 px h=7 → 239 px */ export const KARTEN = { actions: { titel: "Schnellzugriff", icon: "checkin", vorschau: "aktion", beschreibung: "Knöpfe zum Ein- und Auslagern", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 }, komponente: KarteAktionen, }, "checkin-quick": { titel: "Einlagern", icon: "checkin", vorschau: "aktion", beschreibung: "Artikel suchen, Menge eintragen – ohne Seitenwechsel", standard: { w: 4, h: 4 }, min: { w: 3, h: 4 }, komponente: () => , }, "checkout-quick": { titel: "Auslagern", icon: "checkout", vorschau: "aktion", beschreibung: "Artikel suchen, Menge entnehmen – ohne Seitenwechsel", standard: { w: 4, h: 4 }, min: { w: 3, h: 4 }, komponente: () => , }, "product-stock": { titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl", beschreibung: "Bestand eines festen Artikels – mehrfach verwendbar", standard: { w: 3, h: 3 }, min: { w: 2, h: 3 }, config: [{ key: "product_id", type: "product", label: "Artikel" }], komponente: KarteArtikel, }, status: { titel: "Status", icon: "dashboard", vorschau: "kennzahlen", beschreibung: "Bald ablaufend, abgelaufen, nachzukaufen", standard: { w: 8, h: 3 }, min: { w: 4, h: 3 }, komponente: KarteStatus, }, "kpi-products": { titel: "Artikel mit Bestand", icon: "package", vorschau: "kennzahl", beschreibung: "Anzahl verschiedener Artikel im Lager", standard: { w: 3, h: 3 }, min: { w: 2, h: 3 }, komponente: KarteArtikelzahl, }, "kpi-units": { titel: "Artikeleinheiten", icon: "box", vorschau: "kennzahl", beschreibung: "Summe aller Gebinde im Lager", standard: { w: 3, h: 3 }, min: { w: 2, h: 3 }, komponente: KarteEinheiten, }, expiring: { titel: "Bald ablaufend", icon: "clock", vorschau: "tabelle", beschreibung: "Nur Chargen innerhalb der Warnfrist – ohne bereits abgelaufene", standard: { w: 6, h: 6 }, min: { w: 4, h: 4 }, komponente: () => , }, expired: { titel: "Abgelaufen", icon: "alert", vorschau: "tabelle", beschreibung: "Nur überfällige Chargen", standard: { w: 6, h: 5 }, min: { w: 4, h: 4 }, komponente: () => , }, "expiry-all": { titel: "Ablauf-Übersicht", icon: "clock", vorschau: "tabelle", beschreibung: "Abgelaufen und bald ablaufend in einer Liste", standard: { w: 6, h: 6 }, min: { w: 4, h: 4 }, komponente: () => , }, shopping: { titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle", beschreibung: "Produkte und Gruppen unter Mindestbestand", standard: { w: 6, h: 6 }, min: { w: 4, h: 4 }, config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }], komponente: KarteEinkaufsliste, }, loans: { titel: "Verliehen", icon: "loan", vorschau: "tabelle", beschreibung: "Welche Einzelstücke gerade draußen sind und bei wem", standard: { w: 6, h: 5 }, min: { w: 4, h: 4 }, komponente: () => , }, "loans-overdue": { titel: "Überfällige Verleihe", icon: "alert", vorschau: "tabelle", beschreibung: "Nur Stücke, deren Rückgabedatum vorbei ist", standard: { w: 6, h: 4 }, min: { w: 4, h: 3 }, komponente: () => , }, movements: { titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle", beschreibung: "Wer hat zuletzt was ein- oder ausgelagert", standard: { w: 6, h: 6 }, min: { w: 4, h: 4 }, komponente: KarteBewegungen, }, "expiry-donut": { titel: "Ablauf-Anteile", icon: "clock", vorschau: "ring", beschreibung: "Ring: in Ordnung, bald ablaufend, abgelaufen", standard: { w: 4, h: 6 }, min: { w: 3, h: 5 }, config: [{ key: "mit_ohne_mhd", type: "boolean", label: "„Ohne MHD“ einbeziehen", default: true }], komponente: KarteAblaufRing, }, "category-donut": { titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring", beschreibung: "Ring: welche Kategorie wie viel ausmacht", standard: { w: 4, h: 6 }, min: { w: 3, h: 5 }, config: [ { key: "tiefe", type: "select", label: "Kategorie-Tiefe", hint: "wie weit Unterkategorien zusammengefasst werden", default: "", optionen: [ { value: "", label: "Feinste (jede Kategorie einzeln)" }, { value: "1", label: "Nur oberste Stufe" }, { value: "2", label: "Bis 2. Stufe" }, { value: "3", label: "Bis 3. Stufe" }, ] }, { key: "detail", type: "boolean", label: "Detailansicht (alle Kategorien einzeln, keine Gruppe „Andere“)" }, ], komponente: KarteKategorienRing, }, "category-bars": { titel: "Kategorien nach Zustand", icon: "tag", vorschau: "balken", beschreibung: "Balken je Kategorie, nach Ablauf aufgeteilt", standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, komponente: KarteKategorienSaeulen, }, "stock-timeline": { titel: "Bestandsverlauf", icon: "history", vorschau: "linie", beschreibung: "Artikeleinheiten im Lager über 90 Tage", zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, config: [{ type: "zeitraum", label: "Zeitraum" }], komponente: KarteBestandsverlauf, }, "product-timeline": { titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie", beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar", zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 }, config: [ { key: "product_id", type: "product", label: "Artikel" }, { type: "zeitraum", label: "Zeitraum" }, ], komponente: KarteArtikelverlauf, }, "activity-timeline": { titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall", beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung", zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 }, config: [{ type: "zeitraum", label: "Zeitraum" }], komponente: KarteBewegungsverlauf, }, }; export const KARTEN_IDS = Object.keys(KARTEN);