Web: alle Kategorie-Auswahlen als aufklappbarer Baum
Statt der nativen "— —"-Dropdowns nutzen jetzt auch Produktformular (Kategorie + Inline-"Neue Kategorie"-Elternwahl), Einlagern-Maske und Produkte-Filter den CategorySelect-Baum. CategorySelect um optionale Zusatz-Eintraege (extraOptions) erweitert, damit der Produkte-Filter "Alle Kategorien"/"Ohne Kategorie" weiter anbietet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ export default function CategorySelect({
|
|||||||
onChange,
|
onChange,
|
||||||
nodes,
|
nodes,
|
||||||
rootLabel = "– oberste Ebene –",
|
rootLabel = "– oberste Ebene –",
|
||||||
|
// Zusätzliche Einträge über dem Baum, z.B. { value: 0, label: "Ohne Kategorie" }
|
||||||
|
// für Filter. Ihr Wert wird unverändert an onChange gereicht.
|
||||||
|
extraOptions = [],
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -29,8 +32,10 @@ export default function CategorySelect({
|
|||||||
const idset = new Set(nodes.map((n) => n.id));
|
const idset = new Set(nodes.map((n) => n.id));
|
||||||
const hasKids = (id) => nodes.some((n) => n.parent_id === id);
|
const hasKids = (id) => nodes.some((n) => n.parent_id === id);
|
||||||
|
|
||||||
const selected = value == null || value === "" ? null : Number(value);
|
const isRoot = value == null || value === "";
|
||||||
const label = selected == null ? rootLabel : nameById[selected] ?? rootLabel;
|
const extra = extraOptions.find((o) => String(o.value) === String(value));
|
||||||
|
const selectedId = !isRoot && !extra ? Number(value) : null;
|
||||||
|
const label = isRoot ? rootLabel : extra ? extra.label : nameById[selectedId] ?? rootLabel;
|
||||||
|
|
||||||
function place() {
|
function place() {
|
||||||
const r = btnRef.current?.getBoundingClientRect();
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
@@ -101,7 +106,7 @@ export default function CategorySelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
<span className={selected == null ? "muted" : ""}>{label}</span>
|
<span className={isRoot ? "muted" : ""}>{label}</span>
|
||||||
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
|
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -113,12 +118,23 @@ export default function CategorySelect({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tree-pop-opt ${selected == null ? "sel" : ""}`}
|
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
||||||
style={{ paddingLeft: 12 }}
|
style={{ paddingLeft: 12 }}
|
||||||
onClick={() => pick(null)}
|
onClick={() => pick(null)}
|
||||||
>
|
>
|
||||||
{rootLabel}
|
{rootLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{extraOptions.map((o) => (
|
||||||
|
<button
|
||||||
|
key={String(o.value)}
|
||||||
|
type="button"
|
||||||
|
className={`tree-pop-opt ${extra && String(extra.value) === String(o.value) ? "sel" : ""}`}
|
||||||
|
style={{ paddingLeft: 12 }}
|
||||||
|
onClick={() => pick(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{visible.map((n) => {
|
{visible.map((n) => {
|
||||||
const kids = hasKids(n.id);
|
const kids = hasKids(n.id);
|
||||||
const zu = collapsed.has(n.id);
|
const zu = collapsed.has(n.id);
|
||||||
@@ -139,7 +155,7 @@ export default function CategorySelect({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tree-pop-opt ${selected === n.id ? "sel" : ""}`}
|
className={`tree-pop-opt ${selectedId === n.id ? "sel" : ""}`}
|
||||||
onClick={() => pick(n.id)}
|
onClick={() => pick(n.id)}
|
||||||
>
|
>
|
||||||
{n.name}
|
{n.name}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ProduktThumb } from "../components/ProduktBild";
|
|||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { suggestionToProduct } from "../offUtils";
|
import { suggestionToProduct } from "../offUtils";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
||||||
|
|
||||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||||
@@ -138,12 +139,12 @@ export default function CheckIn() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Kategorie
|
Kategorie
|
||||||
<select value={newCategoryId} onChange={(e) => setNewCategoryId(e.target.value)}>
|
<CategorySelect
|
||||||
<option value="">– keine –</option>
|
value={newCategoryId === "" ? null : Number(newCategoryId)}
|
||||||
{asTree(categories).map((c) => (
|
nodes={asTree(categories)}
|
||||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
rootLabel="– keine –"
|
||||||
))}
|
onChange={(id) => setNewCategoryId(id == null ? "" : String(id))}
|
||||||
</select>
|
/>
|
||||||
<span className="muted small">Nur für den Überblick in der Artikelliste.</span>
|
<span className="muted small">Nur für den Überblick in der Artikelliste.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ProduktBild from "../components/ProduktBild";
|
|||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import { DynamicFields, FIELD_TYPES } from "../fields";
|
import { DynamicFields, FIELD_TYPES } from "../fields";
|
||||||
import ObjektBestand from "../components/ObjektBestand";
|
import ObjektBestand from "../components/ObjektBestand";
|
||||||
import Einzelstuecke from "../components/Einzelstuecke";
|
import Einzelstuecke from "../components/Einzelstuecke";
|
||||||
@@ -696,15 +697,13 @@ export default function ProductForm() {
|
|||||||
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
|
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
|
||||||
Kategorie
|
Kategorie
|
||||||
</span>
|
</span>
|
||||||
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
|
<CategorySelect
|
||||||
disabled={readOnly}>
|
value={form.category_id === "" ? null : Number(form.category_id)}
|
||||||
<option value="">– keine –</option>
|
nodes={asTree(categories).filter((c) => c.tracking === modus)}
|
||||||
{asTree(categories).filter((c) => c.tracking === modus).map((c) => (
|
rootLabel="– keine –"
|
||||||
<option key={c.id} value={c.id}>
|
disabled={readOnly}
|
||||||
{"— ".repeat(c.depth)}{c.name}
|
onChange={(id) => set("category_id", id == null ? "" : String(id))}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="grow" style={{ display: "flex", alignItems: "flex-end" }}>
|
<div className="grow" style={{ display: "flex", alignItems: "flex-end" }}>
|
||||||
@@ -729,13 +728,12 @@ export default function ProductForm() {
|
|||||||
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
|
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">Untergeordnet (optional)
|
<label className="grow">Untergeordnet (optional)
|
||||||
<select value={newCat.parent_id}
|
<CategorySelect
|
||||||
onChange={(e) => setNewCat({ ...newCat, parent_id: e.target.value })}>
|
value={newCat.parent_id === "" ? null : Number(newCat.parent_id)}
|
||||||
<option value="">– oberste Ebene –</option>
|
nodes={asTree(categories).filter((c) => c.tracking === modus)}
|
||||||
{asTree(categories).filter((c) => c.tracking === modus).map((c) => (
|
rootLabel="– oberste Ebene –"
|
||||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
onChange={(id) => setNewCat({ ...newCat, parent_id: id == null ? "" : String(id) })}
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn primary" onClick={createCategoryInline}>
|
<button type="button" className="btn primary" onClick={createCategoryInline}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Icon from "../components/Icon";
|
|||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { fmt, gebinde, unitShort } from "../units";
|
import { fmt, gebinde, unitShort } from "../units";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
import CategorySelect from "../components/CategorySelect";
|
||||||
|
|
||||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||||
function unitCount(p) {
|
function unitCount(p) {
|
||||||
@@ -83,14 +84,14 @@ export default function Products() {
|
|||||||
<label className="grow" style={{ margin: 0 }}>
|
<label className="grow" style={{ margin: 0 }}>
|
||||||
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder="Produkt suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label style={{ margin: 0 }}>
|
<label style={{ margin: 0, width: 230 }}>
|
||||||
<select value={categoryId} onChange={(e) => { setCategoryId(e.target.value); load(e.target.value); }}>
|
<CategorySelect
|
||||||
<option value="">Alle Kategorien</option>
|
value={categoryId}
|
||||||
<option value="0">Ohne Kategorie</option>
|
nodes={asTree(categories).filter((c) => !typ || c.tracking === typ)}
|
||||||
{asTree(categories).filter((c) => !typ || c.tracking === typ).map((c) => (
|
rootLabel="Alle Kategorien"
|
||||||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
extraOptions={[{ value: 0, label: "Ohne Kategorie" }]}
|
||||||
))}
|
onChange={(id) => { const v = id == null ? "" : String(id); setCategoryId(v); load(v); }}
|
||||||
</select>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user