diff --git a/backend/app/routers/etiketten.py b/backend/app/routers/etiketten.py index 30cf16b..ac4989f 100644 --- a/backend/app/routers/etiketten.py +++ b/backend/app/routers/etiketten.py @@ -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() diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 1cd46e5..63bfbbc 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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, )) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1e758ce..d32b2da 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/etiketten.py b/backend/app/services/etiketten.py index 917b2f8..e849bf7 100644 --- a/backend/app/services/etiketten.py +++ b/backend/app/services/etiketten.py @@ -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() diff --git a/backend/tests/test_etiketten.py b/backend/tests/test_etiketten.py index e6b9b96..c07e4a5 100644 --- a/backend/tests/test_etiketten.py +++ b/backend/tests/test_etiketten.py @@ -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() diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index b474d67..5cb1d9a 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -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 ( diff --git a/web/src/components/EtikettenVorlagen.jsx b/web/src/components/EtikettenVorlagen.jsx index 73bdf9a..5c04b16 100644 --- a/web/src/components/EtikettenVorlagen.jsx +++ b/web/src/components/EtikettenVorlagen.jsx @@ -123,6 +123,9 @@ export default function EtikettenVorlagen({ onError }) { {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" : ""} @@ -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.
+ + + Nur Zeilen mit bestimmten Feldwerten (alle Bedingungen müssen zutreffen) + ++ Wert genau so eingeben, wie er im Feld steht; bei Ja/Nein-Feldern + „ja" oder „nein". Leer heißt: ohne Wert – also alle, + bei denen das Feld nicht ausgefüllt ist. +
> )} diff --git a/web/src/fields.jsx b/web/src/fields.jsx index ac86ec7..7fbc1d4 100644 --- a/web/src/fields.jsx +++ b/web/src/fields.jsx @@ -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 }) { ); } + +/** + * Ein eigenes Feld als Tabellenspalte. + * + * Der Schlüssel ist `feld: