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 (
);
}
/**
* 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 (
)}
);
}
/**
* 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
;
}
// 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) => (
);
}
/**
* 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}
) : (
Stück
Verliehen an
Zurück
{zeilen.map((v) => (
{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. */}
>
);
}
/**
* 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.
);
}
/**
* 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