Artikelauswahl im Dashboard: Marke dazu, Suche davor

Die Auswahl zeigte nur den Namen. In einem Vorrat mit mehreren
Herstellern standen damit zwei Zeilen "Pesto Rosso" untereinander, ohne
jeden Unterschied - man konnte nur raten und hinterher nachsehen, ob die
richtige Karte entstanden ist. Dazu war es eine blanke Liste ueber den
ganzen Katalog, hunderte Zeilen lang.

Jetzt steht in jeder Zeile "Name - Marke - Gebindegroesse", und darueber
ein Suchfeld, das wortweise filtert ("pesto mutti" findet eins von
vier). Der bereits gewaehlte Artikel bleibt in der Liste, auch wenn die
Suche ihn ausblendet - sonst zeigte das Feld nichts an und ein Klick
daneben haette die Wahl verworfen.

Der Kartentitel traegt die Marke jetzt ebenfalls, sonst hiessen zwei
Karten weiterhin gleich. Die Gebindegroesse bleibt dort weg, sie waere
im Titel zu lang; dafuer gibt es den Schalter an artikelLabel().

Die Beschriftung liegt in units.js, weil sie ueberall gebraucht wird, wo
Artikel zur Auswahl stehen - zwei Fassungen davon liefen sonst
auseinander.

Von Hand geprueft: zwei gleichnamige Pesto angelegt, Auswahl
unterscheidet sie, Suche kuerzt auf einen, Titel liest "Pesto Rosso -
Mutti". Suite bei 517.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 15:23:48 +02:00
co-authored by Claude Opus 5
parent 2e610677aa
commit 4972feef09
3 changed files with 80 additions and 9 deletions
+57 -8
View File
@@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
import Icon from "../components/Icon";
import { ZEITRAEUME } from "./cards";
import { locationOptions } from "../locationPath";
import { artikelLabel } from "../units";
/**
* Konfig-Dialog einer Karte – wie in HomeAssistant: ein eigener Dialog mit den
@@ -17,14 +19,9 @@ export default function KartenConfig({
function feld(f) {
if (f.type === "product") {
return (
<label key={f.key}>
{f.label}
<select value={karteProps[f.key] ?? ""}
onChange={(e) => onProps({ [f.key]: e.target.value ? Number(e.target.value) : undefined })}>
<option value="">– Artikel wählen –</option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
<ArtikelWahl key={f.key} label={f.label} produkte={produkte}
wert={karteProps[f.key] ?? ""}
onWahl={(id) => onProps({ [f.key]: id ?? undefined })} />
);
}
if (f.type === "location") {
@@ -91,3 +88,55 @@ export default function KartenConfig({
</div>
);
}
/**
* Artikel aussuchen – mit Suchfeld und sprechenden Zeilen.
*
* Eine blanke Auswahlliste ueber den ganzen Katalog ist unbrauchbar: Sie ist
* hunderte Zeilen lang, und weil nur der Name darin stand, standen "Pesto
* Rosso" und "Pesto Rosso" untereinander, ohne Unterschied. Jetzt steht Marke
* und Gebindegroesse dabei, und das Suchfeld daruber kuerzt die Liste auf das,
* was man meint.
*/
function ArtikelWahl({ label, produkte, wert, onWahl }) {
const [suche, setSuche] = useState("");
const gefiltert = useMemo(() => {
const begriffe = suche.toLowerCase().split(/\s+/).filter(Boolean);
if (begriffe.length === 0) return produkte;
return produkte.filter((p) => {
const text = artikelLabel(p).toLowerCase();
return begriffe.every((b) => text.includes(b));
});
}, [produkte, suche]);
// Der gewaehlte Artikel muss in der Liste bleiben, auch wenn die Suche ihn
// gerade ausblendet - sonst zeigt das Feld nichts an und ein Klick daneben
// wuerde die Wahl verwerfen.
const gewaehlt = produkte.find((p) => String(p.id) === String(wert));
const sichtbar = gewaehlt && !gefiltert.includes(gewaehlt)
? [gewaehlt, ...gefiltert]
: gefiltert;
return (
<div>
<label>
{label}
<input value={suche} placeholder="suchen … (Name oder Marke)"
onChange={(e) => setSuche(e.target.value)} />
</label>
<select value={wert} style={{ marginTop: "var(--sp-2)" }}
onChange={(e) => onWahl(e.target.value ? Number(e.target.value) : null)}>
<option value="">– Artikel wählen –</option>
{sichtbar.map((p) => (
<option key={p.id} value={p.id}>{artikelLabel(p)}</option>
))}
</select>
<p className="muted small mt-0">
{suche
? `${gefiltert.length} von ${produkte.length} Artikeln`
: `${produkte.length} Artikel`}
</p>
</div>
);
}
+6 -1
View File
@@ -9,6 +9,7 @@ import { useToast } from "../toast";
import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
import KartenConfig from "../dashboard/KartenConfig";
import Vorschau from "../dashboard/Vorschau";
import { artikelLabel } from "../units";
const Raster = WidthProvider(GridLayout);
@@ -380,7 +381,11 @@ export default function Dashboard() {
const ort = eintrag.props?.location_id
? locations.find((l) => l.id === eintrag.props.location_id)
: null;
const zusatz = artikel?.name || ort?.name; // im Titel gezeigt
// Im Titel steht die Marke mit, sonst heissen zwei Karten gleich,
// wenn zwei Hersteller denselben Artikelnamen fuehren. Die
// Gebindegroesse bliebe dort zu lang - die steht in der Auswahl.
const zusatz = (artikel ? artikelLabel(artikel, { groesse: false }) : "")
|| ort?.name;
return (
<section key={eintrag.i} className="card dash-card">
<div className="dash-card-head">
+17
View File
@@ -114,6 +114,23 @@ export function stockLabelMitZweit(p) {
return zweit ? `${stockLabel(p)} · ${zweit}` : stockLabel(p);
}
/**
* Artikelname, wie man ihn in einer Auswahlliste braucht: "Pesto Rosso ·
* Barilla · 190 g".
*
* Der blosse Name reicht nicht. Ein Vorrat enthaelt denselben Artikel von
* mehreren Herstellern und in mehreren Groessen; zwei Zeilen "Pesto Rosso"
* untereinander sind dann nicht zu unterscheiden, und man waehlt die falsche.
* Marke und Gebindegroesse sind genau die beiden Angaben, die sie trennen.
*/
export function artikelLabel(p, { groesse = true } = {}) {
if (!p) return "";
const gebinde = groesse && p.package_size > 0
? `${fmt(p.package_size)} ${unitShort(p.base_unit)}`
: "";
return [p.name, p.brand, gebinde].filter(Boolean).join(" · ");
}
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
export function unitOptions(product) {
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];