Eigene Felder: Spalten in der Artikelliste und Export nach Feldwerten
Eigene Felder waren bisher nur im Artikelformular zu sehen. Zwei Luecken
daraus: In den Listen tauchten sie gar nicht auf, und es gab keinen Weg,
gezielt nach einem Feldwert zu exportieren.
SPALTEN. Je eigenem Feld eine Spalte in der Artikelliste, Schluessel
feld:<id> wie im Etikettenexport (die ID und nicht key, der ist nur je
Kategorie eindeutig). Reine Oberflaechenarbeit: GET /products liefert die
Werte laengst mit, Products.jsx warf sie nur weg. Filtern, Sortieren,
Ein-/Ausblenden und das Merken erbt die Spalte von DataTable.
Ja/Nein erscheint als Haken, liefert zum Filtern aber "Ja"/"Nein" - und
"Nein" auch ohne gespeicherten Wert: Wer den Haken wegnimmt, loescht die
Zeile, "nein" und "nie gesetzt" sind in den Daten dasselbe. Zahlen sortieren
als Zahl, sonst stuende "10" vor "9".
DataTable lernt dafuer defaultHidden: Alle Feldspalten starten verborgen und
sind erst auf Wunsch da - sonst hinge die Liste je nach Haushalt voller
Spalten, die fuer die meisten Zeilen gar nicht gelten.
Dabei einen vorhandenen Fehler gefunden: urlState las die Sortierung mit
split(":") und nahm das erste Stueck als Spalte. Bei "feld:12:asc" kam
dadurch "feld" heraus, und die Sortierung fiel beim Neuladen still weg. Jetzt
am LETZTEN Doppelpunkt getrennt.
EXPORT. Die Etikettenvorlagen bekommen einen Wertefilter: feldfilter in der
Konfiguration, mehrere Bedingungen UND-verknuepft, genau verglichen ohne
Ruecksicht auf Gross-/Kleinschreibung, leerer Wert heisst "ohne Wert".
Gefiltert wird in Python ueber die ohnehin geladenen Feldwerte - auf value
liegt kein Index, und "fehlende Zeile zaehlt als leer" braeuchte in SQL je
Bedingung ein NOT EXISTS.
Der Filter gilt auch fuer Einzelstuecke; der Wert haengt am Artikel des
Stuecks. Ein geloeschtes Feld laesst seine Bedingung wegfallen, genau wie
eine Spalte zu einem geloeschten Feld still verschwindet.
Die Sicherung schreibt den Filter wie die Spalten auf Kategorie::Label um -
ohne das zeigte er nach dem Zurueckspielen auf ein fremdes Feld, und der
Export gaebe mehr Zeilen aus als das Original. Dafuer ist _konfiguration aus
dem Router als konfiguration_schreiben in den Dienst gezogen: Sonst haetten
DREI Stellen denselben JSON-Text gebaut.
Zehn neue Tests, 540 gruen. Keine Schemaaenderung - konfiguration ist freies
JSON, alte Vorlagen lesen sich als leerer Filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
83f7134459
commit
dd57d3356a
@@ -12,7 +12,6 @@ vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang.
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
@@ -34,6 +33,7 @@ from ..services.etiketten import (
|
||||
ARTEN,
|
||||
csv_erzeugen,
|
||||
konfiguration_lesen,
|
||||
konfiguration_schreiben,
|
||||
verfuegbare_spalten,
|
||||
)
|
||||
|
||||
@@ -129,22 +129,12 @@ def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut:
|
||||
spalten=konf["spalten"],
|
||||
kategorie_ids=konf["kategorie_ids"],
|
||||
trennzeichen=konf["trennzeichen"],
|
||||
feldfilter=konf["feldfilter"],
|
||||
datei_name=vorlage.datei_name,
|
||||
updated_at=vorlage.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _konfiguration(spalten: list[str], kategorie_ids: list[int], trennzeichen: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spalten", response_model=list[SpalteOut])
|
||||
def spalten(
|
||||
art: str = "items",
|
||||
@@ -194,8 +184,12 @@ def vorlage_anlegen(
|
||||
name=name,
|
||||
art=payload.art,
|
||||
position=(hoechste.position + 1) if hoechste else 0,
|
||||
konfiguration=_konfiguration(
|
||||
payload.spalten, payload.kategorie_ids, payload.trennzeichen
|
||||
konfiguration=konfiguration_schreiben(
|
||||
payload.spalten,
|
||||
payload.kategorie_ids,
|
||||
payload.trennzeichen,
|
||||
# json.dumps kann mit Pydantic-Modellen nichts anfangen.
|
||||
[f.model_dump() for f in payload.feldfilter],
|
||||
),
|
||||
)
|
||||
db.add(vorlage)
|
||||
@@ -231,14 +225,15 @@ def vorlage_aendern(
|
||||
if "position" in data:
|
||||
vorlage.position = data["position"]
|
||||
|
||||
# Die drei Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
|
||||
# Die Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
|
||||
# einen schickt, darf die anderen nicht verlieren.
|
||||
if {"spalten", "kategorie_ids", "trennzeichen"} & data.keys():
|
||||
if {"spalten", "kategorie_ids", "trennzeichen", "feldfilter"} & data.keys():
|
||||
alt = konfiguration_lesen(vorlage)
|
||||
vorlage.konfiguration = _konfiguration(
|
||||
vorlage.konfiguration = konfiguration_schreiben(
|
||||
data.get("spalten", alt["spalten"]),
|
||||
data.get("kategorie_ids", alt["kategorie_ids"]),
|
||||
data.get("trennzeichen", alt["trennzeichen"]),
|
||||
data.get("feldfilter", alt["feldfilter"]),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -23,7 +23,7 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before
|
||||
from ..services import gruppen as gruppen_graph
|
||||
from ..services.group_codes import sync as sync_group_code
|
||||
from ..services.min_stock import lies_ueberall, schreibe_ueberall
|
||||
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen
|
||||
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen, konfiguration_schreiben
|
||||
from ..models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
@@ -493,6 +493,17 @@ def _vorlagen_exportieren(db: Session) -> list[dict]:
|
||||
)
|
||||
else:
|
||||
spalten.append(schluessel)
|
||||
# Der Wertefilter zeigt ebenfalls auf Feld-IDs und braucht dieselbe
|
||||
# Umschreibung wie die Spalten - sonst zeigt er nach dem Zurueckspielen
|
||||
# auf ein fremdes Feld oder ist still weg.
|
||||
feldfilter = []
|
||||
for bedingung in konf["feldfilter"]:
|
||||
fd = db.get(FieldDefinition, bedingung["feld_id"])
|
||||
if fd is None:
|
||||
continue
|
||||
pfad = _category_path(db, db.get(Category, fd.category_id))
|
||||
feldfilter.append({"feld": f"{pfad}::{fd.label}", "wert": bedingung["wert"]})
|
||||
|
||||
zeilen.append({
|
||||
"name": v.name,
|
||||
"art": v.art,
|
||||
@@ -502,6 +513,7 @@ def _vorlagen_exportieren(db: Session) -> list[dict]:
|
||||
_category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"]
|
||||
],
|
||||
"trennzeichen": konf["trennzeichen"],
|
||||
"feldfilter": feldfilter,
|
||||
"datei_name": v.datei_name,
|
||||
"datei_base64": base64.b64encode(v.datei).decode() if v.datei else None,
|
||||
})
|
||||
@@ -544,6 +556,25 @@ def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
|
||||
if fd is not None:
|
||||
spalten.append(f"{FELD_PRAEFIX}{fd.id}")
|
||||
|
||||
feldfilter: list[dict] = []
|
||||
for bedingung in entry.get("feldfilter", []):
|
||||
if not isinstance(bedingung, dict):
|
||||
continue
|
||||
pfad, _, label = str(bedingung.get("feld", "")).rpartition("::")
|
||||
kat_id = pfad_zu_id.get(pfad)
|
||||
fd = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(
|
||||
FieldDefinition.category_id == kat_id,
|
||||
FieldDefinition.label == label,
|
||||
).first()
|
||||
if kat_id else None
|
||||
)
|
||||
# Wie bei den Spalten: fehlt das Feld hier, faellt die Bedingung
|
||||
# weg statt die ganze Vorlage scheitern zu lassen.
|
||||
if fd is not None:
|
||||
feldfilter.append({"feld_id": fd.id, "wert": bedingung.get("wert", "")})
|
||||
|
||||
kategorie_ids = [
|
||||
pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id
|
||||
]
|
||||
@@ -552,11 +583,12 @@ def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
|
||||
name=name,
|
||||
art=entry.get("art") or "items",
|
||||
position=int(entry.get("position") or 0),
|
||||
konfiguration=json.dumps({
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": entry.get("trennzeichen") or ",",
|
||||
}, ensure_ascii=False),
|
||||
konfiguration=konfiguration_schreiben(
|
||||
spalten,
|
||||
kategorie_ids,
|
||||
entry.get("trennzeichen") or ",",
|
||||
feldfilter,
|
||||
),
|
||||
datei_name=entry.get("datei_name"),
|
||||
datei=base64.b64decode(roh_datei) if roh_datei else None,
|
||||
))
|
||||
|
||||
@@ -1126,6 +1126,18 @@ class SpalteOut(BaseModel):
|
||||
titel: str
|
||||
|
||||
|
||||
class FeldFilter(BaseModel):
|
||||
"""Eine Bedingung auf einem eigenen Feld.
|
||||
|
||||
Mehrere Bedingungen muessen alle zutreffen. Verglichen wird genau, aber
|
||||
ohne Ruecksicht auf Gross-/Kleinschreibung. Ein leerer ``wert`` trifft die
|
||||
Zeilen OHNE Wert in diesem Feld.
|
||||
"""
|
||||
|
||||
feld_id: int
|
||||
wert: str = ""
|
||||
|
||||
|
||||
class EtikettenVorlageOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -1137,6 +1149,7 @@ class EtikettenVorlageOut(BaseModel):
|
||||
spalten: list[str] = []
|
||||
kategorie_ids: list[int] = []
|
||||
trennzeichen: str = ","
|
||||
feldfilter: list[FeldFilter] = []
|
||||
# Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt.
|
||||
datei_name: str | None = None
|
||||
updated_at: datetime
|
||||
@@ -1148,6 +1161,7 @@ class EtikettenVorlageCreate(BaseModel):
|
||||
spalten: list[str] = []
|
||||
kategorie_ids: list[int] = []
|
||||
trennzeichen: str = ","
|
||||
feldfilter: list[FeldFilter] = []
|
||||
|
||||
|
||||
class EtikettenVorlageUpdate(BaseModel):
|
||||
@@ -1158,6 +1172,7 @@ class EtikettenVorlageUpdate(BaseModel):
|
||||
spalten: list[str] | None = None
|
||||
kategorie_ids: list[int] | None = None
|
||||
trennzeichen: str | None = None
|
||||
feldfilter: list[FeldFilter] | None = None
|
||||
position: int | None = None
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..models import (
|
||||
Category,
|
||||
EtikettenVorlage,
|
||||
FieldDefinition,
|
||||
FieldType,
|
||||
Item,
|
||||
Location,
|
||||
Product,
|
||||
@@ -231,10 +232,98 @@ def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict:
|
||||
trenn = roh.get("trennzeichen") or ","
|
||||
if trenn not in (",", ";", "\t"):
|
||||
trenn = ","
|
||||
return {"spalten": spalten, "kategorie_ids": kategorie_ids, "trennzeichen": trenn}
|
||||
# Kaputte Eintraege fallen einzeln weg, nicht der ganze Filter - sonst
|
||||
# entschiede ein Tippfehler darueber, ob ein Export alles ausgibt.
|
||||
feldfilter = []
|
||||
for eintrag in roh.get("feldfilter", []) if isinstance(roh.get("feldfilter"), list) else []:
|
||||
if not isinstance(eintrag, dict):
|
||||
continue
|
||||
fid = eintrag.get("feld_id")
|
||||
if not str(fid).lstrip("-").isdigit():
|
||||
continue
|
||||
wert = eintrag.get("wert", "")
|
||||
feldfilter.append({"feld_id": int(fid), "wert": str(wert) if wert is not None else ""})
|
||||
return {
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": trenn,
|
||||
"feldfilter": feldfilter,
|
||||
}
|
||||
|
||||
|
||||
def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list:
|
||||
def konfiguration_schreiben(
|
||||
spalten: list[str],
|
||||
kategorie_ids: list[int],
|
||||
trennzeichen: str,
|
||||
feldfilter: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Gegenstueck zu konfiguration_lesen.
|
||||
|
||||
Lag frueher als _konfiguration im Router. Hierher gezogen, weil sonst DREI
|
||||
Stellen denselben JSON-Text bauen (POST, PATCH und der Sicherungs-Import)
|
||||
und ein neuer Schluessel an jeder einzeln nachgetragen werden muesste.
|
||||
"""
|
||||
return json.dumps(
|
||||
{
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
|
||||
"feldfilter": feldfilter or [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _feld_passt(fd: FieldDefinition, bedingung: str, ist: str | None) -> bool:
|
||||
"""Trifft der gespeicherte Wert die Bedingung?
|
||||
|
||||
Ja/Nein ist der heikle Fall: Nimmt man den Haken weg, loescht
|
||||
apply_field_values die Zeile (services/fields.py). „nein" und „nie gesetzt"
|
||||
sind in den Daten also dasselbe - beide muessen treffen, sonst faende
|
||||
„alle mit Nein" so gut wie nichts.
|
||||
|
||||
coerce_value wird NUR fuer Ja/Nein benutzt: Bei Auswahl, Zahl und Datum
|
||||
wirft es bei unpassendem Text, und ein vertippter Filterwert soll keinen
|
||||
Serverfehler beim Herunterladen ausloesen.
|
||||
"""
|
||||
if fd.field_type == FieldType.boolean.value:
|
||||
from .fields import coerce_value
|
||||
|
||||
return (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
|
||||
return (ist or "").casefold() == (bedingung or "").strip().casefold()
|
||||
|
||||
|
||||
def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict]) -> list:
|
||||
"""Zeilen auf die reduzieren, die ALLE Bedingungen erfuellen.
|
||||
|
||||
In Python und nicht in SQL: Auf ProductFieldValue.value liegt kein Index,
|
||||
„fehlende Zeile zaehlt als leer" braeuchte je Bedingung ein NOT EXISTS, und
|
||||
die Mengen sind haushaltsgross. _feldwerte holt ohnehin alles in einem Zug.
|
||||
|
||||
Ein geloeschtes Feld laesst seine Bedingung wegfallen - genau wie eine
|
||||
Spalte zu einem geloeschten Feld still verschwindet.
|
||||
"""
|
||||
bedingungen = []
|
||||
for eintrag in feldfilter:
|
||||
fd = db.get(FieldDefinition, eintrag["feld_id"])
|
||||
if fd is not None:
|
||||
bedingungen.append((fd, eintrag["wert"]))
|
||||
if not bedingungen:
|
||||
return zeilen
|
||||
|
||||
werte = _feldwerte(db, art, zeilen)
|
||||
passend = []
|
||||
for zeile in zeilen:
|
||||
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
|
||||
eigene = werte.get(produkt_id, {})
|
||||
if all(_feld_passt(fd, wert, eigene.get(fd.id)) for fd, wert in bedingungen):
|
||||
passend.append(zeile)
|
||||
return passend
|
||||
|
||||
|
||||
def _zeilen(
|
||||
db: Session, art: str, kategorie_ids: list[int], feldfilter: list[dict] | None = None
|
||||
) -> list:
|
||||
if art == "locations":
|
||||
return db.query(Location).order_by(Location.name).all()
|
||||
|
||||
@@ -250,14 +339,16 @@ def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list:
|
||||
frage = db.query(Product)
|
||||
if ids is not None:
|
||||
frage = frage.filter(Product.category_id.in_(ids or {-1}))
|
||||
return frage.order_by(Product.name).all()
|
||||
zeilen = frage.order_by(Product.name).all()
|
||||
else:
|
||||
frage = db.query(Item)
|
||||
if ids is not None:
|
||||
frage = frage.join(Product, Item.product_id == Product.id).filter(
|
||||
Product.category_id.in_(ids or {-1})
|
||||
)
|
||||
zeilen = frage.order_by(Item.id).all()
|
||||
|
||||
frage = db.query(Item)
|
||||
if ids is not None:
|
||||
frage = frage.join(Product, Item.product_id == Product.id).filter(
|
||||
Product.category_id.in_(ids or {-1})
|
||||
)
|
||||
return frage.order_by(Item.id).all()
|
||||
return _nach_feldwerten(db, art, zeilen, feldfilter) if feldfilter else zeilen
|
||||
|
||||
|
||||
def _feldwerte(db: Session, art: str, zeilen: list) -> dict[int, dict[int, str | None]]:
|
||||
@@ -335,7 +426,7 @@ def csv_erzeugen(db: Session, vorlage: EtikettenVorlage, basis_url: str = "") ->
|
||||
# Lieber die festen Spalten der Bezugsart als eine leere Datei.
|
||||
gueltig = list(SPALTEN.get(art, {}).keys())
|
||||
|
||||
zeilen = _zeilen(db, art, konf["kategorie_ids"])
|
||||
zeilen = _zeilen(db, art, konf["kategorie_ids"], konf["feldfilter"])
|
||||
kontext = Kontext(db=db, basis_url=basis_url, feldwerte=_feldwerte(db, art, zeilen))
|
||||
|
||||
puffer = io.StringIO()
|
||||
|
||||
@@ -54,7 +54,7 @@ def kabel(db):
|
||||
return cat, fd
|
||||
|
||||
|
||||
def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=","):
|
||||
def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=",", feldfilter=None):
|
||||
v = EtikettenVorlage(
|
||||
name=f"Test {len(db.query(EtikettenVorlage).all())}",
|
||||
art=art,
|
||||
@@ -62,6 +62,7 @@ def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=","):
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids or [],
|
||||
"trennzeichen": trenn,
|
||||
"feldfilter": feldfilter or [],
|
||||
}),
|
||||
)
|
||||
db.add(v)
|
||||
@@ -226,7 +227,12 @@ def test_kaputte_konfiguration_ergibt_vorgaben(db):
|
||||
db.commit()
|
||||
|
||||
konf = konfiguration_lesen(v)
|
||||
assert konf == {"spalten": [], "kategorie_ids": [], "trennzeichen": ","}
|
||||
assert konf == {
|
||||
"spalten": [],
|
||||
"kategorie_ids": [],
|
||||
"trennzeichen": ",",
|
||||
"feldfilter": [],
|
||||
}
|
||||
|
||||
|
||||
def test_unerlaubtes_trennzeichen_faellt_auf_komma_zurueck(db):
|
||||
@@ -399,3 +405,236 @@ def test_lbx_mit_schlichtem_namen_bleibt_unveraendert(db, admin):
|
||||
|
||||
assert 'filename="kabel.lbx"' in antwort.headers["content-disposition"]
|
||||
_disposition_pruefen(antwort, "kabel.lbx")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Wertefilter: nur Zeilen mit bestimmten Feldwerten
|
||||
# --------------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def haken(db):
|
||||
"""Kategorie „Kleidung" mit einem Ja/Nein-Feld „Verliehen"."""
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(
|
||||
category_id=cat.id, label="Verliehen", key="verliehen", field_type="boolean"
|
||||
)
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
db.refresh(fd)
|
||||
return cat, fd
|
||||
|
||||
|
||||
def _artikel(db, cat, name, fd=None, wert=None):
|
||||
"""Artikel, optional mit einem Feldwert. ``wert=None`` legt KEINE Zeile an."""
|
||||
product = Product(name=name, category_id=cat.id, package_size=1)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
if fd is not None and wert is not None:
|
||||
db.add(ProductFieldValue(
|
||||
product_id=product.id, field_definition_id=fd.id, value=wert
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _namen(db, vorlage):
|
||||
"""Die Namensspalte des erzeugten CSV, ohne Kopfzeile."""
|
||||
return _zeilen(csv_erzeugen(db, vorlage))[1:]
|
||||
|
||||
|
||||
def test_feldfilter_waehlt_nur_passende_artikel(db, haken):
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_nein_trifft_auch_artikel_ohne_zeile(db, haken):
|
||||
"""Der Kern: Haken wegnehmen loescht die Zeile.
|
||||
|
||||
„nein" und „nie gesetzt" sind in den Daten dasselbe. Ohne diese Gleichung
|
||||
faende „alle mit Nein" nur die, die einmal angehakt und wieder abgehakt
|
||||
wurden – und das ist fast niemand.
|
||||
"""
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
_artikel(db, cat, "Schal") # nie angefasst, gar keine Zeile
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "nein"}])
|
||||
|
||||
assert sorted(_namen(db, v)) == ["Hose", "Schal"]
|
||||
|
||||
|
||||
def test_leerer_wert_trifft_artikel_ohne_wert(db, kabel):
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Mit Laenge", fd, "5")
|
||||
_artikel(db, cat, "Ohne Laenge")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": ""}])
|
||||
|
||||
assert _namen(db, v) == ["Ohne Laenge"]
|
||||
|
||||
|
||||
def test_feldfilter_vergleicht_genau_nicht_teilweise(db, kabel):
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Kurz", fd, "3")
|
||||
_artikel(db, cat, "Lang", fd, "30")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "3"}])
|
||||
|
||||
assert _namen(db, v) == ["Kurz"]
|
||||
|
||||
|
||||
def test_mehrere_bedingungen_muessen_alle_zutreffen(db, haken):
|
||||
cat, fd_bool = haken
|
||||
fd_text = FieldDefinition(
|
||||
category_id=cat.id, label="Farbe", key="farbe", field_type="text"
|
||||
)
|
||||
db.add(fd_text)
|
||||
db.commit()
|
||||
db.refresh(fd_text)
|
||||
|
||||
blau = _artikel(db, cat, "Blaue Jacke", fd_bool, "true")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=blau.id, field_definition_id=fd_text.id, value="blau"
|
||||
))
|
||||
rot = _artikel(db, cat, "Rote Jacke", fd_bool, "true")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=rot.id, field_definition_id=fd_text.id, value="rot"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["name"], art="products", feldfilter=[
|
||||
{"feld_id": fd_bool.id, "wert": "ja"},
|
||||
{"feld_id": fd_text.id, "wert": "blau"},
|
||||
])
|
||||
|
||||
assert _namen(db, v) == ["Blaue Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_und_kategorie_greifen_zusammen(db, haken):
|
||||
cat, fd = haken
|
||||
zweite = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
|
||||
db.add(zweite)
|
||||
db.commit()
|
||||
db.refresh(zweite)
|
||||
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
# Gleicher Feldwert, andere Kategorie – die Kategorie muss ihn aussperren.
|
||||
fremder = _artikel(db, zweite, "Hammer")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=fremder.id, field_definition_id=fd.id, value="true"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["name"], art="products", kategorie_ids=[cat.id],
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_wirkt_auch_bei_einzelstuecken(db, haken):
|
||||
"""Der Wert haengt am Artikel – das Stueck erbt ihn."""
|
||||
cat, fd = haken
|
||||
verliehen = _artikel(db, cat, "Jacke", fd, "true")
|
||||
daheim = _artikel(db, cat, "Hose", fd, "false")
|
||||
db.add(Item(uid="UIDAAAAA", product_id=verliehen.id))
|
||||
db.add(Item(uid="UIDBBBBB", product_id=daheim.id))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["produkt"], art="items",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_geloeschtes_feld_im_filter_zerreisst_den_export_nicht(db, haken):
|
||||
"""Wie bei einer Spalte zu einem geloeschten Feld: die Bedingung faellt weg.
|
||||
|
||||
Der Export wird dadurch WEITER, nicht leer – die harmlosere der beiden
|
||||
Fehlrichtungen, und hier festgehalten, damit sie nicht versehentlich kippt.
|
||||
"""
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
db.delete(fd)
|
||||
db.commit()
|
||||
|
||||
assert sorted(_namen(db, v)) == ["Hose", "Jacke"]
|
||||
|
||||
|
||||
def test_kaputter_feldfilter_ergibt_keinen_filter(db):
|
||||
for nr, roh in enumerate(('{"feldfilter": "quatsch"}',
|
||||
'{"feldfilter": [{"feld_id": "abc"}]}',
|
||||
'{"feldfilter": [42]}')):
|
||||
v = EtikettenVorlage(name=f"Kaputt {nr}", art="items", konfiguration=roh)
|
||||
db.add(v)
|
||||
db.commit()
|
||||
assert konfiguration_lesen(v)["feldfilter"] == []
|
||||
|
||||
|
||||
def test_feldfilter_uebersteht_die_sicherung(db, haken):
|
||||
"""Feld-IDs sind zwischen zwei Instanzen nicht dieselben.
|
||||
|
||||
Deshalb steht in der Sicherung „Kategorie::Label" statt einer ID – und
|
||||
zwar fuer Spalten UND Filter. Fehlte die Umschreibung beim Filter, zeigte
|
||||
er nach dem Zurueckspielen auf ein fremdes Feld oder waere still weg, und
|
||||
der Export gaebe mehr Zeilen aus als das Original.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from app.routers.transfer import _import_json, export_backup_json
|
||||
from app.seed import ensure_builtin_units
|
||||
|
||||
cat, fd = haken
|
||||
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
v.name = "Verliehenes"
|
||||
db.commit()
|
||||
|
||||
inhalt = export_backup_json(db=db, _=person).body
|
||||
gesichert = _json.loads(inhalt)["etiketten_vorlagen"][0]
|
||||
# Nach Pfad und Label, nie nach ID.
|
||||
assert gesichert["feldfilter"] == [{"feld": "Kleidung::Verliehen", "wert": "ja"}]
|
||||
|
||||
from app.database import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
motor = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(bind=motor)
|
||||
zweite = sessionmaker(bind=motor)()
|
||||
ensure_builtin_units(zweite)
|
||||
zweiter_nutzer = User(username="chef", password_hash="x", role=Role.admin)
|
||||
zweite.add(zweiter_nutzer)
|
||||
zweite.commit()
|
||||
|
||||
_import_json(zweite, inhalt, zweiter_nutzer, "add")
|
||||
|
||||
neu = zweite.query(EtikettenVorlage).filter(
|
||||
EtikettenVorlage.name == "Verliehenes"
|
||||
).one()
|
||||
neues_feld = zweite.query(FieldDefinition).filter(
|
||||
FieldDefinition.label == "Verliehen"
|
||||
).one()
|
||||
konf = konfiguration_lesen(neu)
|
||||
# Auf die ID der ZWEITEN Instanz umgeschrieben, Wert unveraendert.
|
||||
assert konf["feldfilter"] == [{"feld_id": neues_feld.id, "wert": "ja"}]
|
||||
assert _namen(zweite, neu) == ["Jacke"]
|
||||
zweite.close()
|
||||
|
||||
@@ -13,7 +13,8 @@ import { leseTabelle, schreibeTabelle } from "../urlState";
|
||||
*
|
||||
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
|
||||
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
|
||||
* fixed:true (kein Reorder/Resize), width, min }.
|
||||
* fixed:true (kein Reorder/Resize), width, min,
|
||||
* defaultHidden:true (erscheint erst, wenn man sie im Spalten-Menü einschaltet) }.
|
||||
*
|
||||
* `tree` (optional): { column, idOf(row), parentIdOf(row) } – zeigt die Zeilen als
|
||||
* ein-/ausklappbaren Baum in der Spalte `column`. Sobald gefiltert oder sortiert
|
||||
@@ -60,11 +61,25 @@ export default function DataTable({
|
||||
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
|
||||
// gewaehlte Reihenfolge soll nicht umspringen.
|
||||
const schluessel = columns.map((c) => c.key).join("|");
|
||||
// Spiegel der Reihenfolge, um sie im Effekt zu lesen, ohne sie in die
|
||||
// Abhaengigkeiten zu nehmen (das liefe im Kreis). Gleiche Technik wie bei
|
||||
// onVisibleRef weiter unten.
|
||||
const ordnungRef = useRef(order);
|
||||
ordnungRef.current = order;
|
||||
useEffect(() => {
|
||||
setOrder((alt) => {
|
||||
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
||||
return fehlend.length ? [...alt, ...fehlend] : alt;
|
||||
});
|
||||
const fehlend = columns.map((c) => c.key).filter((k) => !ordnungRef.current.includes(k));
|
||||
if (!fehlend.length) return;
|
||||
setOrder((alt) => [...alt, ...fehlend.filter((k) => !alt.includes(k))]);
|
||||
// Spalten, die sich als „erst auf Wunsch" melden (defaultHidden), starten
|
||||
// verborgen - aber nur beim ERSTEN Auftauchen. Wer sie einschaltet, steht
|
||||
// danach in `order` und taucht hier nie wieder auf.
|
||||
//
|
||||
// setzeHidden und nicht setHidden: Nur der merkt den Stand ueberhaupt
|
||||
// (siehe Speicher-Effekt). Mit setHidden stuende die Spalte zwar in der
|
||||
// gemerkten Reihenfolge, aber nicht unter den versteckten - beim zweiten
|
||||
// Besuch waere sie sichtbar.
|
||||
const verbergen = fehlend.filter((k) => colByKey[k]?.defaultHidden);
|
||||
if (verbergen.length) setzeHidden((s) => new Set([...s, ...verbergen]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schluessel]);
|
||||
|
||||
@@ -431,7 +446,9 @@ export default function DataTable({
|
||||
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
|
||||
setOrder(columns.map((c) => c.key));
|
||||
setWidths({});
|
||||
setzeHidden(new Set());
|
||||
// „Zuruecksetzen" heisst Auslieferungszustand, nicht „alles einblenden":
|
||||
// Spalten mit defaultHidden verschwinden wieder.
|
||||
setzeHidden(new Set(columns.filter((c) => c.defaultHidden).map((c) => c.key)));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -123,6 +123,9 @@ export default function EtikettenVorlagen({ onError }) {
|
||||
<span className="muted small">
|
||||
{ARTEN.find((a) => a.wert === v.art)?.label || v.art}
|
||||
{" · "}{v.spalten.length} Spalten
|
||||
{/* Dass eine Vorlage nur einen Teil ausgibt, soll man sehen,
|
||||
ohne sie aufzuklappen. */}
|
||||
{v.feldfilter?.length ? " · gefiltert" : ""}
|
||||
{v.datei_name ? " · mit Vorlage" : ""}
|
||||
</span>
|
||||
</button>
|
||||
@@ -158,6 +161,7 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) {
|
||||
const [spalten, setSpalten] = useState(vorlage.spalten);
|
||||
const [katIds, setKatIds] = useState(vorlage.kategorie_ids);
|
||||
const [trenn, setTrenn] = useState(vorlage.trennzeichen);
|
||||
const [feldfilter, setFeldfilter] = useState(vorlage.feldfilter || []);
|
||||
const [moeglich, setMoeglich] = useState([]);
|
||||
const [kategorien, setKategorien] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -192,7 +196,7 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.etikettenVorlageAendern(vorlage.id, {
|
||||
art, spalten, kategorie_ids: katIds, trennzeichen: trenn,
|
||||
art, spalten, kategorie_ids: katIds, trennzeichen: trenn, feldfilter,
|
||||
});
|
||||
await onGespeichert();
|
||||
toast("Vorlage gespeichert.");
|
||||
@@ -256,6 +260,43 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) {
|
||||
Eigene Felder hängen am Artikel, nicht am einzelnen Stück – alle Stücke
|
||||
eines Artikels tragen denselben Wert.
|
||||
</p>
|
||||
|
||||
<span className="muted small" style={{ display: "block", marginTop: "var(--sp-3)" }}>
|
||||
Nur Zeilen mit bestimmten Feldwerten (alle Bedingungen müssen zutreffen)
|
||||
</span>
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{feldfilter.map((f, i) => (
|
||||
<li key={f.feld_id}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(`feld:${f.feld_id}`)}</span>
|
||||
<input value={f.wert} placeholder="ohne Wert" style={{ width: 120 }}
|
||||
onChange={(e) => setFeldfilter(feldfilter.map((x, j) =>
|
||||
(j === i ? { ...x, wert: e.target.value } : x)))} />
|
||||
<button type="button" className="btn-icon danger" title="Bedingung entfernen"
|
||||
onClick={() => setFeldfilter(feldfilter.filter((_, j) => j !== i))}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<select value="" style={{ marginTop: "var(--sp-1)" }}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value.slice("feld:".length));
|
||||
if (!id) return;
|
||||
// Ein Feld nur einmal: zwei Bedingungen darauf koennten sich nie
|
||||
// beide erfuellen.
|
||||
setFeldfilter([...feldfilter.filter((x) => x.feld_id !== id),
|
||||
{ feld_id: id, wert: "" }]);
|
||||
}}>
|
||||
<option value="">+ Bedingung auf einem eigenen Feld …</option>
|
||||
{moeglich.filter((m) => m.schluessel.startsWith("feld:")).map((m) => (
|
||||
<option key={m.schluessel} value={m.schluessel}>{m.titel}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="muted small">
|
||||
Wert genau so eingeben, wie er im Feld steht; bei Ja/Nein-Feldern
|
||||
„ja" oder „nein". <strong>Leer heißt: ohne Wert</strong> – also alle,
|
||||
bei denen das Feld nicht ausgefüllt ist.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Icon from "./components/Icon";
|
||||
|
||||
// Selbst definierte Felder: Typen-Registry und die passenden Eingabe-Elemente.
|
||||
// Bewusst schlank gehalten und aus einer Konfiguration heraus gerendert – analog
|
||||
// zur Karten-Registry des Dashboards (dashboard/cards.jsx).
|
||||
@@ -97,3 +99,87 @@ export function DynamicFields({ fields, values, onChange, disabled }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein eigenes Feld als Tabellenspalte.
|
||||
*
|
||||
* Der Schlüssel ist `feld:<id>` – dieselbe Schreibweise wie im Etikettenexport
|
||||
* (FELD_PRAEFIX in services/etiketten.py). Die ID und nicht `key`, weil `key`
|
||||
* nur JE KATEGORIE eindeutig ist: Zwei Kategorien dürfen beide „kapazitaet"
|
||||
* führen.
|
||||
*
|
||||
* `header` kommt von aussen, weil erst der Aufrufer weiss, ob das Label mit
|
||||
* einem anderen kollidiert und um die Kategorie ergänzt werden muss.
|
||||
*
|
||||
* Anzeige und Filterwert sind bewusst verschieden: Ein Häkchen lässt sich nicht
|
||||
* durchsuchen, deshalb liefert `filterText` „Ja"/„Nein" – damit bietet die
|
||||
* Werteauswahl der Tabelle genau diese beiden an.
|
||||
*/
|
||||
export function feldSpalte(field, header) {
|
||||
const lies = (p) => p.field_values?.[String(field.id)] ?? "";
|
||||
|
||||
const spalte = {
|
||||
key: `feld:${field.id}`,
|
||||
header,
|
||||
width: 130,
|
||||
// Eigene Felder hängen an einer Kategorie; für die meisten Zeilen gibt es
|
||||
// das Feld gar nicht. Ein „–" behauptete „nichts eingetragen" – leer liest
|
||||
// sich richtig als „trifft hier nicht zu".
|
||||
render: (p) => lies(p) || null,
|
||||
filterText: lies,
|
||||
defaultHidden: true,
|
||||
};
|
||||
|
||||
switch (field.field_type) {
|
||||
case "boolean":
|
||||
return {
|
||||
...spalte,
|
||||
render: (p) => (lies(p) === "true" ? <Icon name="check" size={14} /> : null),
|
||||
// „Nein" auch ohne gespeicherten Wert: Wer den Haken wegnimmt, löscht
|
||||
// die Zeile (apply_field_values) – „nein" und „nie gesetzt" sind in den
|
||||
// Daten dasselbe.
|
||||
filterText: (p) => (lies(p) === "true" ? "Ja" : "Nein"),
|
||||
};
|
||||
case "number":
|
||||
return {
|
||||
...spalte,
|
||||
align: "num",
|
||||
// Zahlen müssen als Zahl sortieren, sonst steht "10" vor "9". Der
|
||||
// Vergleich der Tabelle rechnet nur, wenn BEIDE Seiten Zahlen sind.
|
||||
// Leeres zuerst, und -Infinity statt NaN: NaN machte die Sortierung
|
||||
// unstetig, 0 vermischte Leeres mit echten Nullen.
|
||||
sortValue: (p) => {
|
||||
const n = Number(String(lies(p)).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : -Infinity;
|
||||
},
|
||||
};
|
||||
case "date":
|
||||
return {
|
||||
...spalte,
|
||||
render: (p) => {
|
||||
const roh = lies(p);
|
||||
const d = roh ? new Date(roh) : null;
|
||||
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString("de-DE") : roh || null;
|
||||
},
|
||||
filterText: (p) => {
|
||||
const roh = lies(p);
|
||||
const d = roh ? new Date(roh) : null;
|
||||
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString("de-DE") : roh;
|
||||
},
|
||||
// Nach dem ISO-Text sortieren: buchstäblich geordnet ist zeitlich geordnet.
|
||||
sortValue: lies,
|
||||
};
|
||||
case "textarea":
|
||||
return {
|
||||
...spalte,
|
||||
// Zeilenumbrüche zusammenfalten, sonst sprengt eine Notiz die Zeilenhöhe.
|
||||
// Gesucht wird weiterhin im vollen Text.
|
||||
render: (p) => {
|
||||
const roh = lies(p);
|
||||
return roh ? <span title={roh}>{roh.replace(/\s+/g, " ")}</span> : null;
|
||||
},
|
||||
};
|
||||
default:
|
||||
return spalte;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||||
import { categoryInfoMap } from "../categoryPath";
|
||||
import { gruppenInfoMap } from "../groupGraph";
|
||||
import { fmt, gebinde, unitShort } from "../units";
|
||||
import { feldSpalte } from "../fields";
|
||||
|
||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||
function unitCount(p) {
|
||||
@@ -47,6 +48,9 @@ export default function Products({ fixedType = null }) {
|
||||
const [categories, setCategories] = useState([]);
|
||||
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
|
||||
const [groups, setGroups] = useState([]);
|
||||
// Alle eigenen Felder quer ueber die Kategorien - eine Anfrage, wenige
|
||||
// Zeilen. Die WERTE stehen ohnehin schon an jedem Artikel.
|
||||
const [felder, setFelder] = useState([]);
|
||||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||||
// In der URL, damit ein Neuladen die Auswahl behält und sich ein Stand
|
||||
// verschicken lässt.
|
||||
@@ -63,6 +67,7 @@ export default function Products({ fixedType = null }) {
|
||||
.finally(() => setLoading(false));
|
||||
api.listCategories().then(setCategories).catch(() => {});
|
||||
api.listGroups().then(setGroups).catch(() => {});
|
||||
api.listFieldDefinitions().then(setFelder).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
|
||||
@@ -76,7 +81,25 @@ export default function Products({ fixedType = null }) {
|
||||
const titel = fixedType === "food" ? "Lebensmittel"
|
||||
: fixedType === "object" ? "Gegenstände" : "Produkte";
|
||||
|
||||
const columns = [
|
||||
// Eine Spalte je eigenem Feld, standardmaessig ausgeblendet. Der Titel traegt
|
||||
// die Einheit wie im Etikettenexport; kollidieren zwei Labels aus
|
||||
// verschiedenen Kategorien, kommt der Kategoriename dazu - sonst stuenden im
|
||||
// Spalten-Menue zwei gleich benannte Eintraege, die nur die ID unterscheidet.
|
||||
const feldSpalten = useMemo(() => {
|
||||
const titel = (f) => (f.unit ? `${f.label} (${f.unit})` : f.label);
|
||||
const wieOft = new Map();
|
||||
for (const f of felder) wieOft.set(titel(f), (wieOft.get(titel(f)) || 0) + 1);
|
||||
return felder.map((f) => {
|
||||
const kat = catInfo.get(f.category_id)?.parts.at(-1);
|
||||
const kopf = wieOft.get(titel(f)) > 1 && kat ? `${titel(f)} · ${kat}` : titel(f);
|
||||
return feldSpalte(f, kopf);
|
||||
});
|
||||
}, [felder, catInfo]);
|
||||
|
||||
// Gemerkt, weil die Tabelle ihre Wertelisten neu rechnet, sobald sich die
|
||||
// Identitaet des Spalten-Arrays aendert - mit vielen Feldspalten ueber alle
|
||||
// Artikel waere das bei jedem Rendern spuerbar.
|
||||
const columns = useMemo(() => [
|
||||
{ key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
|
||||
render: (p) => <ProduktThumb productId={p.id} version={p.image_version} alt={p.name} /> },
|
||||
{ key: "name", header: "Name", grow: true, min: 200,
|
||||
@@ -148,7 +171,8 @@ export default function Products({ fixedType = null }) {
|
||||
// einen sichtbaren Titel – an einem leeren Kopf gibt es nichts zu greifen.
|
||||
{ key: "details", header: "Details", label: "Details", width: 90, align: "num",
|
||||
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
|
||||
].filter(Boolean);
|
||||
...feldSpalten,
|
||||
].filter(Boolean), [activeTyp, catInfo, grpInfo, feldSpalten]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
+6
-1
@@ -158,7 +158,12 @@ export function leseTabelle(params, kennt) {
|
||||
let sort = null;
|
||||
const sortRoh = params.get("sort");
|
||||
if (sortRoh) {
|
||||
const [spalte, richtung] = sortRoh.split(":");
|
||||
// Am LETZTEN Doppelpunkt trennen: Spaltenschlüssel dürfen selbst einen
|
||||
// enthalten ("feld:12"). Ein split(":") machte daraus die Spalte "feld",
|
||||
// die niemand kennt – die Sortierung fiel beim Neuladen still weg.
|
||||
const schnitt = sortRoh.lastIndexOf(":");
|
||||
const spalte = schnitt < 0 ? sortRoh : sortRoh.slice(0, schnitt);
|
||||
const richtung = schnitt < 0 ? "asc" : sortRoh.slice(schnitt + 1);
|
||||
if (kennt(spalte)) {
|
||||
sort = { key: spalte, dir: richtung === "desc" ? "desc" : "asc" };
|
||||
etwas = true;
|
||||
|
||||
Reference in New Issue
Block a user