Der Knopf im Rezept schrieb die fehlenden Zutaten weg, aber auf der Uebersicht blieb davon nichts zu sehen: Die Karte "Einkaufsliste" holte die drei GERECHNETEN Quellen einzeln und wusste von den gespeicherten Eintraegen nichts. Wer die Uebersicht als Zettel benutzt, hat sie schlicht nicht gesehen. Die Karte holt jetzt alles mit einem Aufruf und zeigt oben den Abschnitt "Selbst draufgesetzt" - eigene Eintraege und die aus einem Rezept uebernommenen, mit Herkunft und Menge. Abgehakte verschwinden dort, die Karte ist zum Einkaufen da und nicht zum Nachlesen. Das Abhaken war in der Karte reiner Sitzungszustand und damit nach dem Neuladen weg. Es benutzt jetzt dieselben Schluessel und dieselben Routen wie die volle Seite: Der Haken wandert sofort, speichert im Hintergrund, und springt zurueck, wenn das schiefgeht. Dazu die Kennzahl "Nachzukaufen": Sie zaehlte nur die gerechneten Zeilen und meldete "alles erreicht", waehrend auf der Liste fuenf Posten standen. Offene eigene Eintraege zaehlen jetzt mit, abgehakte nicht - die sind gekauft. Von Hand geprueft: Zwei Eintraege angelegt, Kennzahl springt auf 2, die Karte zeigt beide samt "2 Packungen", und ein Haken aus der Karte steht anschliessend in der Datenbank. 1 neuer Test, Suite bei 517. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
846 lines
32 KiB
React
846 lines
32 KiB
React
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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (daten == null) return <CardSkeleton variant={skelett} />;
|
||
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||
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 (
|
||
<div className="aktion-gewaehlt">
|
||
<span className="name">{gewaehlt.name}</span>
|
||
<button className="btn-icon" title="Andere wählen"
|
||
onClick={() => { onWaehlen(null); setText(""); }}>
|
||
<Icon name="close" size={15} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="aktion-suche">
|
||
<input value={text} onChange={(e) => setText(e.target.value)}
|
||
placeholder="Artikel suchen…" onMouseDown={(e) => e.stopPropagation()} />
|
||
{treffer.length > 0 && (
|
||
<ul className="aktion-treffer">
|
||
{treffer.map((p) => (
|
||
<li key={p.id}>
|
||
<button onClick={() => { onWaehlen(p); setText(""); }}>{p.name}</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div className="card-stack aktion-karte">
|
||
<Artikelsuche gewaehlt={artikel} onWaehlen={(p) => {
|
||
setArtikel(p); setMeldung(null); setFehler(null);
|
||
}} />
|
||
<div className="aktion-zeile">
|
||
<input className="aktion-menge" value={menge} inputMode="decimal"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onChange={(e) => setMenge(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") buchen(); }} />
|
||
<button className={`btn ${einlagern ? "primary" : ""}`} disabled={!bereit} onClick={buchen}>
|
||
<Icon name={einlagern ? "checkin" : "checkout"} size={16} />
|
||
{einlagern ? "Einlagern" : "Auslagern"}
|
||
</button>
|
||
</div>
|
||
{meldung && <div className="alert ok">{meldung}</div>}
|
||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||
}
|
||
return (
|
||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||
{daten && (
|
||
<div className="card-stack">
|
||
<div className="kpi">
|
||
<div className="kpi-label"><Icon name="package" size={14} />{daten.name}</div>
|
||
<div className="kpi-value">{articlePrimary(daten)}</div>
|
||
</div>
|
||
<div className="muted small">{articleSecondary(daten)}</div>
|
||
</div>
|
||
)}
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
function KarteAktionen() {
|
||
return (
|
||
<div className="card-actions">
|
||
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
|
||
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Kennzahlen
|
||
function Kennzahl({ wert, label, icon, ton }) {
|
||
return (
|
||
<div className={`kpi ${ton || ""}`}>
|
||
<div className="kpi-label"><Icon name={icon} size={14} />{label}</div>
|
||
<div className="kpi-value">{wert}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KarteStatus() {
|
||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||
return (
|
||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||
<div className="kpi-row">
|
||
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||
ton={daten?.expiring_soon ? "warn" : ""} />
|
||
<Kennzahl icon="alert" label="Abgelaufen" wert={daten?.expired ?? 0}
|
||
ton={daten?.expired ? "danger" : ""} />
|
||
<Kennzahl icon="cart" label="Nachzukaufen" wert={daten?.shopping_items ?? 0} />
|
||
</div>
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
function KarteArtikelzahl() {
|
||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||
return (
|
||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
function KarteEinheiten() {
|
||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||
return (
|
||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 (
|
||
<Zustand fehler={fehler} daten={daten} leer={ABLAUF_LEER[modus]}>
|
||
{zeilen.length === 0 ? (
|
||
<div className="empty">{ABLAUF_LEER[modus]}</div>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{zeilen.map((it) => (
|
||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
|
||
<td data-label="Produkt">{it.product_name}</td>
|
||
<td data-label="Menge" className="num">
|
||
{articlePrimary(it)}
|
||
{articleSecondary(it) && <div className="muted small">{articleSecondary(it)}</div>}
|
||
</td>
|
||
<td data-label="MHD">{formatBestBefore(it.best_before, it.best_before_precision)}</td>
|
||
<td data-label="Frist">
|
||
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
|
||
{relativeExpiry(it.days_left)}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten || erledigt == null) return <CardSkeleton variant="liste" />;
|
||
|
||
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 <div className="empty">Alle Mindestbestände erreicht.</div>;
|
||
}
|
||
|
||
// 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) => (
|
||
<ul className="checklist">
|
||
{gruppen.map((it) => {
|
||
const key = `${prefix}g${it.group_id}`;
|
||
return (
|
||
<li key={key} className={erledigt[key] ? "done" : ""}>
|
||
<label>
|
||
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||
<span className="badge accent">Gruppe</span>
|
||
{it.subgroup_count > 0 && (
|
||
<span className="badge" title="Untergruppen sind mitgezählt.">+{it.subgroup_count}</span>
|
||
)}
|
||
<span className="item-name">{it.name}</span>
|
||
</label>
|
||
<span className="muted small">
|
||
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
{produkte.map((it) => {
|
||
const key = `${prefix}p${it.product_id}`;
|
||
return (
|
||
<li key={key} className={erledigt[key] ? "done" : ""}>
|
||
<label>
|
||
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||
<span className="item-name">{it.name}</span>
|
||
</label>
|
||
<span className="muted small">
|
||
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
|
||
return (
|
||
<div className="card-stack">
|
||
{offeneEintraege.length > 0 && (
|
||
<div>
|
||
<div className="muted small" style={{ fontWeight: 600 }}>Selbst draufgesetzt</div>
|
||
<ul className="checklist">
|
||
{offeneEintraege.map((e) => (
|
||
<li key={`e${e.id}`}>
|
||
<label>
|
||
<input type="checkbox" checked={e.done}
|
||
onChange={(ev) => eintragAbhaken(e, ev.target.checked)} />
|
||
{e.art === "group" && <span className="badge accent">Gruppe</span>}
|
||
{e.art === "text" && <span className="badge">Notiz</span>}
|
||
<span className="item-name">{e.name}</span>
|
||
</label>
|
||
<span className="muted small nowrap">
|
||
{[e.recipe_name ? `für ${e.recipe_name}` : "", e.menge_text]
|
||
.filter(Boolean).join(" · ")}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{!gesamtLeer && (
|
||
<div>
|
||
<div className="muted small" style={{ fontWeight: 600 }}>Überall</div>
|
||
{abschnitt(daten.gruppen, daten.produkte, "")}
|
||
</div>
|
||
)}
|
||
{orte.map((o) => ((o.products.length || o.groups.length) ? (
|
||
<div key={o.location_id}>
|
||
<div className="muted small" style={{ fontWeight: 600 }}>
|
||
<Icon name="location" size={13} /> {o.location_name}
|
||
</div>
|
||
{abschnitt(o.groups, o.products, `l${o.location_id}`)}
|
||
</div>
|
||
) : null))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 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 (
|
||
<Zustand fehler={fehler} daten={daten} leer={leer}>
|
||
{zeilen.length === 0 ? (
|
||
<div className="empty">{leer}</div>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr><th>Stück</th><th>Verliehen an</th><th>Zurück</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{zeilen.map((v) => (
|
||
<tr key={v.id} className={verleihRowClass(v)}>
|
||
<td data-label="Stück">
|
||
<Link to={`/i/${encodeURIComponent(v.item_uid || "")}`}>
|
||
{v.product_name || v.item_uid}
|
||
</Link>
|
||
{v.product_name && <div className="muted small">{v.item_uid}</div>}
|
||
</td>
|
||
<td data-label="Verliehen an">
|
||
{v.borrower}
|
||
{v.reason && <div className="muted small">{v.reason}</div>}
|
||
</td>
|
||
<td data-label="Zurück">
|
||
{v.due_on
|
||
? <FristBadge loan={v} />
|
||
: <span className="muted small">offen</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
function KarteBewegungen() {
|
||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||
return (
|
||
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
|
||
{/* 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. */}
|
||
<div className="bewegung-liste">
|
||
{(daten || []).map((m) => (
|
||
<div className="bewegung-zeile" key={m.id}>
|
||
<BewegungIcon typ={m.type} />
|
||
<div className="text">
|
||
<div className="zeit">
|
||
{new Date(m.created_at).toLocaleString("de-DE", {
|
||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||
})}
|
||
</div>
|
||
<div className="produkt">{m.product_name}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Zustand>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten) return <CardSkeleton variant="chart" />;
|
||
|
||
// 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 <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||
}
|
||
|
||
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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten) return <CardSkeleton variant="chart" />;
|
||
|
||
// 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 <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||
}
|
||
|
||
function KarteKategorienSaeulen() {
|
||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten) return <CardSkeleton variant="chart" />;
|
||
|
||
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 (
|
||
<>
|
||
<ul className="chart-legend inline">
|
||
{[["In Ordnung", "--ok"], ["Bald ablaufend", "--warn"], ["Abgelaufen", "--danger"], ["Ohne MHD", "--muted"]]
|
||
.map(([name, token]) => (
|
||
<li key={name}>
|
||
<span className="swatch" style={{ background: tokenFarbe(token) }} />
|
||
<span className="legend-name">{name}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Bars zeilen={zeilen} einheit="Einheiten" />
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten) return <CardSkeleton variant="chart" />;
|
||
|
||
return (
|
||
<Line
|
||
labels={daten.map((p) => 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 <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="card-stack">
|
||
{fehler ? (
|
||
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
|
||
) : (
|
||
<Line
|
||
labels={(daten || []).map((p) => zeitLabel(p.at, tage))}
|
||
serien={[{
|
||
name: "Bestand",
|
||
color: tokenFarbe("--accent", "#3f51b5"),
|
||
werte: (daten || []).map((p) => p.article_units),
|
||
}]}
|
||
einheit="Einheiten"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||
if (!daten) return <CardSkeleton variant="chart" />;
|
||
|
||
return (
|
||
<Wasserfall
|
||
abschnitte={daten.map((p) => ({ ...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: () => <KarteSchnellbuchung richtung="in" />,
|
||
},
|
||
"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: () => <KarteSchnellbuchung richtung="out" />,
|
||
},
|
||
"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: () => <KarteAblauf modus="soon" />,
|
||
},
|
||
expired: {
|
||
titel: "Abgelaufen", icon: "alert", vorschau: "tabelle",
|
||
beschreibung: "Nur überfällige Chargen",
|
||
standard: { w: 6, h: 5 }, min: { w: 4, h: 4 },
|
||
komponente: () => <KarteAblauf modus="expired" />,
|
||
},
|
||
"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: () => <KarteAblauf modus="alle" />,
|
||
},
|
||
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: () => <KarteVerleih modus="alle" />,
|
||
},
|
||
"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: () => <KarteVerleih modus="ueberfaellig" />,
|
||
},
|
||
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);
|