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:
Scarriffle
2026-07-26 13:07:36 +02:00
parent 956a472c04
commit dc66a6d7ff
4 changed files with 51 additions and 35 deletions

View File

@@ -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}

View File

@@ -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">

View File

@@ -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}>

View File

@@ -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>