Files
Vorrania/web/src/dashboard/cards.jsx
T
ScarriffleandClaude Opus 5 1332dd86fa Web: Einzelstuecke verleihen, auch direkt nach dem Scannen
Verleihen und Zuruecknehmen in der Einzelstueck-Tabelle eines Artikels und
in der Liste aller Einzelstuecke, dort als Spalten "Verliehen an" und
"Zurueck bis" (filter- und sortierbar). Ueberfaellig ist rot, demnaechst
faellig gelb - dieselben Zustandsfarben wie beim MHD.

Die Scan-Zielseite /i/<uid> leitete vorher nur zum Artikel weiter. Fuer
"scannen und verleihen" braucht die Aktion aber genau dort ihren Platz:
Die Seite zeigt jetzt, was man in der Hand haelt - Stueck, Artikel,
Lagerort, Verleihzustand - und bietet Verleihen bzw. Zuruecknehmen an.
Gegenstueck zur Lagerort-Seite /l/<code>, die es fuers Einlagern genauso
macht.

Anzeige und Aktionen liegen einmal in components/Verleih.jsx, weil alle
drei Stellen dasselbe brauchen. Der Knopf haengt nicht an isAdmin:
Verleihen ist eine Buchung wie Ein- und Auslagern.

Dazu zwei Dashboard-Karten: alles Verliehene und, getrennt, nur das
Ueberfaellige. Stuecke ohne Rueckgabedatum sind nie faellig und tauchen in
der zweiten Karte nicht auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:30:14 +02:00

788 lines
30 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 { 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({}); // abgehakte Zeilen (nur in dieser Sitzung)
const { daten, fehler } = useDaten(async () => {
const [produkte, gruppen, orte] = await Promise.all([
api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(),
]);
return { produkte, gruppen, orte };
});
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) 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);
if (gesamtLeer && orteLeer) return <div className="empty">Alle Mindestbestände erreicht.</div>;
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val }));
// 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">
{!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);