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:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user