Files
Vorrania/web/src/dashboard/cards.jsx
Scarriffle d993a48711 Web: "Ablauf-Anteile" – "Ohne MHD" ein-/ausblendbar
Die Ablauf-Ring-Karte bekommt einen Umschalter, ob der Anteil "Ohne MHD"
(Gegenstaende/undatierte Chargen) mitgezaehlt wird. Aus zeigt der Ring nur
In Ordnung/Bald/Abgelaufen mit entsprechend neu berechneten Prozenten. Die Wahl
wird in localStorage gemerkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:46:18 +02:00

669 lines
24 KiB
JavaScript
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 { 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (daten == null) return <div className="empty">Lädt</div>;
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}>
{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}>
<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}>
<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}>
<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>
);
}
function KarteEinkaufsliste() {
const { daten, fehler } = useDaten(async () => {
const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
return { produkte, gruppen };
});
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten.produkte.length && !daten.gruppen.length) {
return <div className="empty">Alle Mindestbestände erreicht.</div>;
}
return (
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
</thead>
<tbody>
{daten.gruppen.map((it) => (
<tr key={`g${it.group_id}`}>
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr>
))}
{daten.produkte.map((it) => (
<tr key={`p${it.product_id}`}>
<td data-label="Bedarf">{it.name}</td>
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
function KarteBewegungen() {
const { daten, fehler } = useDaten(() => api.listMovements(20));
return (
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
<div className="table-wrap">
<table className="table">
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
<tbody>
{(daten || []).map((m) => (
<tr key={m.id}>
<td data-label="Zeitpunkt" className="muted">
{new Date(m.created_at).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</td>
<td data-label="Aktion">
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
{BEWEGUNG_LABEL[m.type] || m.type}
</span>
</td>
<td data-label="Produkt">{m.product_name}</td>
</tr>
))}
</tbody>
</table>
</div>
</Zustand>
);
}
// ---------------------------------------------------------------- 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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
// 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 (
<div className="ring-mit-schalter">
<label className="check-inline ring-schalter">
<input type="checkbox" checked={mitOhneMhd}
onChange={(e) => umschalten(e.target.checked)} />
<span>Ohne MHD" einbeziehen</span>
</label>
<Donut segmente={segmente} gesamtLabel="Einheiten" />
</div>
);
}
function KarteKategorienRing() {
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt…</div>;
const segmente = mitFarben(
daten.map((k) => ({ name: k.name, value: k.article_units })),
istDunkel(),
);
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 <div className="empty">Lädt…</div>;
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 <div className="empty">Lädt…</div>;
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 <div className="empty">Lädt…</div>;
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 },
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: () => <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 },
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);