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>
788 lines
30 KiB
React
788 lines
30 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({}); // 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);
|