Web: Dashboards in der Seitenleiste, Karten mehrfach, Buchen ohne Seitenwechsel

Drei Dinge in einem, weil sie dieselbe Umstellung brauchen.

Karten sind jetzt Instanzen: Die Anordnung merkt sich neben der Kennung auch
die Art und ein "props" je Karte. Dadurch laesst sich dieselbe Art mehrfach
auflegen, und "Verlauf eines Artikels" merkt sich endlich, welcher Artikel
gemeint ist - vorher lag diese Wahl nur in useState und war nach jedem
Neuladen wieder weg, was die Karte zum Beobachten unbrauchbar machte. Wer
mehrere Artikel im Blick haben will, legt jetzt einfach mehrere Karten an.

Mehrere Dashboards stehen als eingerueckte Eintraege unter "Übersicht" in der
Seitenleiste, anlegen und umbenennen laeuft ueber die Kopfzeile. Ab dem
zweiten werden sie aufgezaehlt; bei einem einzigen waere die Liste nur eine
Verdopplung.

Neu sind die Karten "Einlagern" und "Auslagern": Artikel suchen, Menge
eintragen, gebucht. MHD und Lagerort bleiben bewusst weg - wer die braucht,
ist auf der vollen Seite besser aufgehoben. Die alte Karte "Schnellzugriff"
bleibt als reine Verlinkung bestehen.

Der Bestaetigungsdialog kann jetzt auch nach Text fragen (prompt: true). Das
war noetig, weil Dashboards benannt werden - und window.prompt sieht aus wie
der Browser, nicht wie diese Anwendung; derselbe Grund, aus dem es hier schon
kein window.confirm gibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 19:18:51 +02:00
parent f62289f975
commit 89598147ed
6 changed files with 486 additions and 79 deletions

View File

@@ -57,6 +57,145 @@ function zeitLabel(iso, tage) {
}
// ---------------------------------------------------------------- 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 {
// "article" ist die Einheit, in der der Artikel gefuehrt wird (Gebinde
// oder Basiseinheit) - genau das, was in der Karte steht.
if (einlagern) {
await api.checkInBatch({
product_id: artikel.id, unit: "article", lines: [{ quantity: zahl }],
});
} else {
await api.checkOut({ product_id: artikel.id, quantity: zahl, unit: "article" });
}
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">
@@ -330,29 +469,24 @@ function KarteBestandsverlauf({ tage = 90 }) {
);
}
function KarteArtikelverlauf({ tage = 90 }) {
const [produkte, setProdukte] = useState([]);
const [gewaehlt, setGewaehlt] = useState("");
/**
* 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],
);
useEffect(() => {
api.listProducts().then((liste) => {
setProdukte(liste);
if (liste.length && !gewaehlt) setGewaehlt(String(liste[0].id));
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!gewaehlt) {
return <div className="empty">Kein Artikel gewählt über Anordnen einstellen.</div>;
}
return (
<div className="card-stack">
<select value={gewaehlt} onChange={(e) => setGewaehlt(e.target.value)}
style={{ marginTop: 0 }}>
<option value=""> Artikel wählen </option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{fehler ? (
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
) : (
@@ -405,6 +539,25 @@ export const KARTEN = {
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",
@@ -479,8 +632,9 @@ export const KARTEN = {
},
"product-timeline": {
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
beschreibung: "Ein wählbarer Artikel über 90 Tage",
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": {