import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import Icon from "../components/Icon"; import { useSettings } from "../settings"; import { amountText, 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." }) { if (fehler) return
{fehler}
; if (daten == null) return
Lädt…
; 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)}
)}
); } function KarteEinkaufsliste() { const { daten, fehler } = useDaten(async () => { const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]); return { produkte, gruppen }; }); if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; if (!daten.produkte.length && !daten.gruppen.length) { return
Alle Mindestbestände erreicht.
; } return (
{daten.gruppen.map((it) => ( ))} {daten.produkte.map((it) => ( ))}
BedarfBestandFehlt
Gruppe {it.name} {fmt(it.stock)} {it.unit_name} {fmt(it.deficit)} {it.unit_name}
{it.name} {amountText(it.stock, it.package_size, it.base_unit)} {amountText(it.deficit, it.package_size, it.base_unit)}
); } const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" }; function KarteBewegungen() { const { daten, fehler } = useDaten(() => api.listMovements(20)); return (
{(daten || []).map((m) => ( ))}
ZeitpunktAktionProdukt
{new Date(m.created_at).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", })} {BEWEGUNG_LABEL[m.type] || m.type} {m.product_name}
); } // ---------------------------------------------------------------- Diagramme function KarteAblaufRing() { const { daten, fehler } = useDaten(() => api.dashboardExpirySplit()); // Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – gemerkt. const [mitOhneMhd, setMitOhneMhd] = useState( () => localStorage.getItem("ablauf_mit_ohne_mhd") !== "0", ); function umschalten(on) { setMitOhneMhd(on); localStorage.setItem("ablauf_mit_ohne_mhd", on ? "1" : "0"); } if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; // 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() { const { daten, fehler } = useDaten(() => api.dashboardByCategory()); if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; const segmente = mitFarben( daten.map((k) => ({ name: k.name, value: k.article_units })), istDunkel(), ); return ; } function KarteKategorienSaeulen() { const { daten, fehler } = useDaten(() => api.dashboardByCategory()); if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; 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 ( <> ); } /** * 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
Lädt…
; 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
Lädt…
; 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 }, artikel: true, 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 }, komponente: KarteEinkaufsliste, }, 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 }, 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 }, 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 }, 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 }, artikel: true, 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 }, komponente: KarteBewegungsverlauf, }, }; export const KARTEN_IDS = Object.keys(KARTEN);