Lagerorte: ueberall ein aufklappbarer Baum statt flacher Pfadliste

Die Lagerort-Auswahl schrieb in jeder Zeile den ganzen Pfad aus
("Hedingen → Guido Zimmer → Kallax → Fach Oben Links → Oben"). Bei einem
tiefen Baum ist das eine Wand aus Wiederholungen, und Geschwister stehen
nur zufaellig beieinander.

Jetzt dasselbe Auswahlfeld wie bei den Kategorien: eingerueckter Baum,
Aeste ein- und ausklappbar. Geschlossen steht weiter der volle Pfad da -
"Oben" allein waere nicht eindeutig, genau dafuer war der Pfad gedacht.

CategorySelect konnte das schon und war auf Lagerort-Codes vorbereitet
(seine IDs duerfen Zeichenketten sein). Dazu kam nur LocationSelect als
duenne Huelle: Baumaufbau und die Umrechnung "" <-> null, damit die 16
Aufrufstellen weiter mit dem leeren String fuer "kein Ort" arbeiten.

Zwei Kleinigkeiten dabei: CategorySelect bekommt showRoot, weil bei den
Mindestbestaenden "nichts gewaehlt" kein gueltiger Wert ist - dort heisst
die oberste Zeile "Ueberall". Und LocationSelect sortiert Geschwister
selbst nach Namen; der Server sortiert zeichenweise, dort stuende "Fach
oben Rechts" hinter "Fach Unten Rechts".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-24 17:37:00 +02:00
co-authored by Claude Opus 5
parent 290ff9341a
commit 7cb61d226f
12 changed files with 113 additions and 91 deletions
+7
View File
@@ -20,7 +20,11 @@ export default function CategorySelect({
// 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 = [],
// Aus, wo „nichts gewaehlt" gar kein gueltiger Wert ist (Mindestbestaende:
// dort heisst die oberste Zeile „Ueberall" und steht in extraOptions).
showRoot = true,
disabled = false,
title,
}) {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(() => new Set());
@@ -123,6 +127,7 @@ export default function CategorySelect({
ref={btnRef}
className="tree-select-btn"
disabled={disabled}
title={title}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
@@ -138,6 +143,7 @@ export default function CategorySelect({
className="tree-pop"
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
>
{showRoot && (
<button
type="button"
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
@@ -146,6 +152,7 @@ export default function CategorySelect({
>
{rootLabel}
</button>
)}
{extraOptions.map((o) => (
<button
key={String(o.value)}
+6 -11
View File
@@ -6,7 +6,8 @@ import { useToast } from "../toast";
import Icon from "./Icon";
import Zubehoer from "./Zubehoer";
import { REASONS } from "./ObjektBestand";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
import { itemDeepLink } from "../qr";
import {
VerleihAktion,
@@ -298,11 +299,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort">
{isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={it.location_id ?? ""} locations={locations}
onChange={(id) => patchItem(it, { location_id: id === "" ? null : id })} />
) : <span>{ortName(it.location_id) || "–"}</span>}
</td>
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
@@ -488,11 +486,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
</label>
<label className="grow">Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={addForm.location_id} locations={locations}
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
</label>
<label className="grow">Gekauft bei
<select value={addForm.shop_id}
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import Icon from "./Icon";
import { locationOptions } from "../locationPath";
import LocationSelect from "./LocationSelect";
// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell
// ist er location_id = null; im <select> braucht es einen Wert, deshalb hier ein
@@ -70,9 +71,9 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
{rows.map((r, i) => (
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={r.location_id} locations={locations}
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
onChange={(id) => setRow(i, { location_id: id })} />
</label>
<label style={{ margin: 0, width: 130 }}>
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
+41
View File
@@ -0,0 +1,41 @@
import CategorySelect from "./CategorySelect";
import { asTree } from "../categoryTree";
/**
* Lagerort-Auswahl als aufklappbarer Baum – dieselbe Darstellung wie bei den
* Kategorien, denn Lagerorte sind genauso verschachtelt (Hedingen → Kallax →
* Fach Oben Links → Oben). Vorher stand überall ein flaches <select>, in dem
* jede Zeile den ganzen Pfad ausschrieb; Geschwister standen dadurch nur
* zufällig beieinander.
*
* `CategorySelect` kann beides – seine IDs dürfen Zeichenketten sein, und
* Lagerort-Codes sind welche. Hier kommt nur der Baumaufbau dazu und die
* Umrechnung "" ↔ null, damit die Aufrufstellen weiter mit dem leeren String
* für „kein Ort" arbeiten wie beim <select> zuvor.
*/
export default function LocationSelect({
value,
onChange,
locations,
rootLabel = "– ohne –",
extraOptions = [],
showRoot = true,
disabled = false,
title,
}) {
return (
<CategorySelect
value={value === "" || value == null ? null : value}
onChange={(id) => onChange(id == null ? "" : id)}
// Nach Namen sortieren, bevor der Baum entsteht: Der Server sortiert
// zeichenweise, dort stuende „Fach oben Rechts" hinter „Fach Unten
// Rechts". asTree uebernimmt die Reihenfolge der Geschwister unveraendert.
nodes={asTree([...(locations || [])].sort((a, b) => a.name.localeCompare(b.name, "de")))}
rootLabel={rootLabel}
extraOptions={extraOptions}
showRoot={showRoot}
disabled={disabled}
title={title}
/>
);
}
+14 -19
View File
@@ -4,7 +4,8 @@ import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
export const REASONS = [
@@ -182,11 +183,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
<label className="grow">
Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={addForm.location_id} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
</label>
<label style={{ width: 120 }}>
Menge
@@ -208,16 +207,14 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
</div>
<div className="row">
<label className="grow">Von
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={move.from} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setMove({ ...move, from: id })} />
</label>
<label className="grow">Nach
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={move.to} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setMove({ ...move, to: id })} />
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={move.quantity}
@@ -237,11 +234,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
</div>
<div className="row">
<label className="grow">Lagerort
<select value={remove.location_id}
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={remove.location_id} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setRemove({ ...remove, location_id: id })} />
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={remove.quantity}
+4 -5
View File
@@ -2,7 +2,8 @@ import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
/**
* Charge aufteilen: einen Teil der Menge (in Artikeleinheiten – Packungen/Stück/g)
@@ -55,10 +56,8 @@ export default function SplitLotDialog({ lot, factor, labelFor, locations, onClo
</label>
<label>
nach Lagerort
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={locId} locations={locations}
rootLabel="– ohne Lagerort –" onChange={setLocId} />
</label>
{rest != null && !zielGleich && (
<p className="muted small mt-0">
+4 -6
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import Icon from "../components/Icon";
import { ZEITRAEUME } from "./cards";
import { locationOptions } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { artikelLabel } from "../units";
/**
@@ -28,11 +28,9 @@ export default function KartenConfig({
return (
<label key={f.key}>
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
<select value={karteProps[f.key] ?? ""}
onChange={(e) => onProps({ [f.key]: e.target.value || undefined })}>
<option value="">– alle Orte –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={karteProps[f.key] ?? ""} locations={locations}
rootLabel="– alle Orte –"
onChange={(id) => onProps({ [f.key]: id || undefined })} />
</label>
);
}
+7 -11
View File
@@ -10,7 +10,8 @@ import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { expiryRowClass, faelligAm, fmt, gebinde, unitShort } from "../units";
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
@@ -243,11 +244,8 @@ export default function Charges() {
filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
filterValues: (l) => [l.location_id ? (locationPathById(l.location_id, locations) || "?") : "(ohne)"],
render: (l) => (isAdmin ? (
<select value={l.location_id || ""} style={{ marginTop: 0, minWidth: 150 }}
onChange={(e) => setLocation(l.id, e.target.value)}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={l.location_id || ""} locations={locations}
onChange={(id) => setLocation(l.id, id)} />
) : (
l.location_id ? (locationPathById(l.location_id, locations) || "?") : <span className="muted">– ohne –</span>
)) },
@@ -305,11 +303,9 @@ export default function Charges() {
: "Chargen in der Tabelle anhaken, dann hier handeln. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."}
</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }}
disabled={selected.size === 0}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={bulkLoc} locations={locations}
rootLabel="– ohne Lagerort –" disabled={selected.size === 0}
onChange={setBulkLoc} />
<button className="btn primary" disabled={selected.size === 0} onClick={applyToSelection}>
<Icon name="location" size={16} />Lagerort setzen
</button>
+3 -5
View File
@@ -9,7 +9,7 @@ import { useToast } from "../toast";
import { suggestionToProduct } from "../offUtils";
import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
import { locationOptions } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
import { gruppenOptionen } from "../groupGraph";
@@ -405,10 +405,8 @@ export default function CheckIn() {
</label>
<label className="grow">
Lagerort (optional)
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
<option value="">– keiner –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={locationId} locations={locations}
rootLabel="– keiner –" onChange={setLocationId} />
</label>
</div>
+4 -6
View File
@@ -5,7 +5,8 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { categoryInfoMap } from "../categoryPath";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import {
@@ -73,11 +74,8 @@ export default function ItemList() {
{ key: "location", header: "Lagerort", width: 220,
filterText: (it) => locationPathById(it.location_id, locations),
render: (it) => (isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(it, { location_id: e.target.value === "" ? null : e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={it.location_id ?? ""} locations={locations}
onChange={(id) => patch(it, { location_id: id === "" ? null : id })} />
) : (locationPathById(it.location_id, locations) || "–")) },
{ key: "loan", header: "Verliehen an", width: 200,
filterText: (it) => it.loan?.borrower || "",
+7 -9
View File
@@ -4,6 +4,7 @@ import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import { locationOptions, locationPathById } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { gruppenOptionen } from "../groupGraph";
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
@@ -308,12 +309,10 @@ export default function MinStock() {
{isAdmin && (
<>
<select value={vonId(r.locId)}
<LocationSelect value={vonId(r.locId)} locations={locations}
title="Ort ändern – der Mindestbestand wird verschoben"
onChange={(e) => moveRow(r, e.target.value)}>
<option value={UEBERALL}>Überall</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall" }]}
onChange={(id) => moveRow(r, id)} />
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
</>
@@ -350,10 +349,9 @@ export default function MinStock() {
<div className="row">
<label className="grow" style={{ maxWidth: 360 }}>
Wo
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
<option value={UEBERALL}>Überall (egal wo)</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={draft.locId} locations={locations}
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
onChange={(id) => setDraft({ ...draft, locId: id })} />
</label>
<label className="seg-label">
Was
+4 -8
View File
@@ -16,7 +16,8 @@ import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
import SplitLotDialog from "../components/SplitLotDialog";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { gruppenOptionen } from "../groupGraph";
import {
BASE_UNITS, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired,
@@ -1354,13 +1355,8 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
</td>
<td data-label="Lagerort">
{editing ? (
<select value={draft.location_id} style={{ marginTop: 0, minWidth: 140 }}
onChange={(e) => setDraft({ ...draft, location_id: e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
<LocationSelect value={draft.location_id} locations={locations}
onChange={(id) => setDraft({ ...draft, location_id: id })} />
) : (
l.location_id
? (locationPathById(l.location_id, locations) || "?")