Gruppen und Kategorien getrennt, EAN-Codes an der Gruppe automatisch gefuehrt
Die Umbenennung von Gruppen in "Kategorien" war falsch: Es sind zwei
verschiedene Dinge. Sie ist zurueckgenommen, Kategorien kommen als eigene Ebene
dazu.
GRUPPE zaehlt Bestaende mehrerer Marken zusammen - Mehl von Rewe, Aldi und
Migros ergeben "5 kg Mehl". Dafuer Mindestbestand mit Einheit und EAN-Codes.
KATEGORIE ordnet allein die Artikelliste ("zeig mir alle Suesswaren"), ist
verschachtelbar wie ein Lagerort und hat weder Bestand noch EAN-Codes. Ein
Artikel kann beides, eines oder keines haben.
Die Vermischung war aelter als die Umbenennung: guessGroup in offUtils.js hat
aus der Open-Food-Facts-KATEGORIE eine GRUPPE geraten. Das ist entfernt. Die
OFF-Einordnung steuert jetzt die Kategorie, wo sie hingehoert; eine Gruppe
entsteht nur ueber einen hinterlegten Gruppen-Code oder bewusste Auswahl.
EAN-Codes an der Gruppe: Das war kein Anzeigefehler. Beim Anlegen eines Artikels
wurde ausschliesslich group_id gesetzt - ein Gruppen-Code entstand nie, die
Liste war tatsaechlich leer. Die Meldung "bereits vergeben" kam daher, dass der
Code am Artikel hing. Jetzt pflegt services/group_codes.py den Code mit: beim
Zuordnen kommt er hinzu, beim Gruppenwechsel wandert er mit, beim Entfernen der
Gruppe oder Loeschen des Artikels verschwindet er. Beim Scannen aendert sich
nichts an der Reihenfolge - der Artikel wird weiterhin zuerst gefunden; der
Gruppen-Eintrag ist Beleg in der Verwaltung und Rueckfall. Traegt man denselben
Code von Hand nach, ist das kein Fehler mehr, sondern die Auskunft, dass er ueber
den Artikel bereits dort steht.
Kategorien im Backend: neue Tabelle mit parent_id (Muster von Location),
products.category_id per ADD COLUMN IF NOT EXISTS nachgezogen, deutsche
zweistufige Startliste analog zu den eingebauten Einheiten. Die Startliste wird
nur angelegt, wenn ueberhaupt noch keine Kategorie existiert - wer sie bewusst
leerraeumt, findet sie nicht wieder. Beim Setzen einer Oberkategorie wird
geprueft, dass keine Kategorie sich selbst oder einem eigenen Nachfahren
untergeordnet wird; sonst entstuende ein Ring und jede Baumdarstellung liefe
endlos. Der Produktfilter schliesst Unterkategorien ein, category_id=0 liefert
die Artikel ohne Kategorie. Export und Import fuehren die Kategorie als Pfad
("Suesswaren & Snacks > Schokolade") in einer Spalte, damit die CSV in Excel
bedienbar bleibt.
Web: neue Seite Kategorien mit Baumdarstellung, Filter ueber der Produktliste,
getrennte Auswahlfelder im Produktformular mit je einer Zeile Erklaerung, und
beim Einlagern laesst sich eine Gruppe samt Einheit und Mindestbestand direkt
anlegen, ohne den Vorgang zu verlassen.
iOS: Kategorie-Filter ueber der Produktliste, Unterkategorien eingerueckt. Die
Artikelzeile nennt jetzt das Gebinde und warnt, wenn der Mindestbestand
unterschritten ist (rot) oder weniger als ein Viertel Luft bleibt (orange).
Getrennte Auswahlfelder fuer Kategorie und Gruppe, Gruppe direkt anlegbar.
Ausserdem die Eingabefelder in der App: .textFieldStyle(.roundedBorder) zeichnet
in der dunklen Darstellung einen fast schwarzen Kasten. Ersetzt durch eine
Systemfuellung, die sich Hell und Dunkel anpasst und zurueckhaltend bleibt.
Getestet: 54 pytest-Tests gruen, 14 davon neu (Nachfahren-Sammler, OFF-Zuordnung
inklusive Vorrang der Unterkategorie, und die komplette Codepflege an der
Gruppe). Gegen die laufende API geprueft: Startliste ohne Dubletten, Filter auf
Ober- und Unterkategorie, Ringschutz, Loeschen einer Kategorie laesst Artikel
und Unterkategorien bestehen, Export/Import-Rundlauf mit Kategoriepfad, und der
Durchlauf aus der Meldung - Artikel mit Gruppe anlegen, Code steht danach in der
Gruppe. Web-Build und iOS-Geraetebuild fehler- und warnungsfrei.
Die Oberflaechen habe ich nicht selbst bedient.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
|||||||
BarcodeOut.model_validate(b)
|
BarcodeOut.model_validate(b)
|
||||||
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
|
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
|
||||||
]
|
]
|
||||||
|
out.category_name = product.category.name if product.category else None
|
||||||
out.kind = KIND_OF_BASE[product.base_unit].value
|
out.kind = KIND_OF_BASE[product.base_unit].value
|
||||||
name, factor = display_unit_info(product)
|
name, factor = display_unit_info(product)
|
||||||
out.unit_name = name
|
out.unit_name = name
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .routers import (
|
|||||||
api_tokens,
|
api_tokens,
|
||||||
auth,
|
auth,
|
||||||
branding,
|
branding,
|
||||||
|
categories,
|
||||||
groups,
|
groups,
|
||||||
locations,
|
locations,
|
||||||
products,
|
products,
|
||||||
@@ -20,7 +21,7 @@ from .routers import (
|
|||||||
users,
|
users,
|
||||||
views,
|
views,
|
||||||
)
|
)
|
||||||
from .seed import ensure_builtin_units, ensure_first_admin
|
from .seed import ensure_builtin_categories, ensure_builtin_units, ensure_first_admin
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ def _ensure_schema() -> None:
|
|||||||
"NOT NULL DEFAULT 'day'",
|
"NOT NULL DEFAULT 'day'",
|
||||||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) "
|
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) "
|
||||||
"NOT NULL DEFAULT 'day'",
|
"NOT NULL DEFAULT 'day'",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
||||||
|
"REFERENCES categories(id) ON DELETE SET NULL",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
@@ -63,6 +66,7 @@ async def lifespan(app: FastAPI):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ensure_builtin_units(db)
|
ensure_builtin_units(db)
|
||||||
|
ensure_builtin_categories(db)
|
||||||
ensure_first_admin(db)
|
ensure_first_admin(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -100,3 +104,4 @@ app.include_router(transfer.router)
|
|||||||
app.include_router(api_tokens.router)
|
app.include_router(api_tokens.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
app.include_router(branding.router)
|
app.include_router(branding.router)
|
||||||
|
app.include_router(categories.router)
|
||||||
|
|||||||
@@ -111,6 +111,27 @@ class Location(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
"""Einordnung eines Artikels **allein für den Überblick**.
|
||||||
|
|
||||||
|
Nicht zu verwechseln mit :class:`Group`: Eine Gruppe fasst Bestände mehrerer
|
||||||
|
Marken zusammen (5 kg Mehl, egal von wem) und trägt Mindestbestand und
|
||||||
|
EAN-Codes. Eine Kategorie tut nichts dergleichen – sie hilft nur, in einer
|
||||||
|
langen Artikelliste "zeig mir alle Süßwaren" sagen zu können.
|
||||||
|
|
||||||
|
Beliebig tief verschachtelbar (Süßwaren → Schokolade), wie :class:`Location`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
__tablename__ = "products"
|
__tablename__ = "products"
|
||||||
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
|
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
|
||||||
@@ -140,9 +161,15 @@ class Product(Base):
|
|||||||
server_default=DatePrecision.day.value,
|
server_default=DatePrecision.day.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Gruppe: zaehlt Bestaende mehrerer Marken zusammen.
|
||||||
group_id: Mapped[int | None] = mapped_column(
|
group_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Kategorie: reine Ordnungshilfe fuer die Artikelliste, voellig unabhaengig
|
||||||
|
# von der Gruppe. Ein Artikel kann beides, eines oder keines haben.
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
||||||
# In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige).
|
# In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige).
|
||||||
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -157,6 +184,7 @@ class Product(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
group: Mapped[Group | None] = relationship(back_populates="products")
|
group: Mapped[Group | None] = relationship(back_populates="products")
|
||||||
|
category: Mapped[Category | None] = relationship()
|
||||||
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
||||||
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
||||||
lots: Mapped[list[Lot]] = relationship(
|
lots: Mapped[list[Lot]] = relationship(
|
||||||
|
|||||||
125
backend/app/routers/categories.py
Normal file
125
backend/app/routers/categories.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Kategorien: Einordnung der Artikel allein für den Überblick.
|
||||||
|
|
||||||
|
Bewusst ohne Bestand, Mindestbestand und EAN-Codes – das ist Sache der Gruppen
|
||||||
|
(siehe routers/groups.py). Kategorien sind verschachtelbar wie Lagerorte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import Category, Product, User
|
||||||
|
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
def descendant_ids(db: Session, category_id: int) -> set[int]:
|
||||||
|
"""Die Kategorie samt allen Unterkategorien.
|
||||||
|
|
||||||
|
Wird für den Produktfilter gebraucht: Wer auf "Süßwaren" filtert, will auch
|
||||||
|
die Artikel aus "Schokolade" sehen. Die Menge ist klein, deshalb in Python
|
||||||
|
statt mit einer rekursiven Abfrage.
|
||||||
|
"""
|
||||||
|
alle = db.query(Category.id, Category.parent_id).all()
|
||||||
|
kinder: dict[int, list[int]] = {}
|
||||||
|
for kid, parent in alle:
|
||||||
|
kinder.setdefault(parent, []).append(kid)
|
||||||
|
|
||||||
|
gefunden: set[int] = set()
|
||||||
|
offen = [category_id]
|
||||||
|
while offen:
|
||||||
|
aktuell = offen.pop()
|
||||||
|
if aktuell in gefunden:
|
||||||
|
continue # schützt zusätzlich vor einem Ring in den Daten
|
||||||
|
gefunden.add(aktuell)
|
||||||
|
offen.extend(kinder.get(aktuell, []))
|
||||||
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(db: Session, category: Category) -> CategoryOut:
|
||||||
|
out = CategoryOut.model_validate(category)
|
||||||
|
out.product_count = (
|
||||||
|
db.query(Product).filter(Product.category_id == category.id).count()
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CategoryOut])
|
||||||
|
def list_categories(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
|
) -> list[CategoryOut]:
|
||||||
|
rows = db.query(Category).order_by(Category.name).all()
|
||||||
|
return [_to_out(db, c) for c in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(
|
||||||
|
payload: CategoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> CategoryOut:
|
||||||
|
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
||||||
|
category = Category(name=payload.name, parent_id=payload.parent_id)
|
||||||
|
db.add(category)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(category)
|
||||||
|
return _to_out(db, category)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{category_id}", response_model=CategoryOut)
|
||||||
|
def update_category(
|
||||||
|
category_id: int,
|
||||||
|
payload: CategoryUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> CategoryOut:
|
||||||
|
category = db.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "parent_id" in data and data["parent_id"] is not None:
|
||||||
|
neuer_elternteil = data["parent_id"]
|
||||||
|
if db.get(Category, neuer_elternteil) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
||||||
|
# Ohne diese Prüfung entstünde ein Ring und jede Baumdarstellung
|
||||||
|
# (Oberfläche wie Filter) liefe endlos.
|
||||||
|
if neuer_elternteil in descendant_ids(db, category_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Eine Kategorie kann nicht sich selbst oder einer ihrer "
|
||||||
|
"Unterkategorien untergeordnet werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(category, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(category)
|
||||||
|
return _to_out(db, category)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{category_id}")
|
||||||
|
def delete_category(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> Response:
|
||||||
|
"""Artikel und Unterkategorien bleiben bestehen, sie verlieren nur die Zuordnung."""
|
||||||
|
category = db.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||||
|
|
||||||
|
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
|
||||||
|
db.query(Product).filter(Product.category_id == category_id).update(
|
||||||
|
{Product.category_id: None}
|
||||||
|
)
|
||||||
|
db.query(Category).filter(Category.parent_id == category_id).update(
|
||||||
|
{Category.parent_id: None}
|
||||||
|
)
|
||||||
|
db.delete(category)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -111,23 +111,40 @@ def update_group(
|
|||||||
return _group_to_out(db, group)
|
return _group_to_out(db, group)
|
||||||
|
|
||||||
|
|
||||||
def _describe_conflict(db: Session, code: str) -> str | None:
|
def _describe_conflict(db: Session, code: str, group_id: int) -> str | None:
|
||||||
"""Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter."""
|
"""Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter."""
|
||||||
product = db.query(Product).filter(Product.barcode == code).first()
|
product = db.query(Product).filter(Product.barcode == code).first()
|
||||||
existing = db.query(Barcode).filter(Barcode.code == code).first()
|
existing = db.query(Barcode).filter(Barcode.code == code).first()
|
||||||
|
|
||||||
if existing is not None and existing.product_id:
|
if existing is not None and existing.product_id:
|
||||||
product = db.get(Product, existing.product_id) or product
|
product = db.get(Product, existing.product_id) or product
|
||||||
|
|
||||||
if product is not None:
|
if product is not None:
|
||||||
|
if product.group_id == group_id:
|
||||||
|
# Der Code steht durch den Artikel ohnehin schon in dieser Gruppe.
|
||||||
|
return (
|
||||||
|
f"Dieser Code gehört zum Artikel \"{product.name}\" und ist über ihn "
|
||||||
|
"bereits in dieser Gruppe."
|
||||||
|
)
|
||||||
|
if product.group_id is not None:
|
||||||
|
other = db.get(Group, product.group_id)
|
||||||
|
name = other.name if other else "einer anderen Gruppe"
|
||||||
|
return (
|
||||||
|
f"Dieser Code gehört zum Artikel \"{product.name}\", der in der Gruppe "
|
||||||
|
f"\"{name}\" liegt. Ändere die Gruppe des Artikels, statt den Code hier "
|
||||||
|
"einzutragen."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"Dieser Code gehört bereits zum Artikel \"{product.name}\". "
|
f"Dieser Code gehört bereits zum Artikel \"{product.name}\". Ordne den "
|
||||||
"Artikel-Codes müssen hier nicht eingetragen werden – beim Scannen "
|
"Artikel dieser Gruppe zu – sein Code erscheint dann automatisch hier."
|
||||||
"wird immer zuerst der Artikel gefunden, und dessen Kategorie zählt."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if existing is not None and existing.group_id == group_id:
|
||||||
|
return "Dieser Code ist in dieser Gruppe bereits hinterlegt."
|
||||||
if existing is not None and existing.group_id:
|
if existing is not None and existing.group_id:
|
||||||
other = db.get(Group, existing.group_id)
|
other = db.get(Group, existing.group_id)
|
||||||
name = other.name if other else "einer anderen Kategorie"
|
name = other.name if other else "einer anderen Gruppe"
|
||||||
return f"Dieser Code ist bereits der Kategorie \"{name}\" zugeordnet."
|
return f"Dieser Code ist bereits der Gruppe \"{name}\" zugeordnet."
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return "Dieser Code ist bereits vergeben."
|
return "Dieser Code ist bereits vergeben."
|
||||||
return None
|
return None
|
||||||
@@ -145,7 +162,7 @@ def add_group_barcode(
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
code = payload.code.strip()
|
code = payload.code.strip()
|
||||||
conflict = _describe_conflict(db, code)
|
conflict = _describe_conflict(db, code, group.id)
|
||||||
if conflict:
|
if conflict:
|
||||||
raise HTTPException(status.HTTP_409_CONFLICT, conflict)
|
raise HTTPException(status.HTTP_409_CONFLICT, conflict)
|
||||||
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
|
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ from sqlalchemy.orm import Session
|
|||||||
from ..crud import product_to_out
|
from ..crud import product_to_out
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Barcode, BaseUnit, Group, Product, User
|
from ..models import Barcode, BaseUnit, Category, Group, Product, User
|
||||||
from ..off import lookup_barcode
|
from ..off import lookup_barcode
|
||||||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||||
|
from ..services.categories import suggest_category
|
||||||
|
from .categories import descendant_ids
|
||||||
from ..services.conversion import ConversionError, resolve_product_unit
|
from ..services.conversion import ConversionError, resolve_product_unit
|
||||||
|
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
@@ -15,13 +18,24 @@ router = APIRouter(prefix="/products", tags=["products"])
|
|||||||
@router.get("", response_model=list[ProductOut])
|
@router.get("", response_model=list[ProductOut])
|
||||||
def list_products(
|
def list_products(
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
) -> list[ProductOut]:
|
) -> list[ProductOut]:
|
||||||
|
"""Artikel auflisten, optional nach Name und Kategorie eingegrenzt.
|
||||||
|
|
||||||
|
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
|
||||||
|
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
||||||
|
auch "Schokolade" sehen.
|
||||||
|
"""
|
||||||
query = db.query(Product)
|
query = db.query(Product)
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
query = query.filter(Product.name.ilike(like))
|
query = query.filter(Product.name.ilike(like))
|
||||||
|
if category_id == 0:
|
||||||
|
query = query.filter(Product.category_id.is_(None))
|
||||||
|
elif category_id is not None:
|
||||||
|
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||||
products = query.order_by(Product.name).all()
|
products = query.order_by(Product.name).all()
|
||||||
return [product_to_out(db, p) for p in products]
|
return [product_to_out(db, p) for p in products]
|
||||||
|
|
||||||
@@ -52,9 +66,14 @@ def lookup(
|
|||||||
group_id, group_name = group.id, group.name
|
group_id, group_name = group.id, group.name
|
||||||
|
|
||||||
suggestion = lookup_barcode(code)
|
suggestion = lookup_barcode(code)
|
||||||
|
# Die OFF-Einordnung steuert die KATEGORIE (Ueberblick), nicht die Gruppe.
|
||||||
|
category = suggest_category(db, suggestion)
|
||||||
|
category_id = category.id if category else None
|
||||||
|
category_name = category.name if category else None
|
||||||
if suggestion:
|
if suggestion:
|
||||||
return LookupResult(
|
return LookupResult(
|
||||||
found=True, suggestion=suggestion, group_id=group_id, group_name=group_name
|
found=True, suggestion=suggestion, group_id=group_id, group_name=group_name,
|
||||||
|
category_id=category_id, category_name=category_name,
|
||||||
)
|
)
|
||||||
return LookupResult(found=False, group_id=group_id, group_name=group_name)
|
return LookupResult(found=False, group_id=group_id, group_name=group_name)
|
||||||
|
|
||||||
@@ -101,12 +120,15 @@ def create_product(
|
|||||||
package_label=payload.package_label or None,
|
package_label=payload.package_label or None,
|
||||||
date_precision=payload.date_precision.value,
|
date_precision=payload.date_precision.value,
|
||||||
group_id=payload.group_id,
|
group_id=payload.group_id,
|
||||||
|
category_id=payload.category_id,
|
||||||
min_stock=payload.min_stock,
|
min_stock=payload.min_stock,
|
||||||
min_stock_unit_id=payload.min_stock_unit_id,
|
min_stock_unit_id=payload.min_stock_unit_id,
|
||||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||||
source="manual",
|
source="manual",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
|
db.flush() # product.id fuer den Gruppen-Code
|
||||||
|
sync_group_code(db, product)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(product)
|
db.refresh(product)
|
||||||
return product_to_out(db, product)
|
return product_to_out(db, product)
|
||||||
@@ -153,6 +175,8 @@ def update_product(
|
|||||||
data["date_precision"] = data["date_precision"].value
|
data["date_precision"] = data["date_precision"].value
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||||||
|
sync_group_code(db, product)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(product)
|
db.refresh(product)
|
||||||
return product_to_out(db, product)
|
return product_to_out(db, product)
|
||||||
@@ -207,5 +231,6 @@ def delete_product(
|
|||||||
product = db.get(Product, product_id)
|
product = db.get(Product, product_id)
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
detach_group_code(db, product)
|
||||||
db.delete(product)
|
db.delete(product)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..services.dates import MONTH, clean_precision, normalize_best_before
|
from ..services.dates import MONTH, clean_precision, normalize_best_before
|
||||||
|
from ..services.group_codes import sync as sync_group_code
|
||||||
from ..models import (
|
from ..models import (
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
|
Category,
|
||||||
DatePrecision,
|
DatePrecision,
|
||||||
Group,
|
Group,
|
||||||
Location,
|
Location,
|
||||||
@@ -37,7 +39,7 @@ router = APIRouter(tags=["transfer"])
|
|||||||
|
|
||||||
CSV_FIELDS = [
|
CSV_FIELDS = [
|
||||||
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
||||||
"gruppe", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
"gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
||||||
]
|
]
|
||||||
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ def export_stock_csv(
|
|||||||
product.package_size if product.package_size else "",
|
product.package_size if product.package_size else "",
|
||||||
product.package_label or "",
|
product.package_label or "",
|
||||||
product.group.name if product.group else "",
|
product.group.name if product.group else "",
|
||||||
|
_category_path(db, product.category),
|
||||||
product.min_stock if product.min_stock is not None else "",
|
product.min_stock if product.min_stock is not None else "",
|
||||||
]
|
]
|
||||||
lots = (
|
lots = (
|
||||||
@@ -149,6 +152,7 @@ def export_backup_json(
|
|||||||
"package_label": p.package_label,
|
"package_label": p.package_label,
|
||||||
"date_precision": p.date_precision,
|
"date_precision": p.date_precision,
|
||||||
"group": p.group.name if p.group else None,
|
"group": p.group.name if p.group else None,
|
||||||
|
"category": _category_path(db, p.category),
|
||||||
"min_stock": p.min_stock,
|
"min_stock": p.min_stock,
|
||||||
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
||||||
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
||||||
@@ -269,6 +273,45 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None:
|
|||||||
return loc
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def _category_path(db: Session, category: Category | None) -> str:
|
||||||
|
"""Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte.
|
||||||
|
|
||||||
|
So bleibt die CSV in Excel bedienbar, statt für jede Ebene eine eigene
|
||||||
|
Spalte zu brauchen.
|
||||||
|
"""
|
||||||
|
if category is None:
|
||||||
|
return ""
|
||||||
|
teile = [category.name]
|
||||||
|
aktuell, tiefe = category, 0
|
||||||
|
while aktuell.parent_id is not None and tiefe < 10: # Schutz vor einem Ring
|
||||||
|
aktuell = db.get(Category, aktuell.parent_id)
|
||||||
|
if aktuell is None:
|
||||||
|
break
|
||||||
|
teile.append(aktuell.name)
|
||||||
|
tiefe += 1
|
||||||
|
return " > ".join(reversed(teile))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_category(db: Session, path: str | None) -> Category | None:
|
||||||
|
"""Legt den ganzen Pfad an, falls Teile davon fehlen."""
|
||||||
|
path = (path or "").strip()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
parent: Category | None = None
|
||||||
|
for name in [t.strip() for t in path.split(">") if t.strip()]:
|
||||||
|
query = db.query(Category).filter(Category.name == name)
|
||||||
|
query = query.filter(
|
||||||
|
Category.parent_id == (parent.id if parent else None)
|
||||||
|
)
|
||||||
|
node = query.first()
|
||||||
|
if node is None:
|
||||||
|
node = Category(name=name, parent_id=parent.id if parent else None)
|
||||||
|
db.add(node)
|
||||||
|
db.flush()
|
||||||
|
parent = node
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -300,6 +343,7 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
if unit is None:
|
if unit is None:
|
||||||
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
||||||
group = _get_or_create_group(db, row.get("gruppe"))
|
group = _get_or_create_group(db, row.get("gruppe"))
|
||||||
|
category = _get_or_create_category(db, row.get("kategorie"))
|
||||||
product = Product(
|
product = Product(
|
||||||
barcode=barcode,
|
barcode=barcode,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -311,11 +355,15 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
# Nur im JSON-Backup enthalten; aus der CSV kommt hier nichts.
|
# Nur im JSON-Backup enthalten; aus der CSV kommt hier nichts.
|
||||||
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
|
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
|
||||||
group_id=group.id if group else None,
|
group_id=group.id if group else None,
|
||||||
|
category_id=category.id if category else None,
|
||||||
min_stock=_num(row.get("mindestbestand")),
|
min_stock=_num(row.get("mindestbestand")),
|
||||||
source="import",
|
source="import",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
|
||||||
|
# ueber die Oberflaeche.
|
||||||
|
sync_group_code(db, product)
|
||||||
created.append(product.name)
|
created.append(product.name)
|
||||||
return product
|
return product
|
||||||
|
|
||||||
@@ -454,6 +502,7 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
"packungsgroesse": entry.get("package_size") or "",
|
"packungsgroesse": entry.get("package_size") or "",
|
||||||
"gebinde": entry.get("package_label") or "",
|
"gebinde": entry.get("package_label") or "",
|
||||||
"mhd_genauigkeit": entry.get("date_precision") or "",
|
"mhd_genauigkeit": entry.get("date_precision") or "",
|
||||||
|
"kategorie": entry.get("category") or "",
|
||||||
"gruppe": entry.get("group") or "",
|
"gruppe": entry.get("group") or "",
|
||||||
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,27 @@ class GroupUpdate(BaseModel):
|
|||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Categories ----
|
||||||
|
class CategoryOut(BaseModel):
|
||||||
|
"""Reine Ordnungshilfe – kein Bestand, kein Mindestbestand, keine EAN-Codes."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
parent_id: int | None = None
|
||||||
|
is_builtin: bool = False
|
||||||
|
product_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Locations ----
|
# ---- Locations ----
|
||||||
class LocationOut(BaseModel):
|
class LocationOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -144,6 +165,8 @@ class ProductBase(BaseModel):
|
|||||||
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
|
# Kategorie: nur fuer den Ueberblick, unabhaengig von der Gruppe.
|
||||||
|
category_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||||||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
@@ -165,6 +188,7 @@ class ProductUpdate(BaseModel):
|
|||||||
package_label: str | None = Field(default=None, max_length=32)
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
date_precision: DatePrecision | None = None
|
date_precision: DatePrecision | None = None
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
|
category_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
min_stock_in_packages: bool | None = None
|
min_stock_in_packages: bool | None = None
|
||||||
@@ -183,6 +207,8 @@ class ProductOut(BaseModel):
|
|||||||
package_label: str | None = None
|
package_label: str | None = None
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None
|
group_id: int | None
|
||||||
|
category_id: int | None = None
|
||||||
|
category_name: str | None = None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
min_stock_in_packages: bool = False
|
min_stock_in_packages: bool = False
|
||||||
@@ -208,6 +234,9 @@ class LookupResult(BaseModel):
|
|||||||
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
|
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
group_name: str | None = None
|
group_name: str | None = None
|
||||||
|
# Aus der Open-Food-Facts-Kategorie abgeleiteter Vorschlag (nur Vorschlag).
|
||||||
|
category_id: int | None = None
|
||||||
|
category_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BarcodeCreate(BaseModel):
|
class BarcodeCreate(BaseModel):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .models import Role, Unit, UnitKind, User
|
from .models import Category, Role, Unit, UnitKind, User
|
||||||
from .security import hash_password
|
from .security import hash_password
|
||||||
|
|
||||||
# (Name, Art, Faktor zur kanonischen Basiseinheit)
|
# (Name, Art, Faktor zur kanonischen Basiseinheit)
|
||||||
@@ -26,6 +26,43 @@ def ensure_builtin_units(db: Session) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Startliste für den Überblick über den Vorrat. Zweistufig, damit die
|
||||||
|
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
|
||||||
|
# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen.
|
||||||
|
BUILTIN_CATEGORIES: list[tuple[str, list[str]]] = [
|
||||||
|
("Obst & Gemüse", []),
|
||||||
|
("Molkereiprodukte", ["Käse", "Joghurt & Quark"]),
|
||||||
|
("Fleisch & Fisch", []),
|
||||||
|
("Brot & Backwaren", []),
|
||||||
|
("Trockenwaren & Teigwaren", ["Mehl & Zucker", "Nudeln & Reis"]),
|
||||||
|
("Konserven & Gläser", []),
|
||||||
|
("Tiefkühl", []),
|
||||||
|
("Süßwaren & Snacks", ["Schokolade", "Chips & Salziges"]),
|
||||||
|
("Getränke", ["Kaffee & Tee"]),
|
||||||
|
("Gewürze & Saucen", []),
|
||||||
|
("Frühstück", []),
|
||||||
|
("Haushalt & Hygiene", []),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_builtin_categories(db: Session) -> None:
|
||||||
|
"""Legt nur an, was fehlt – gelöschte Kategorien kommen nicht zurück.
|
||||||
|
|
||||||
|
Anders als bei den Einheiten prüfen wir zusätzlich, ob überhaupt schon
|
||||||
|
Kategorien existieren: Wer die Startliste bewusst leergeräumt hat, soll sie
|
||||||
|
beim nächsten Start nicht wiederfinden.
|
||||||
|
"""
|
||||||
|
if db.query(Category).first() is not None:
|
||||||
|
return
|
||||||
|
for name, children in BUILTIN_CATEGORIES:
|
||||||
|
parent = Category(name=name, is_builtin=True)
|
||||||
|
db.add(parent)
|
||||||
|
db.flush()
|
||||||
|
for child in children:
|
||||||
|
db.add(Category(name=child, parent_id=parent.id, is_builtin=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def ensure_first_admin(db: Session) -> None:
|
def ensure_first_admin(db: Session) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
has_users = db.query(User).first() is not None
|
has_users = db.query(User).first() is not None
|
||||||
|
|||||||
70
backend/app/services/categories.py
Normal file
70
backend/app/services/categories.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Schlägt aus den Open-Food-Facts-Angaben eine Kategorie vor.
|
||||||
|
|
||||||
|
OFF liefert die Einordnung auf Englisch und sehr fein gegliedert
|
||||||
|
("Plant-based foods and beverages, Cereals and potatoes, Breakfasts"). Diese
|
||||||
|
Werte direkt als Kategorien zu übernehmen, ergäbe hunderte unbrauchbare
|
||||||
|
Einträge – deshalb eine Zuordnung auf die überschaubare eigene Liste.
|
||||||
|
|
||||||
|
Der Vorschlag ist nur ein Vorschlag: In der Oberfläche ist er vorbelegt und
|
||||||
|
jederzeit änderbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Category
|
||||||
|
|
||||||
|
# Stichwort -> Name der Kategorie. Reihenfolge zählt: Die erste Zeile, die
|
||||||
|
# passt, gewinnt. Feineres steht deshalb oben, damit "chocolate" in der
|
||||||
|
# Unterkategorie "Schokolade" landet und nicht im allgemeinen "Süßwaren".
|
||||||
|
KEYWORD_MAP: list[tuple[tuple[str, ...], str]] = [
|
||||||
|
(("chocolat", "schokolade", "cocoa"), "Schokolade"),
|
||||||
|
(("chips", "crisps", "salty-snack", "apéritif", "aperitif"), "Chips & Salziges"),
|
||||||
|
(("cheese", "fromage", "käse", "kaese"), "Käse"),
|
||||||
|
(("yogurt", "yoghurt", "joghurt", "quark", "skyr"), "Joghurt & Quark"),
|
||||||
|
(("flour", "sugar", "mehl", "zucker", "farine"), "Mehl & Zucker"),
|
||||||
|
(("pasta", "noodle", "rice", "nudel", "reis", "spaghetti"), "Nudeln & Reis"),
|
||||||
|
(("coffee", "tea", "kaffee", "tee"), "Kaffee & Tee"),
|
||||||
|
|
||||||
|
(("dairy", "dairies", "milk", "milch", "cream", "rahm", "butter"), "Molkereiprodukte"),
|
||||||
|
(("sweet", "candy", "candies", "biscuit", "cookie", "confectioner",
|
||||||
|
"süßwaren", "bonbon"), "Süßwaren & Snacks"),
|
||||||
|
(("fruit", "vegetable", "obst", "gemüse", "gemuese", "salad"), "Obst & Gemüse"),
|
||||||
|
(("meat", "fish", "seafood", "fleisch", "wurst", "sausage", "poultry"), "Fleisch & Fisch"),
|
||||||
|
(("bread", "bakery", "brot", "backwaren", "pastry"), "Brot & Backwaren"),
|
||||||
|
(("canned", "conserve", "konserve", "tinned"), "Konserven & Gläser"),
|
||||||
|
(("frozen", "tiefkühl", "tiefkuehl", "surgelé", "surgele"), "Tiefkühl"),
|
||||||
|
(("beverage", "drink", "getränk", "getraenk", "water", "juice", "soda"), "Getränke"),
|
||||||
|
(("spice", "sauce", "condiment", "gewürz", "gewuerz", "vinegar", "oil"), "Gewürze & Saucen"),
|
||||||
|
(("breakfast", "cereal", "müsli", "muesli", "jam", "honey", "frühstück"), "Frühstück"),
|
||||||
|
(("cleaning", "hygiene", "detergent", "haushalt", "soap"), "Haushalt & Hygiene"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_category(db: Session, suggestion: dict | None) -> Category | None:
|
||||||
|
"""Passende vorhandene Kategorie zu einem OFF-Vorschlag, sonst ``None``.
|
||||||
|
|
||||||
|
Es wird nichts angelegt: Fehlt die Kategorie (weil sie gelöscht oder
|
||||||
|
umbenannt wurde), gibt es eben keinen Vorschlag.
|
||||||
|
"""
|
||||||
|
if not suggestion:
|
||||||
|
return None
|
||||||
|
|
||||||
|
haystack = " ".join(
|
||||||
|
[
|
||||||
|
str(suggestion.get("category_suggestion") or ""),
|
||||||
|
*[str(t) for t in (suggestion.get("category_tags") or [])],
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
if not haystack:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for keywords, name in KEYWORD_MAP:
|
||||||
|
if any(word in haystack for word in keywords):
|
||||||
|
treffer = db.query(Category).filter(Category.name == name).first()
|
||||||
|
if treffer is not None:
|
||||||
|
return treffer
|
||||||
|
# Kategorie gibt es nicht (mehr) - weitersuchen, vielleicht passt
|
||||||
|
# eine gröbere Zeile weiter unten.
|
||||||
|
return None
|
||||||
74
backend/app/services/group_codes.py
Normal file
74
backend/app/services/group_codes.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Pflegt die EAN-Codes an einer Gruppe automatisch mit.
|
||||||
|
|
||||||
|
Bisher entstand ein Gruppen-Code ausschließlich von Hand. Wurde ein Artikel
|
||||||
|
einer Gruppe zugeordnet, blieb dessen EAN-Code dort unsichtbar – die Gruppe
|
||||||
|
zeigte „0 EANs", obwohl Artikel darin lagen, und der Versuch, den Code
|
||||||
|
nachzutragen, scheiterte mit „bereits vergeben" (er hing ja am Artikel).
|
||||||
|
|
||||||
|
Jetzt wandert der Haupt-Barcode eines Artikels automatisch an die Gruppe:
|
||||||
|
|
||||||
|
* beim Anlegen und beim Zuordnen einer Gruppe → Code kommt hinzu
|
||||||
|
* beim Wechsel der Gruppe → Code wandert mit
|
||||||
|
* beim Entfernen der Gruppe oder Löschen des Artikels → Code verschwindet
|
||||||
|
|
||||||
|
Beim Scannen ändert das nichts an der Reihenfolge: ``routers.products.lookup``
|
||||||
|
findet weiterhin zuerst den Artikel, dessen Gruppe zählt. Der Gruppen-Eintrag
|
||||||
|
ist Beleg in der Verwaltung und Rückfall, falls der Artikel später verschwindet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Barcode, Product
|
||||||
|
|
||||||
|
|
||||||
|
def _group_row(db: Session, code: str) -> Barcode | None:
|
||||||
|
"""Vorhandene Gruppen-Zeile zu diesem Code (nicht: Artikel-Alias)."""
|
||||||
|
return (
|
||||||
|
db.query(Barcode)
|
||||||
|
.filter(Barcode.code == code, Barcode.group_id.isnot(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync(db: Session, product: Product) -> None:
|
||||||
|
"""Bringt den Gruppen-Code eines Artikels auf den aktuellen Stand.
|
||||||
|
|
||||||
|
Deckt Anlegen, Ändern und Entfernen der Gruppe gleichermaßen ab, damit die
|
||||||
|
Aufrufer sich nicht merken müssen, was vorher galt.
|
||||||
|
"""
|
||||||
|
code = (product.barcode or "").strip()
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
|
||||||
|
row = _group_row(db, code)
|
||||||
|
|
||||||
|
# Keine Gruppe mehr: einen früher angelegten Eintrag wieder entfernen.
|
||||||
|
if product.group_id is None:
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
return
|
||||||
|
|
||||||
|
if row is not None:
|
||||||
|
# Gruppe gewechselt: Zeile umhängen statt löschen und neu anlegen,
|
||||||
|
# damit eine hinterlegte Notiz erhalten bleibt.
|
||||||
|
row.group_id = product.group_id
|
||||||
|
return
|
||||||
|
|
||||||
|
# Hängt der Code bereits als Alias an einem Artikel, gehört er dort hin -
|
||||||
|
# code ist eindeutig, eine zweite Zeile wäre nicht möglich.
|
||||||
|
if db.query(Barcode).filter(Barcode.code == code).first() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
db.add(Barcode(code=code, group_id=product.group_id))
|
||||||
|
|
||||||
|
|
||||||
|
def detach(db: Session, product: Product) -> None:
|
||||||
|
"""Vor dem Löschen eines Artikels dessen Gruppen-Code aufräumen."""
|
||||||
|
code = (product.barcode or "").strip()
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
row = _group_row(db, code)
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
173
backend/tests/test_categories.py
Normal file
173
backend/tests/test_categories.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Kategorien (Überblick) und die automatische EAN-Pflege an der Gruppe.
|
||||||
|
|
||||||
|
Kategorie und Gruppe sind bewusst zwei verschiedene Dinge: Die Gruppe zählt
|
||||||
|
Bestände mehrerer Marken zusammen, die Kategorie ordnet nur die Artikelliste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models import Barcode, Category, Group, Product
|
||||||
|
from app.routers.categories import descendant_ids
|
||||||
|
from app.services.categories import suggest_category
|
||||||
|
from app.services.group_codes import detach, sync
|
||||||
|
from app.seed import ensure_builtin_categories
|
||||||
|
|
||||||
|
|
||||||
|
def _baum(db):
|
||||||
|
"""Süßwaren → Schokolade → Zartbitter."""
|
||||||
|
ober = Category(name="Süßwaren & Snacks")
|
||||||
|
db.add(ober)
|
||||||
|
db.flush()
|
||||||
|
mitte = Category(name="Schokolade", parent_id=ober.id)
|
||||||
|
db.add(mitte)
|
||||||
|
db.flush()
|
||||||
|
unten = Category(name="Zartbitter", parent_id=mitte.id)
|
||||||
|
db.add(unten)
|
||||||
|
db.commit()
|
||||||
|
return ober, mitte, unten
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Verschachtelung ----
|
||||||
|
|
||||||
|
def test_filter_schliesst_unterkategorien_ein(db):
|
||||||
|
ober, mitte, unten = _baum(db)
|
||||||
|
assert descendant_ids(db, ober.id) == {ober.id, mitte.id, unten.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_einer_unterkategorie_bleibt_eng(db):
|
||||||
|
_, mitte, unten = _baum(db)
|
||||||
|
assert descendant_ids(db, mitte.id) == {mitte.id, unten.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_blatt_hat_nur_sich_selbst(db):
|
||||||
|
_, _, unten = _baum(db)
|
||||||
|
assert descendant_ids(db, unten.id) == {unten.id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Vorschlag aus Open Food Facts ----
|
||||||
|
|
||||||
|
def test_off_vorschlag_bevorzugt_die_feinere_kategorie(db):
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
treffer = suggest_category(db, {"category_tags": ["en:sweet-snacks", "en:chocolates"]})
|
||||||
|
# "chocolates" ist genauer als "sweet-snacks" und gewinnt deshalb.
|
||||||
|
assert treffer is not None and treffer.name == "Schokolade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_vorschlag_faellt_auf_die_obere_kategorie_zurueck(db):
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
treffer = suggest_category(db, {"category_tags": ["en:sweet-snacks", "en:candies"]})
|
||||||
|
assert treffer is not None and treffer.name == "Süßwaren & Snacks"
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_vorschlag_ohne_treffer_ist_none(db):
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
assert suggest_category(db, {"category_tags": ["en:voelliges-neuland"]}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_vorschlag_ohne_angaben_ist_none(db):
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
assert suggest_category(db, None) is None
|
||||||
|
assert suggest_category(db, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_startliste_wird_nicht_doppelt_angelegt(db):
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
anzahl = db.query(Category).count()
|
||||||
|
ensure_builtin_categories(db)
|
||||||
|
assert db.query(Category).count() == anzahl
|
||||||
|
|
||||||
|
|
||||||
|
# ---- EAN-Codes an der Gruppe ----
|
||||||
|
|
||||||
|
def _artikel(db, gruppe, code="4001"):
|
||||||
|
product = Product(name="Aldi Mehl", barcode=code, group_id=gruppe.id if gruppe else None)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _gruppen_codes(db, gruppe):
|
||||||
|
return {
|
||||||
|
b.code for b in db.query(Barcode).filter(Barcode.group_id == gruppe.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_landet_beim_anlegen_in_der_gruppe(db):
|
||||||
|
mehl = Group(name="Mehl")
|
||||||
|
db.add(mehl)
|
||||||
|
db.commit()
|
||||||
|
product = _artikel(db, mehl)
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
assert _gruppen_codes(db, mehl) == {"4001"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_wandert_beim_gruppenwechsel_mit(db):
|
||||||
|
mehl, back = Group(name="Mehl"), Group(name="Backzutaten")
|
||||||
|
db.add_all([mehl, back])
|
||||||
|
db.commit()
|
||||||
|
product = _artikel(db, mehl)
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
product.group_id = back.id
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
|
assert _gruppen_codes(db, back) == {"4001"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_verschwindet_ohne_gruppe(db):
|
||||||
|
mehl = Group(name="Mehl")
|
||||||
|
db.add(mehl)
|
||||||
|
db.commit()
|
||||||
|
product = _artikel(db, mehl)
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
product.group_id = None
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_verschwindet_beim_loeschen_des_artikels(db):
|
||||||
|
mehl = Group(name="Mehl")
|
||||||
|
db.add(mehl)
|
||||||
|
db.commit()
|
||||||
|
product = _artikel(db, mehl)
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
detach(db, product)
|
||||||
|
db.delete(product)
|
||||||
|
db.commit()
|
||||||
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_artikel_ohne_barcode_erzeugt_keinen_eintrag(db):
|
||||||
|
mehl = Group(name="Mehl")
|
||||||
|
db.add(mehl)
|
||||||
|
db.commit()
|
||||||
|
product = Product(name="Lose Ware", group_id=mehl.id)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremder_alias_wird_nicht_ueberschrieben(db):
|
||||||
|
"""Hängt der Code schon als Alias an einem anderen Artikel, bleibt er dort."""
|
||||||
|
mehl = Group(name="Mehl")
|
||||||
|
anderer = Product(name="Anderer Artikel")
|
||||||
|
db.add_all([mehl, anderer])
|
||||||
|
db.commit()
|
||||||
|
db.add(Barcode(code="4001", product_id=anderer.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
product = _artikel(db, mehl)
|
||||||
|
sync(db, product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
|
assert db.query(Barcode).filter(Barcode.code == "4001").one().product_id == anderer.id
|
||||||
@@ -54,8 +54,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
|||||||
- **MHD wahlweise nur mit Monat und Jahr.** Gespeichert wird weiterhin ein echtes
|
- **MHD wahlweise nur mit Monat und Jahr.** Gespeichert wird weiterhin ein echtes
|
||||||
Datum (Monatsletzter), damit FEFO unverändert arbeitet; die Genauigkeit steht
|
Datum (Monatsletzter), damit FEFO unverändert arbeitet; die Genauigkeit steht
|
||||||
getrennt an Charge und Produkt. Export/Import bleiben verlustfrei.
|
getrennt an Charge und Produkt. Export/Import bleiben verlustfrei.
|
||||||
- **Gruppen heißen in der Oberfläche jetzt Kategorien.** Tabelle, Feld `group_id`
|
- **Kategorien als eigene Ebene neben den Gruppen.** Zuvor wurden beide Begriffe
|
||||||
und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe weiterlaufen.
|
vermischt – aus der Open-Food-Facts-Kategorie wurde sogar eine *Gruppe* geraten.
|
||||||
|
Jetzt getrennt: Die **Gruppe** zählt Bestände mehrerer Marken zusammen
|
||||||
|
(Mindestbestand, EAN-Codes), die **Kategorie** ordnet nur die Artikelliste und
|
||||||
|
ist verschachtelbar (Süßwaren → Schokolade). Mitgelieferte Startliste,
|
||||||
|
Vorschlag aus der OFF-Einordnung, Filter in App und Web.
|
||||||
|
- **EAN-Codes an der Gruppe werden automatisch gepflegt:** Beim Zuordnen eines
|
||||||
|
Artikels landet sein Code dort, beim Gruppenwechsel wandert er mit, beim
|
||||||
|
Löschen verschwindet er. Vorher blieb die Codeliste einer Gruppe leer, obwohl
|
||||||
|
Artikel darin lagen.
|
||||||
- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung).
|
- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung).
|
||||||
|
|
||||||
## Schritt 3 – Weitere Ideen – offen
|
## Schritt 3 – Weitere Ideen – offen
|
||||||
|
|||||||
@@ -69,12 +69,20 @@ Beide Wege öffnen die App **direkt in der Kamera**.
|
|||||||
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste |
|
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste |
|
||||||
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen |
|
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen |
|
||||||
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
|
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
|
||||||
|
| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen |
|
||||||
|
|
||||||
## Kategorien
|
## Gruppe und Kategorie – zwei verschiedene Dinge
|
||||||
Was in der Datenbank und in der Schnittstelle `group` heißt, trägt in der
|
**Gruppe** dient der *Bestandsrechnung*: Mehl von Rewe, Aldi und Migros zählen
|
||||||
Oberfläche den Namen **Kategorie** („Molkereiprodukte", „Brot", …). Der Name in
|
zusammen, wichtig ist „ich habe 5 kg Mehl“. Eine Gruppe hat deshalb einen
|
||||||
API und Tabelle blieb bewusst unverändert, damit bestehende Zugriffe – etwa aus
|
gemeinsamen Mindestbestand mit Einheit und EAN-Codes. Ordnest du einen Artikel
|
||||||
Home Assistant – weiterlaufen.
|
einer Gruppe zu, erscheint sein EAN-Code automatisch dort; wechselt er die
|
||||||
|
Gruppe, wandert der Code mit.
|
||||||
|
|
||||||
|
**Kategorie** dient *allein dem Überblick* („Süßwaren“, „Molkereiprodukte“) und
|
||||||
|
ist verschachtelbar wie ein Lagerort (Süßwaren → Schokolade). Sie hat keinen
|
||||||
|
Bestand, keinen Mindestbestand und keine EAN-Codes. Filterst du auf eine
|
||||||
|
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen
|
||||||
|
schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor.
|
||||||
|
|
||||||
## Noch offen
|
## Noch offen
|
||||||
- Push-Benachrichtigungen bei ablaufenden Produkten
|
- Push-Benachrichtigungen bei ablaufenden Produkten
|
||||||
|
|||||||
@@ -87,9 +87,17 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/locations"), as: [StorageLocation].self)
|
try await send(try makeRequest("/locations"), as: [StorageLocation].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchProducts(_ query: String) async throws -> [Product] {
|
/// `categoryId == 0` liefert Artikel ohne Kategorie; eine echte ID schliesst
|
||||||
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
/// die Unterkategorien mit ein.
|
||||||
return try await send(try makeRequest("/products?q=\(escaped)"), as: [Product].self)
|
func searchProducts(_ query: String, categoryId: Int? = nil) async throws -> [Product] {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !query.isEmpty {
|
||||||
|
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
parts.append("q=\(escaped)")
|
||||||
|
}
|
||||||
|
if let categoryId { parts.append("category_id=\(categoryId)") }
|
||||||
|
let suffix = parts.isEmpty ? "" : "?" + parts.joined(separator: "&")
|
||||||
|
return try await send(try makeRequest("/products" + suffix), as: [Product].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func product(id: Int) async throws -> Product {
|
func product(id: Int) async throws -> Product {
|
||||||
@@ -117,6 +125,16 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func categories() async throws -> [CategoryItem] {
|
||||||
|
try await send(try makeRequest("/categories"), as: [CategoryItem].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGroup(_ payload: NewGroupRequest) async throws -> GroupItem {
|
||||||
|
var request = try makeRequest("/groups", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: GroupItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
func settings() async throws -> [SettingEntry] {
|
func settings() async throws -> [SettingEntry] {
|
||||||
try await send(try makeRequest("/settings"), as: [SettingEntry].self)
|
try await send(try makeRequest("/settings"), as: [SettingEntry].self)
|
||||||
}
|
}
|
||||||
|
|||||||
105
ios/Sources/CategoryPicker.swift
Normal file
105
ios/Sources/CategoryPicker.swift
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Auswahl einer Kategorie mit eingerueckten Unterkategorien.
|
||||||
|
///
|
||||||
|
/// Kategorie und Gruppe sind zwei verschiedene Dinge: Die Kategorie ordnet nur
|
||||||
|
/// die Artikelliste, die Gruppe zaehlt Bestaende mehrerer Marken zusammen.
|
||||||
|
struct CategoryPicker: View {
|
||||||
|
let categories: [CategoryItem]
|
||||||
|
@Binding var selection: Int?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("Kategorie", selection: $selection) {
|
||||||
|
Text("– keine –").tag(Int?.none)
|
||||||
|
ForEach(flattened(), id: \.item.id) { eintrag in
|
||||||
|
Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name)
|
||||||
|
.tag(Int?.some(eintrag.item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattened() -> [(item: CategoryItem, depth: Int)] {
|
||||||
|
let ids = Set(categories.map(\.id))
|
||||||
|
var result: [(CategoryItem, Int)] = []
|
||||||
|
|
||||||
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
|
result.append((node, depth))
|
||||||
|
for child in categories.filter({ $0.parentId == node.id }) {
|
||||||
|
walk(child, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||||
|
walk(root, 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht
|
||||||
|
/// verlassen muss. Nur fuer Administratoren - POST /groups verlangt das.
|
||||||
|
struct NewGroupSheet: View {
|
||||||
|
let units: [Unit]
|
||||||
|
var onCreated: (GroupItem) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var minStock = ""
|
||||||
|
@State private var unitId: Int?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LabeledField(label: "Name", text: $name)
|
||||||
|
} footer: {
|
||||||
|
Text("Eine Gruppe zählt Bestände mehrerer Marken zusammen – "
|
||||||
|
+ "z. B. Mehl von verschiedenen Herstellern als „5 kg Mehl“.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Mindestbestand (optional)") {
|
||||||
|
QuantityField(label: "Menge", text: $minStock)
|
||||||
|
Picker("Einheit", selection: $unitId) {
|
||||||
|
Text("– Basiseinheit –").tag(Int?.none)
|
||||||
|
ForEach(units) { unit in
|
||||||
|
Text(unit.name).tag(Int?.some(unit.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }
|
||||||
|
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Neue Gruppe")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() async {
|
||||||
|
error = nil
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let created = try await APIClient.shared.createGroup(
|
||||||
|
NewGroupRequest(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
minStock: Double(minStock.replacingOccurrences(of: ",", with: ".")),
|
||||||
|
minStockUnitId: unitId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onCreated(created)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,23 @@ struct LineRef: Identifiable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Dezente Umrandung fuer Eingabefelder in einem Formular.
|
||||||
|
///
|
||||||
|
/// `.textFieldStyle(.roundedBorder)` zeichnet in der dunklen Darstellung
|
||||||
|
/// einen fast schwarzen Kasten, der sich hart von der Zeile abhebt. Eine
|
||||||
|
/// Systemfuellung passt sich Hell und Dunkel an und bleibt zurueckhaltend,
|
||||||
|
/// macht das Feld aber weiterhin als Eingabe erkennbar.
|
||||||
|
func fieldBox(width: CGFloat) -> some View {
|
||||||
|
self
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: width)
|
||||||
|
.background(Color(.tertiarySystemFill))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Textzeile mit Beschriftung links und sichtbarem Rahmen.
|
/// Textzeile mit Beschriftung links und sichtbarem Rahmen.
|
||||||
struct LabeledField: View {
|
struct LabeledField: View {
|
||||||
let label: String
|
let label: String
|
||||||
@@ -19,8 +36,7 @@ struct LabeledField: View {
|
|||||||
TextField(label, text: $text)
|
TextField(label, text: $text)
|
||||||
.keyboardType(keyboard)
|
.keyboardType(keyboard)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.textFieldStyle(.roundedBorder)
|
.fieldBox(width: 190)
|
||||||
.frame(maxWidth: 190)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,8 +57,7 @@ struct QuantityField: View {
|
|||||||
TextField(label, text: $text)
|
TextField(label, text: $text)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.textFieldStyle(.roundedBorder)
|
.fieldBox(width: 110)
|
||||||
.frame(maxWidth: 110)
|
|
||||||
if let suffix {
|
if let suffix {
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct CheckInView: View {
|
|||||||
@State private var suggestion: LookupResult.Suggestion?
|
@State private var suggestion: LookupResult.Suggestion?
|
||||||
@State private var suggestedGroupId: Int?
|
@State private var suggestedGroupId: Int?
|
||||||
@State private var suggestedGroupName: String?
|
@State private var suggestedGroupName: String?
|
||||||
|
@State private var suggestedCategoryName: String?
|
||||||
@State private var unknownCode: String?
|
@State private var unknownCode: String?
|
||||||
@State private var manualCodeShown = false
|
@State private var manualCodeShown = false
|
||||||
@State private var manualCode = ""
|
@State private var manualCode = ""
|
||||||
@@ -111,23 +112,33 @@ struct CheckInView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sagt vor dem Anlegen, welche Kategorie der Artikel bekommt und warum.
|
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func categoryNote() -> some View {
|
private func groupNote() -> some View {
|
||||||
if let name = suggestedGroupName {
|
if let kategorie = suggestedCategoryName {
|
||||||
HStack(alignment: .top, spacing: 6) {
|
noteRow(symbol: "square.grid.2x2",
|
||||||
Image(systemName: "tag")
|
title: "Kategorie „\(kategorie)“ vorgeschlagen",
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
detail: "Aus der Einordnung bei Open Food Facts.")
|
||||||
Text("Wird der Kategorie „\(name)“ zugeordnet").font(.caption).bold()
|
|
||||||
Text("Dieser EAN-Code ist dort hinterlegt.")
|
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
if let name = suggestedGroupName {
|
||||||
|
noteRow(symbol: "tag",
|
||||||
|
title: "Wird der Gruppe „\(name)“ zugeordnet",
|
||||||
|
detail: "Dieser EAN-Code ist dort hinterlegt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteRow(symbol: String, title: String, detail: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(title).font(.caption).bold()
|
||||||
|
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
||||||
@@ -137,7 +148,7 @@ struct CheckInView: View {
|
|||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
|
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
categoryNote()
|
groupNote()
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
||||||
suggestion: item) { created in
|
suggestion: item) { created in
|
||||||
@@ -161,7 +172,7 @@ struct CheckInView: View {
|
|||||||
Text("Unbekannter Code \(code)").font(.headline)
|
Text("Unbekannter Code \(code)").font(.headline)
|
||||||
Text("Weder im Katalog noch bei Open Food Facts.")
|
Text("Weder im Katalog noch bei Open Food Facts.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
categoryNote()
|
groupNote()
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
|
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
|
||||||
unknownCode = nil
|
unknownCode = nil
|
||||||
@@ -191,6 +202,7 @@ struct CheckInView: View {
|
|||||||
let result = try await APIClient.shared.lookup(barcode: code)
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
suggestedGroupId = result.groupId
|
suggestedGroupId = result.groupId
|
||||||
suggestedGroupName = result.groupName
|
suggestedGroupName = result.groupName
|
||||||
|
suggestedCategoryName = result.categoryName
|
||||||
if let existing = result.existingProduct {
|
if let existing = result.existingProduct {
|
||||||
product = existing
|
product = existing
|
||||||
} else if let hint = result.suggestion {
|
} else if let hint = result.suggestion {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ func formatAmount(_ value: Double) -> String {
|
|||||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kennzeichnung "Kategorie" an einer Listenzeile.
|
/// Kennzeichnung "Gruppe" an einer Listenzeile.
|
||||||
struct CategoryBadge: View {
|
struct GroupBadge: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("Kategorie")
|
Text("Gruppe")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -18,7 +18,7 @@ struct CategoryBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zeile mit Symbol links, damit Artikel und Kategorie unterscheidbar sind.
|
/// Zeile mit Symbol links, damit Artikel und Gruppe unterscheidbar sind.
|
||||||
struct ListRow<Trailing: View>: View {
|
struct ListRow<Trailing: View>: View {
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
let title: String
|
let title: String
|
||||||
@@ -70,13 +70,13 @@ struct ShoppingListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !groups.isEmpty {
|
if !groups.isEmpty {
|
||||||
Section("Kategorien unter Mindestbestand") {
|
Section("Gruppen unter Mindestbestand") {
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
ListRow(
|
ListRow(
|
||||||
systemImage: "square.stack.3d.up",
|
systemImage: "square.stack.3d.up",
|
||||||
title: group.name,
|
title: group.name,
|
||||||
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
|
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
|
||||||
) { CategoryBadge() }
|
) { GroupBadge() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,23 +173,49 @@ struct ExpiringView: View {
|
|||||||
struct ProductListView: View {
|
struct ProductListView: View {
|
||||||
@State private var query = ""
|
@State private var query = ""
|
||||||
@State private var products: [Product] = []
|
@State private var products: [Product] = []
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||||||
|
@State private var categoryId: Int?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
|
|
||||||
|
private var filterLabel: String {
|
||||||
|
if categoryId == 0 { return "Ohne Kategorie" }
|
||||||
|
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
|
||||||
|
return treffer.name
|
||||||
|
}
|
||||||
|
return "Alle Kategorien"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(products) { product in
|
Section {
|
||||||
NavigationLink {
|
Menu {
|
||||||
ProductDetailView(product: product)
|
Button("Alle Kategorien") { pick(nil) }
|
||||||
|
Button("Ohne Kategorie") { pick(0) }
|
||||||
|
Divider()
|
||||||
|
ForEach(categoryTree()) { eintrag in
|
||||||
|
Button(eintrag.label) { pick(eintrag.item.id) }
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
ListRow(
|
HStack {
|
||||||
systemImage: "shippingbox",
|
Label("Kategorie", systemImage: "line.3.horizontal.decrease.circle")
|
||||||
title: product.name,
|
Spacer()
|
||||||
subtitle: "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
|
Text(filterLabel).foregroundStyle(.secondary)
|
||||||
) { EmptyView() }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if products.isEmpty && !busy {
|
|
||||||
Text("Keine Treffer").foregroundStyle(.secondary)
|
Section {
|
||||||
|
ForEach(products) { product in
|
||||||
|
NavigationLink {
|
||||||
|
ProductDetailView(product: product)
|
||||||
|
} label: {
|
||||||
|
ProductRow(product: product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if products.isEmpty && !busy {
|
||||||
|
Text("Keine Treffer").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchable(text: $query, prompt: "Artikel suchen")
|
.searchable(text: $query, prompt: "Artikel suchen")
|
||||||
@@ -197,12 +223,89 @@ struct ProductListView: View {
|
|||||||
.navigationTitle("Produkte")
|
.navigationTitle("Produkte")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.task { await load() }
|
.task {
|
||||||
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pick(_ id: Int?) {
|
||||||
|
categoryId = id
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorien in Baumreihenfolge mit eingerueckter Beschriftung.
|
||||||
|
private struct TreeEntry: Identifiable {
|
||||||
|
let item: CategoryItem
|
||||||
|
let depth: Int
|
||||||
|
var id: Int { item.id }
|
||||||
|
var label: String { String(repeating: " ", count: depth) + item.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryTree() -> [TreeEntry] {
|
||||||
|
let ids = Set(categories.map(\.id))
|
||||||
|
var result: [TreeEntry] = []
|
||||||
|
|
||||||
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
|
result.append(TreeEntry(item: node, depth: depth))
|
||||||
|
for child in categories.filter({ $0.parentId == node.id }) {
|
||||||
|
walk(child, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||||||
|
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||||
|
walk(root, 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
products = (try? await APIClient.shared.searchProducts(query)) ?? []
|
products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artikelzeile mit Gebinde und Hinweis auf den Mindestbestand.
|
||||||
|
struct ProductRow: View {
|
||||||
|
let product: Product
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "shippingbox")
|
||||||
|
.font(.body).foregroundStyle(.secondary).frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(product.name)
|
||||||
|
Text(bestand)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let hinweis = mindestbestand {
|
||||||
|
Text(hinweis).font(.caption2).foregroundStyle(farbe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bestand: String {
|
||||||
|
var text = "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
|
||||||
|
// Bei einem Gebinde die Basismenge dahinter, damit "2 Glas" greifbar wird.
|
||||||
|
if let size = product.packageSize, size > 0 {
|
||||||
|
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mindestbestand: String? {
|
||||||
|
guard let anzeige = product.minStockDisplay else { return nil }
|
||||||
|
let einheit = product.minStockUnitLabel ?? ""
|
||||||
|
switch product.stockLevel {
|
||||||
|
case .below: return "unter Mindestbestand (\(formatAmount(anzeige)) \(einheit))"
|
||||||
|
case .close: return "bald nachkaufen (Minimum \(formatAmount(anzeige)) \(einheit))"
|
||||||
|
case .ok, .none: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var farbe: Color {
|
||||||
|
product.stockLevel == .below ? .red : .orange
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,20 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
let barcodes: [BarcodeEntry]
|
let barcodes: [BarcodeEntry]
|
||||||
/// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month").
|
/// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month").
|
||||||
let datePrecision: String?
|
let datePrecision: String?
|
||||||
|
/// Kategorie: reine Ordnungshilfe fuer die Artikelliste (nicht die Gruppe).
|
||||||
|
let categoryId: Int?
|
||||||
|
let categoryName: String?
|
||||||
|
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
|
||||||
|
let minStockDisplay: Double?
|
||||||
|
let minStockUnitLabel: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, barcode, name, brand, stock, kind, barcodes
|
case id, barcode, name, brand, stock, kind, barcodes
|
||||||
case datePrecision = "date_precision"
|
case datePrecision = "date_precision"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case categoryName = "category_name"
|
||||||
|
case minStockDisplay = "min_stock_display"
|
||||||
|
case minStockUnitLabel = "min_stock_unit_label"
|
||||||
case imageUrl = "image_url"
|
case imageUrl = "image_url"
|
||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
@@ -81,6 +91,16 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
||||||
|
|
||||||
|
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
|
||||||
|
enum StockLevel { case none, ok, close, below }
|
||||||
|
|
||||||
|
var stockLevel: StockLevel {
|
||||||
|
guard let minimum = minStock, minimum > 0 else { return .none }
|
||||||
|
if stock < minimum { return .below }
|
||||||
|
// Weniger als ein Viertel Luft: bald nachkaufen.
|
||||||
|
return stock < minimum * 1.25 ? .close : .ok
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auswahleintrag fuer Einheiten.
|
/// Auswahleintrag fuer Einheiten.
|
||||||
@@ -102,14 +122,20 @@ struct LookupResult: Codable {
|
|||||||
let found: Bool
|
let found: Bool
|
||||||
let existingProduct: Product?
|
let existingProduct: Product?
|
||||||
let suggestion: Suggestion?
|
let suggestion: Suggestion?
|
||||||
|
/// Gruppe: nur gesetzt, wenn der Code dort ausdruecklich hinterlegt ist.
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
let groupName: String?
|
let groupName: String?
|
||||||
|
/// Kategorie: aus der Open-Food-Facts-Einordnung vorgeschlagen.
|
||||||
|
let categoryId: Int?
|
||||||
|
let categoryName: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case found, suggestion
|
case found, suggestion
|
||||||
case existingProduct = "existing_product"
|
case existingProduct = "existing_product"
|
||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
case groupName = "group_name"
|
case groupName = "group_name"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case categoryName = "category_name"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Suggestion: Codable {
|
struct Suggestion: Codable {
|
||||||
@@ -215,6 +241,7 @@ struct NewProductRequest: Codable {
|
|||||||
let packageLabel: String?
|
let packageLabel: String?
|
||||||
let datePrecision: String
|
let datePrecision: String
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
|
let categoryId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case barcode, name, brand
|
case barcode, name, brand
|
||||||
@@ -224,6 +251,7 @@ struct NewProductRequest: Codable {
|
|||||||
case packageLabel = "package_label"
|
case packageLabel = "package_label"
|
||||||
case datePrecision = "date_precision"
|
case datePrecision = "date_precision"
|
||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
|
case categoryId = "category_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +344,8 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
var packageSize: Double?
|
var packageSize: Double?
|
||||||
var packageLabel: String?
|
var packageLabel: String?
|
||||||
var datePrecision: String?
|
var datePrecision: String?
|
||||||
/// Kategorie (heisst in der API weiterhin group_id).
|
|
||||||
var groupId: Int?
|
var groupId: Int?
|
||||||
|
var categoryId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name, brand
|
case name, brand
|
||||||
@@ -325,6 +353,7 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
case packageLabel = "package_label"
|
case packageLabel = "package_label"
|
||||||
case datePrecision = "date_precision"
|
case datePrecision = "date_precision"
|
||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
|
case categoryId = "category_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -335,6 +364,7 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
try container.encode(packageLabel, forKey: .packageLabel)
|
try container.encode(packageLabel, forKey: .packageLabel)
|
||||||
try container.encode(datePrecision, forKey: .datePrecision)
|
try container.encode(datePrecision, forKey: .datePrecision)
|
||||||
try container.encode(groupId, forKey: .groupId)
|
try container.encode(groupId, forKey: .groupId)
|
||||||
|
try container.encode(categoryId, forKey: .categoryId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,9 +386,33 @@ struct SettingEntry: Codable {
|
|||||||
let value: String
|
let value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kategorie (heisst in der Datenbank und in der API weiterhin "group";
|
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
|
||||||
/// nur die Beschriftung in der Oberflaeche wurde auf "Kategorie" geaendert).
|
|
||||||
struct GroupItem: Codable, Identifiable, Hashable {
|
struct GroupItem: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade).
|
||||||
|
struct CategoryItem: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let parentId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case parentId = "parent_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neue Gruppe anlegen (nur fuer Administratoren).
|
||||||
|
struct NewGroupRequest: Codable {
|
||||||
|
let name: String
|
||||||
|
let minStock: Double?
|
||||||
|
let minStockUnitId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case minStock = "min_stock"
|
||||||
|
case minStockUnitId = "min_stock_unit_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ struct ProductDetailView: View {
|
|||||||
@State private var packageLabel = ""
|
@State private var packageLabel = ""
|
||||||
@State private var datePrecision = "day"
|
@State private var datePrecision = "day"
|
||||||
@State private var groupId: Int?
|
@State private var groupId: Int?
|
||||||
@State private var categories: [GroupItem] = []
|
@State private var categoryId: Int?
|
||||||
|
@State private var groups: [GroupItem] = []
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
|
||||||
@State private var editLot: Lot?
|
@State private var editLot: Lot?
|
||||||
|
|
||||||
@@ -58,18 +60,30 @@ struct ProductDetailView: View {
|
|||||||
Picker("Bezeichnung", selection: $packageLabel) {
|
Picker("Bezeichnung", selection: $packageLabel) {
|
||||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
}
|
}
|
||||||
Picker("Kategorie", selection: $groupId) {
|
|
||||||
Text("– keine –").tag(Int?.none)
|
|
||||||
ForEach(categories) { kategorie in
|
|
||||||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("MHD-Angabe", selection: $datePrecision) {
|
Picker("MHD-Angabe", selection: $datePrecision) {
|
||||||
Text("Tagesdatum").tag("day")
|
Text("Tagesdatum").tag("day")
|
||||||
Text("nur Monat/Jahr").tag("month")
|
Text("nur Monat/Jahr").tag("month")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
CategoryPicker(categories: categories, selection: $categoryId)
|
||||||
|
} footer: {
|
||||||
|
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Gruppe", selection: $groupId) {
|
||||||
|
Text("– keine –").tag(Int?.none)
|
||||||
|
ForEach(groups) { gruppe in
|
||||||
|
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||||||
|
+ "wandert bei einem Wechsel mit.")
|
||||||
|
}
|
||||||
|
|
||||||
Section("Erkennung") {
|
Section("Erkennung") {
|
||||||
LabeledContent("Barcode", value: current.barcode ?? "–")
|
LabeledContent("Barcode", value: current.barcode ?? "–")
|
||||||
if !current.barcodes.isEmpty {
|
if !current.barcodes.isEmpty {
|
||||||
@@ -129,10 +143,12 @@ struct ProductDetailView: View {
|
|||||||
packageLabel = current.packageLabel ?? ""
|
packageLabel = current.packageLabel ?? ""
|
||||||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||||||
groupId = current.groupId
|
groupId = current.groupId
|
||||||
|
categoryId = current.categoryId
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reload() async {
|
private func reload() async {
|
||||||
categories = (try? await APIClient.shared.groups()) ?? []
|
groups = (try? await APIClient.shared.groups()) ?? []
|
||||||
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
||||||
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
||||||
current = frisch
|
current = frisch
|
||||||
@@ -153,7 +169,8 @@ struct ProductDetailView: View {
|
|||||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
datePrecision: datePrecision,
|
datePrecision: datePrecision,
|
||||||
groupId: groupId
|
groupId: groupId,
|
||||||
|
categoryId: categoryId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
status = "Gespeichert."
|
status = "Gespeichert."
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ struct ProductFormView: View {
|
|||||||
var onCreated: (Product) -> Void
|
var onCreated: (Product) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
@State private var barcode = ""
|
@State private var barcode = ""
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@@ -66,7 +67,11 @@ struct ProductFormView: View {
|
|||||||
@State private var packageLabel = ""
|
@State private var packageLabel = ""
|
||||||
@State private var datePrecision = "day"
|
@State private var datePrecision = "day"
|
||||||
@State private var selectedGroupId: Int?
|
@State private var selectedGroupId: Int?
|
||||||
@State private var categories: [GroupItem] = []
|
@State private var selectedCategoryId: Int?
|
||||||
|
@State private var groups: [GroupItem] = []
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
@State private var units: [Unit] = []
|
||||||
|
@State private var newGroupShown = false
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -80,12 +85,29 @@ struct ProductFormView: View {
|
|||||||
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
||||||
LabeledField(label: "Name", text: $name)
|
LabeledField(label: "Name", text: $name)
|
||||||
LabeledField(label: "Marke", text: $brand)
|
LabeledField(label: "Marke", text: $brand)
|
||||||
Picker("Kategorie", selection: $selectedGroupId) {
|
}
|
||||||
|
Section {
|
||||||
|
CategoryPicker(categories: categories, selection: $selectedCategoryId)
|
||||||
|
} footer: {
|
||||||
|
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Picker("Gruppe", selection: $selectedGroupId) {
|
||||||
Text("– keine –").tag(Int?.none)
|
Text("– keine –").tag(Int?.none)
|
||||||
ForEach(categories) { kategorie in
|
ForEach(groups) { gruppe in
|
||||||
Text(kategorie.name).tag(Int?.some(kategorie.id))
|
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if session.isAdmin {
|
||||||
|
Button {
|
||||||
|
newGroupShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Neue Gruppe anlegen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
||||||
|
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
|
||||||
}
|
}
|
||||||
Section("Einheit") {
|
Section("Einheit") {
|
||||||
Picker("Basiseinheit", selection: $baseUnit) {
|
Picker("Basiseinheit", selection: $baseUnit) {
|
||||||
@@ -114,12 +136,24 @@ struct ProductFormView: View {
|
|||||||
.navigationTitle("Neuer Artikel")
|
.navigationTitle("Neuer Artikel")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: prefill)
|
.onAppear(perform: prefill)
|
||||||
.task { categories = (try? await APIClient.shared.groups()) ?? [] }
|
.task {
|
||||||
|
groups = (try? await APIClient.shared.groups()) ?? []
|
||||||
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $newGroupShown) {
|
||||||
|
NavigationStack {
|
||||||
|
NewGroupSheet(units: units) { created in
|
||||||
|
groups.append(created)
|
||||||
|
selectedGroupId = created.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prefill() {
|
private func prefill() {
|
||||||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||||
// Aus dem gescannten Code vorgeschlagene Kategorie uebernehmen.
|
// Aus dem gescannten Code vorgeschlagene Gruppe uebernehmen.
|
||||||
selectedGroupId = groupId
|
selectedGroupId = groupId
|
||||||
if let suggestion {
|
if let suggestion {
|
||||||
name = suggestion.name
|
name = suggestion.name
|
||||||
@@ -144,7 +178,8 @@ struct ProductFormView: View {
|
|||||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
datePrecision: datePrecision,
|
datePrecision: datePrecision,
|
||||||
groupId: selectedGroupId
|
groupId: selectedGroupId,
|
||||||
|
categoryId: selectedCategoryId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onCreated(product)
|
onCreated(product)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ProductForm from "./pages/ProductForm";
|
|||||||
import CheckIn from "./pages/CheckIn";
|
import CheckIn from "./pages/CheckIn";
|
||||||
import CheckOut from "./pages/CheckOut";
|
import CheckOut from "./pages/CheckOut";
|
||||||
import Groups from "./pages/Groups";
|
import Groups from "./pages/Groups";
|
||||||
|
import Categories from "./pages/Categories";
|
||||||
import Locations from "./pages/Locations";
|
import Locations from "./pages/Locations";
|
||||||
import Units from "./pages/Units";
|
import Units from "./pages/Units";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
@@ -47,7 +48,8 @@ function Sidebar() {
|
|||||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||||||
<NavItem to="/products" icon="package" label="Produkte" />
|
<NavItem to="/products" icon="package" label="Produkte" />
|
||||||
<NavItem to="/groups" icon="tag" label="Kategorien" />
|
<NavItem to="/groups" icon="tag" label="Gruppen" />
|
||||||
|
<NavItem to="/categories" icon="package" label="Kategorien" />
|
||||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -105,6 +107,7 @@ export default function App() {
|
|||||||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||||
<Route path="/groups" element={<Protected><Groups /></Protected>} />
|
<Route path="/groups" element={<Protected><Groups /></Protected>} />
|
||||||
|
<Route path="/categories" element={<Protected><Categories /></Protected>} />
|
||||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||||
|
|||||||
@@ -98,7 +98,16 @@ export const api = {
|
|||||||
me: () => request("/auth/me"),
|
me: () => request("/auth/me"),
|
||||||
|
|
||||||
// Produkte
|
// Produkte
|
||||||
listProducts: (q) => request(`/products${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
listProducts: (q, categoryId) => {
|
||||||
|
// categoryId 0 = "ohne Kategorie"; eine echte ID schliesst Unterkategorien ein.
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
if (categoryId !== undefined && categoryId !== null && categoryId !== "") {
|
||||||
|
params.set("category_id", categoryId);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return request(`/products${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
getProduct: (id) => request(`/products/${id}`),
|
getProduct: (id) => request(`/products/${id}`),
|
||||||
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
||||||
createProduct: (body) => request("/products", { method: "POST", body }),
|
createProduct: (body) => request("/products", { method: "POST", body }),
|
||||||
@@ -128,6 +137,12 @@ export const api = {
|
|||||||
createLocation: (body) => request("/locations", { method: "POST", body }),
|
createLocation: (body) => request("/locations", { method: "POST", body }),
|
||||||
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
|
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Kategorien: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende)
|
||||||
|
listCategories: () => request("/categories"),
|
||||||
|
createCategory: (body) => request("/categories", { method: "POST", body }),
|
||||||
|
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
listGroups: () => request("/groups"),
|
listGroups: () => request("/groups"),
|
||||||
createGroup: (body) => request("/groups", { method: "POST", body }),
|
createGroup: (body) => request("/groups", { method: "POST", body }),
|
||||||
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
||||||
|
|||||||
28
web/src/categoryTree.js
Normal file
28
web/src/categoryTree.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Kategorien sind verschachtelbar (Süßwaren → Schokolade). Damit Liste,
|
||||||
|
// Auswahlfeld und Filter dieselbe Reihenfolge und Einrückung zeigen, steht die
|
||||||
|
// Aufbereitung hier einmal. Gleiches Vorgehen wie bei den Lagerorten.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt eine flache Liste in Baumreihenfolge und ergänzt `depth`.
|
||||||
|
* Kategorien, deren Elternteil fehlt, gelten als oberste Ebene – so geht
|
||||||
|
* nichts verloren, falls eine Oberkategorie gelöscht wurde.
|
||||||
|
*/
|
||||||
|
export function asTree(categories = []) {
|
||||||
|
const ids = new Set(categories.map((c) => c.id));
|
||||||
|
const isRoot = (c) => !c.parent_id || !ids.has(c.parent_id);
|
||||||
|
const ordered = [];
|
||||||
|
|
||||||
|
function walk(node, depth) {
|
||||||
|
ordered.push({ ...node, depth });
|
||||||
|
for (const child of categories.filter((c) => c.parent_id === node.id)) {
|
||||||
|
walk(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const root of categories.filter(isRoot)) walk(root, 0);
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einrückung für Auswahlfelder – dort geht kein CSS-Abstand. */
|
||||||
|
export function indentLabel(category) {
|
||||||
|
return `${" ".repeat(category.depth || 0)}${category.name}`;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import Icon from "./Icon";
|
|||||||
* Zwei Herkünfte in einer Liste: Codes, die über einen Artikel dazugehören
|
* Zwei Herkünfte in einer Liste: Codes, die über einen Artikel dazugehören
|
||||||
* (nur Anzeige – sie stehen am Artikel, nicht hier), und selbst hinterlegte
|
* (nur Anzeige – sie stehen am Artikel, nicht hier), und selbst hinterlegte
|
||||||
* Codes, die noch keinem Artikel gehören. Vorher waren nur letztere sichtbar,
|
* Codes, die noch keinem Artikel gehören. Vorher waren nur letztere sichtbar,
|
||||||
* weshalb eine Kategorie mit Artikeln „0 EANs“ anzeigte.
|
* weshalb eine Gruppe mit Artikeln „0 EANs“ anzeigte.
|
||||||
*
|
*
|
||||||
* onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt.
|
* onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
// Gemeinsame Helfer rund um Open-Food-Facts-Vorschläge.
|
// Gemeinsame Helfer rund um Open-Food-Facts-Vorschläge.
|
||||||
|
|
||||||
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
|
// Hinweis: Aus der OFF-Kategorie wurde frueher eine GRUPPE geraten. Das war die
|
||||||
export function guessGroup(groups, suggestion) {
|
// Verwechslung zweier Dinge - die OFF-Einordnung beschreibt eine Kategorie
|
||||||
if (!suggestion) return "";
|
// (Ueberblick), keine Gruppe (Bestandsrechnung). Den Vorschlag liefert jetzt
|
||||||
const haystack = [
|
// das Backend als category_id/category_name im Lookup.
|
||||||
suggestion.category_suggestion || "",
|
|
||||||
...(suggestion.category_tags || []),
|
|
||||||
suggestion.name || "",
|
|
||||||
].join(" ").toLowerCase();
|
|
||||||
const hit = groups.find((g) => haystack.includes(g.name.toLowerCase()));
|
|
||||||
return hit ? String(hit.id) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baut aus einem OFF-Vorschlag ein Produkt-Anlage-Payload.
|
// Baut aus einem OFF-Vorschlag ein Produkt-Anlage-Payload.
|
||||||
export function suggestionToProduct(s, groupId = "") {
|
export function suggestionToProduct(s, groupId = "", categoryId = "") {
|
||||||
return {
|
return {
|
||||||
barcode: s.barcode || null,
|
barcode: s.barcode || null,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -23,5 +16,6 @@ export function suggestionToProduct(s, groupId = "") {
|
|||||||
package_size: s.package_size ?? null,
|
package_size: s.package_size ?? null,
|
||||||
min_stock: null,
|
min_stock: null,
|
||||||
group_id: groupId === "" || groupId == null ? null : Number(groupId),
|
group_id: groupId === "" || groupId == null ? null : Number(groupId),
|
||||||
|
category_id: categoryId === "" || categoryId == null ? null : Number(categoryId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
165
web/src/pages/Categories.jsx
Normal file
165
web/src/pages/Categories.jsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import { asTree } from "../categoryTree";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategorien ordnen die Artikelliste – mehr nicht.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der
|
||||||
|
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein.
|
||||||
|
*/
|
||||||
|
export default function Categories() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [form, setForm] = useState({ name: "", parent_id: "" });
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setCategories(await api.listCategories());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function add(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.createCategory({
|
||||||
|
name: form.name,
|
||||||
|
parent_id: form.parent_id === "" ? null : Number(form.parent_id),
|
||||||
|
});
|
||||||
|
setForm({ name: "", parent_id: form.parent_id });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(category, body) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.updateCategory(category.id, body);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(category) {
|
||||||
|
const hinweis = category.product_count
|
||||||
|
? ` ${category.product_count} Artikel verlieren die Zuordnung, bleiben aber erhalten.`
|
||||||
|
: "";
|
||||||
|
if (!confirm(`Kategorie "${category.name}" löschen?${hinweis}`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteCategory(category.id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = asTree(categories);
|
||||||
|
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Kategorien</h1>
|
||||||
|
<div className="sub">
|
||||||
|
Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren".
|
||||||
|
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das
|
||||||
|
übernehmen die Gruppen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="card" style={{ padding: 0 }}>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th className="num">Artikel</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tree.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8,
|
||||||
|
paddingLeft: c.depth * 22 }}>
|
||||||
|
{c.depth > 0 && <span className="muted">↳</span>}
|
||||||
|
{isAdmin ? (
|
||||||
|
<input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
if (v && v !== c.name) patch(c, { name: v });
|
||||||
|
}} />
|
||||||
|
) : <span className="strong">{c.name}</span>}
|
||||||
|
{c.depth > 0 && c.parent_id && nameById[c.parent_id] && (
|
||||||
|
<span className="badge">in {nameById[c.parent_id]}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num muted">{c.product_count}</td>
|
||||||
|
<td className="num">
|
||||||
|
{isAdmin && (
|
||||||
|
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isAdmin && (
|
||||||
|
<form className="card" onSubmit={add}>
|
||||||
|
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={form.name} placeholder="z.B. Süßwaren & Snacks"
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Untergeordnet (optional)
|
||||||
|
<select value={form.parent_id}
|
||||||
|
onChange={(e) => setForm({ ...form, parent_id: e.target.value })}>
|
||||||
|
<option value="">– oberste Ebene –</option>
|
||||||
|
{tree.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{"— ".repeat(c.depth)}{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||||
|
<p className="muted small">
|
||||||
|
Unterkategorien sind beliebig tief möglich. Filterst du später auf eine
|
||||||
|
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit.
|
||||||
|
Namen lassen sich in der Tabelle direkt ändern.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { guessGroup, suggestionToProduct } from "../offUtils";
|
import { suggestionToProduct } from "../offUtils";
|
||||||
|
import { asTree } from "../categoryTree";
|
||||||
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: "" });
|
||||||
@@ -13,13 +14,19 @@ export default function CheckIn() {
|
|||||||
const [barcode, setBarcode] = useState("");
|
const [barcode, setBarcode] = useState("");
|
||||||
const [product, setProduct] = useState(null);
|
const [product, setProduct] = useState(null);
|
||||||
const [suggestion, setSuggestion] = useState(null);
|
const [suggestion, setSuggestion] = useState(null);
|
||||||
// Kategorie, die diesem Code zugeordnet ist (heisst in der API weiterhin "group").
|
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
|
||||||
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
|
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
|
||||||
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
// Auswahl fuer den Artikel, der aus dem Scan angelegt werden soll.
|
||||||
|
const [newGroupId, setNewGroupId] = useState("");
|
||||||
|
const [newCategoryId, setNewCategoryId] = useState("");
|
||||||
|
// Kleines Formular, um eine Gruppe direkt hier anzulegen.
|
||||||
|
const [groupDraft, setGroupDraft] = useState(null);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
|
|
||||||
const [unit, setUnit] = useState("");
|
const [unit, setUnit] = useState("");
|
||||||
@@ -36,12 +43,14 @@ export default function CheckIn() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listLocations().then(setLocations).catch(() => {});
|
api.listLocations().then(setLocations).catch(() => {});
|
||||||
api.listGroups().then(setGroups).catch(() => {});
|
api.listGroups().then(setGroups).catch(() => {});
|
||||||
|
api.listCategories().then(setCategories).catch(() => {});
|
||||||
api.listUnits().then(setUnits).catch(() => {});
|
api.listUnits().then(setUnits).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function resetLookup() {
|
function resetLookup() {
|
||||||
setSuggestion(null);
|
setSuggestion(null);
|
||||||
setUnknownBarcode(null);
|
setUnknownBarcode(null);
|
||||||
|
setGroupDraft(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProduct(p) {
|
function selectProduct(p) {
|
||||||
@@ -65,12 +74,14 @@ export default function CheckIn() {
|
|||||||
if (res.found && res.existing_product) {
|
if (res.found && res.existing_product) {
|
||||||
selectProduct(res.existing_product);
|
selectProduct(res.existing_product);
|
||||||
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
||||||
} else if (res.found && res.suggestion) {
|
|
||||||
setSuggestion(res.suggestion);
|
|
||||||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
|
||||||
} else {
|
} else {
|
||||||
setUnknownBarcode(barcode.trim());
|
// Gruppe kommt ausschliesslich aus einem hinterlegten Gruppen-Code,
|
||||||
|
// die Kategorie aus der Open-Food-Facts-Einordnung.
|
||||||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
||||||
|
setNewGroupId(res.group_id ? String(res.group_id) : "");
|
||||||
|
setNewCategoryId(res.category_id ? String(res.category_id) : "");
|
||||||
|
if (res.found && res.suggestion) setSuggestion(res.suggestion);
|
||||||
|
else setUnknownBarcode(barcode.trim());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -80,15 +91,17 @@ export default function CheckIn() {
|
|||||||
async function createFromSuggestion() {
|
async function createFromSuggestion() {
|
||||||
setError(null); setBusy(true);
|
setError(null); setBusy(true);
|
||||||
try {
|
try {
|
||||||
// Ist der Code einer Kategorie zugeordnet, hat das Vorrang vor dem Rateversuch aus OFF.
|
const payload = suggestionToProduct(suggestion, newGroupId, newCategoryId);
|
||||||
const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
|
|
||||||
const payload = suggestionToProduct(suggestion, groupId);
|
|
||||||
const created = await api.createProduct(payload);
|
const created = await api.createProduct(payload);
|
||||||
selectProduct(created);
|
selectProduct(created);
|
||||||
const kategorie = groups.find((g) => String(g.id) === String(created.group_id));
|
const gruppe = groups.find((g) => String(g.id) === String(created.group_id));
|
||||||
|
const kategorie = categories.find((c) => String(c.id) === String(created.category_id));
|
||||||
setInfo(
|
setInfo(
|
||||||
`Produkt "${created.name}" angelegt` +
|
`Produkt "${created.name}" angelegt` +
|
||||||
(kategorie ? ` und der Kategorie "${kategorie.name}" zugeordnet.` : " (ohne Kategorie).")
|
(kategorie ? `, Kategorie "${kategorie.name}"` : "") +
|
||||||
|
(gruppe
|
||||||
|
? `, Gruppe "${gruppe.name}" – der EAN-Code steht jetzt auch bei der Gruppe.`
|
||||||
|
: " (ohne Gruppe).")
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -97,6 +110,92 @@ export default function CheckIn() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gruppe an Ort und Stelle anlegen, damit man den Vorgang nicht verlassen muss. */
|
||||||
|
async function createGroup(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await api.createGroup({
|
||||||
|
name: groupDraft.name,
|
||||||
|
min_stock: groupDraft.min_stock === "" ? null : Number(groupDraft.min_stock),
|
||||||
|
min_stock_unit_id:
|
||||||
|
groupDraft.min_stock_unit_id === "" ? null : Number(groupDraft.min_stock_unit_id),
|
||||||
|
});
|
||||||
|
setGroups(await api.listGroups());
|
||||||
|
setNewGroupId(String(created.id));
|
||||||
|
setGroupDraft(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zuordnung des neu anzulegenden Artikels: Kategorie und Gruppe. */
|
||||||
|
function assignFields() {
|
||||||
|
return (
|
||||||
|
<div className="assign-box">
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Kategorie
|
||||||
|
<select value={newCategoryId} onChange={(e) => setNewCategoryId(e.target.value)}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{asTree(categories).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="muted small">Nur für den Überblick in der Artikelliste.</span>
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Gruppe
|
||||||
|
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="muted small">
|
||||||
|
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && !groupDraft && (
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => setGroupDraft({ name: "", min_stock: "", min_stock_unit_id: "" })}>
|
||||||
|
<Icon name="plus" size={14} />Neue Gruppe anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupDraft && (
|
||||||
|
<form className="card" style={{ marginTop: "var(--sp-3)" }} onSubmit={createGroup}>
|
||||||
|
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Name
|
||||||
|
<input value={groupDraft.name} placeholder="z.B. Mehl" required
|
||||||
|
onChange={(e) => setGroupDraft({ ...groupDraft, name: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Mindestbestand (optional)
|
||||||
|
<input type="number" step="any" value={groupDraft.min_stock} placeholder="z.B. 2"
|
||||||
|
onChange={(e) => setGroupDraft({ ...groupDraft, min_stock: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Einheit
|
||||||
|
<select value={groupDraft.min_stock_unit_id}
|
||||||
|
onChange={(e) => setGroupDraft({ ...groupDraft, min_stock_unit_id: e.target.value })}>
|
||||||
|
<option value="">– Basiseinheit –</option>
|
||||||
|
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="btn-pair">
|
||||||
|
<button type="button" className="btn ghost" onClick={() => setGroupDraft(null)}>Abbrechen</button>
|
||||||
|
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function doSearch(e) {
|
async function doSearch(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -191,7 +290,7 @@ export default function CheckIn() {
|
|||||||
{suggestionGroup.name && (
|
{suggestionGroup.name && (
|
||||||
<div className="assign-note">
|
<div className="assign-note">
|
||||||
<Icon name="tag" size={14} />
|
<Icon name="tag" size={14} />
|
||||||
Wird automatisch der Kategorie <strong>{suggestionGroup.name}</strong> zugeordnet
|
Wird automatisch der Gruppe <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||||
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -206,6 +305,8 @@ export default function CheckIn() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{suggestion && isAdmin && assignFields()}
|
||||||
|
|
||||||
{unknownBarcode && (
|
{unknownBarcode && (
|
||||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
<Icon name="alert" size={16} />
|
<Icon name="alert" size={16} />
|
||||||
@@ -218,13 +319,15 @@ export default function CheckIn() {
|
|||||||
{suggestionGroup.name && (
|
{suggestionGroup.name && (
|
||||||
<div className="assign-note">
|
<div className="assign-note">
|
||||||
<Icon name="tag" size={14} />
|
<Icon name="tag" size={14} />
|
||||||
Wird automatisch der Kategorie <strong>{suggestionGroup.name}</strong> zugeordnet
|
Wird automatisch der Gruppe <strong>{suggestionGroup.name}</strong> zugeordnet
|
||||||
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
<span className="muted small"> – weil dieser EAN-Code dort hinterlegt ist.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{unknownBarcode && isAdmin && assignFields()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function Dashboard() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{groupShopping.map((it) => (
|
{groupShopping.map((it) => (
|
||||||
<tr key={`g${it.group_id}`}>
|
<tr key={`g${it.group_id}`}>
|
||||||
<td><span className="badge accent">Kategorie</span> {it.name}</td>
|
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||||
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
|
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
|
||||||
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function Groups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(group) {
|
async function remove(group) {
|
||||||
if (!confirm(`Kategorie "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return;
|
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteGroup(group.id);
|
await api.deleteGroup(group.id);
|
||||||
if (selectedId === group.id) setSelectedId(null);
|
if (selectedId === group.id) setSelectedId(null);
|
||||||
@@ -68,9 +68,9 @@ export default function Groups() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Kategorien</h1>
|
<h1>Gruppen</h1>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
Produkte zusammenfassen, Mindestbestand je Kategorie setzen und EAN-Codes hinterlegen
|
Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl". Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +82,7 @@ export default function Groups() {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Kategorie</th>
|
<th>Gruppe</th>
|
||||||
<th className="num">Produkte</th>
|
<th className="num">Produkte</th>
|
||||||
<th className="num">Bestand</th>
|
<th className="num">Bestand</th>
|
||||||
<th>Mindestbestand</th>
|
<th>Mindestbestand</th>
|
||||||
@@ -133,7 +133,7 @@ export default function Groups() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
||||||
{/* Beide Herkünfte zählen, sonst steht bei einer Kategorie
|
{/* Beide Herkünfte zählen, sonst steht bei einer Gruppe
|
||||||
mit Artikeln irreführend eine 0. */}
|
mit Artikeln irreführend eine 0. */}
|
||||||
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
|
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
|
||||||
</button>
|
</button>
|
||||||
@@ -148,7 +148,7 @@ export default function Groups() {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Kategorien.</td></tr>}
|
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,16 +159,15 @@ export default function Groups() {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<h2>EAN-Codes: {selected.name}</h2>
|
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
<BarcodeList
|
<BarcodeList
|
||||||
barcodes={selected.barcodes || []}
|
barcodes={selected.barcodes || []}
|
||||||
productBarcodes={selected.product_barcodes || []}
|
productBarcodes={selected.product_barcodes || []}
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
hint={"Mit „Artikel“ gekennzeichnete Codes gehören einem Artikel dieser Kategorie " +
|
hint={"Codes der zugeordneten Artikel stehen automatisch hier. Zusätzlich kannst du Codes " +
|
||||||
"und lassen sich hier nicht ändern. Selbst hinterlegte Codes wirken nur für " +
|
"hinterlegen, die noch zu keinem Artikel gehören: Wird so ein Code beim " +
|
||||||
"noch unbekannte Artikel: Wird so ein Code beim Einlagern gescannt, landet " +
|
"Einlagern gescannt, landet der neu angelegte Artikel automatisch in dieser Gruppe."}
|
||||||
"der neu angelegte Artikel automatisch in dieser Kategorie."}
|
|
||||||
onAdd={async (body) => {
|
onAdd={async (body) => {
|
||||||
try {
|
try {
|
||||||
await api.addGroupBarcode(selected.id, body);
|
await api.addGroupBarcode(selected.id, body);
|
||||||
@@ -188,7 +187,7 @@ export default function Groups() {
|
|||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<form className="card" onSubmit={add}>
|
<form className="card" onSubmit={add}>
|
||||||
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
|
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
<input placeholder="z.B. Mehl" value={form.name}
|
<input placeholder="z.B. Mehl" value={form.name}
|
||||||
@@ -211,7 +210,7 @@ export default function Groups() {
|
|||||||
</div>
|
</div>
|
||||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Der Bestand der Kategorie summiert nur Produkte, die zur gewählten Einheit passen.
|
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
|
||||||
Namen lassen sich in der Tabelle direkt ändern.
|
Namen lassen sich in der Tabelle direkt ändern.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { api } from "../api";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import BarcodeList from "../components/BarcodeList";
|
import BarcodeList from "../components/BarcodeList";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { guessGroup } from "../offUtils";
|
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
|
import { asTree } from "../categoryTree";
|
||||||
import {
|
import {
|
||||||
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
||||||
} from "../units";
|
} from "../units";
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
barcode: "", name: "", brand: "", image_url: "",
|
barcode: "", name: "", brand: "", image_url: "",
|
||||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", group_id: "",
|
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
||||||
|
group_id: "", category_id: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
||||||
@@ -37,6 +38,7 @@ export default function ProductForm() {
|
|||||||
const [product, setProduct] = useState(null);
|
const [product, setProduct] = useState(null);
|
||||||
const [lots, setLots] = useState([]);
|
const [lots, setLots] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
@@ -76,8 +78,8 @@ export default function ProductForm() {
|
|||||||
setMinUnit(newSel);
|
setMinUnit(newSel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySuggestion(s, groupsList, unitList) {
|
function applySuggestion(res, unitList) {
|
||||||
const groupGuess = guessGroup(groupsList, s);
|
const s = res.suggestion;
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
barcode: s.barcode || f.barcode,
|
barcode: s.barcode || f.barcode,
|
||||||
@@ -86,17 +88,21 @@ export default function ProductForm() {
|
|||||||
image_url: s.image_url || f.image_url,
|
image_url: s.image_url || f.image_url,
|
||||||
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
|
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
|
||||||
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
|
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
|
||||||
group_id: f.group_id || groupGuess,
|
// Kategorie schlaegt das Backend aus der OFF-Einordnung vor; die Gruppe
|
||||||
|
// nur, wenn der Code dort ausdruecklich hinterlegt ist.
|
||||||
|
category_id: f.category_id || (res.category_id ? String(res.category_id) : ""),
|
||||||
|
group_id: f.group_id || (res.group_id ? String(res.group_id) : ""),
|
||||||
}));
|
}));
|
||||||
if (s.package_size != null) setMinUnit("package");
|
if (s.package_size != null) setMinUnit("package");
|
||||||
setInfo(
|
setInfo(
|
||||||
"Daten von Open Food Facts übernommen." +
|
"Daten von Open Food Facts übernommen." +
|
||||||
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") +
|
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") +
|
||||||
(groupGuess ? " Passende Kategorie vorgeschlagen." : "")
|
(res.category_name ? ` Kategorie „${res.category_name}" vorgeschlagen.` : "") +
|
||||||
|
(res.group_name ? ` Gruppe „${res.group_name}" über den EAN-Code erkannt.` : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLookup(code, groupsList, unitList) {
|
async function runLookup(code, unitList) {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setInfo(null);
|
setInfo(null);
|
||||||
@@ -106,7 +112,7 @@ export default function ProductForm() {
|
|||||||
setInfo("Dieses Produkt existiert bereits.");
|
setInfo("Dieses Produkt existiert bereits.");
|
||||||
navigate(`/products/${res.existing_product.id}`);
|
navigate(`/products/${res.existing_product.id}`);
|
||||||
} else if (res.found && res.suggestion) {
|
} else if (res.found && res.suggestion) {
|
||||||
applySuggestion(res.suggestion, groupsList, unitList);
|
applySuggestion(res, unitList);
|
||||||
} else {
|
} else {
|
||||||
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||||
}
|
}
|
||||||
@@ -118,9 +124,12 @@ export default function ProductForm() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
|
const [gs, us, cs] = await Promise.all([
|
||||||
|
api.listGroups(), api.listUnits(), api.listCategories(),
|
||||||
|
]);
|
||||||
setGroups(gs);
|
setGroups(gs);
|
||||||
setUnits(us);
|
setUnits(us);
|
||||||
|
setCategories(cs);
|
||||||
try {
|
try {
|
||||||
const s = await api.listSettings();
|
const s = await api.listSettings();
|
||||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||||
@@ -148,13 +157,14 @@ export default function ProductForm() {
|
|||||||
date_precision: p.date_precision === "month" ? "month" : "day",
|
date_precision: p.date_precision === "month" ? "month" : "day",
|
||||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||||
group_id: p.group_id ?? "",
|
group_id: p.group_id ?? "",
|
||||||
|
category_id: p.category_id ?? "",
|
||||||
});
|
});
|
||||||
setLots(await api.listLots(id));
|
setLots(await api.listLots(id));
|
||||||
} else {
|
} else {
|
||||||
const bc = searchParams.get("barcode");
|
const bc = searchParams.get("barcode");
|
||||||
if (bc) {
|
if (bc) {
|
||||||
set("barcode", bc);
|
set("barcode", bc);
|
||||||
await runLookup(bc, gs, us);
|
await runLookup(bc, us);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -184,6 +194,7 @@ export default function ProductForm() {
|
|||||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||||
min_stock_in_packages: sel === "package",
|
min_stock_in_packages: sel === "package",
|
||||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||||
|
category_id: form.category_id === "" ? null : Number(form.category_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +338,33 @@ export default function ProductForm() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Kategorie
|
Gruppe
|
||||||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||||||
<option value="">– keine –</option>
|
<option value="">– keine –</option>
|
||||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<span className="muted small">
|
||||||
|
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code dieses Artikels
|
||||||
|
erscheint danach automatisch bei der Gruppe.
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Kategorie
|
||||||
|
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
|
||||||
|
disabled={readOnly}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{asTree(categories).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="muted small">
|
||||||
|
Nur für den Überblick in der Artikelliste – ohne Einfluss auf Bestände.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="grow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { fmt, unitShort } from "../units";
|
import { fmt, unitShort } from "../units";
|
||||||
|
import { asTree } from "../categoryTree";
|
||||||
|
|
||||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||||
function unitCount(p) {
|
function unitCount(p) {
|
||||||
@@ -19,11 +20,14 @@ export default function Products() {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
// "" = alle, "0" = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||||||
|
const [categoryId, setCategoryId] = useState("");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
async function load() {
|
async function load(cat = categoryId) {
|
||||||
try {
|
try {
|
||||||
setProducts(await api.listProducts(q));
|
setProducts(await api.listProducts(q, cat));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,7 @@ export default function Products() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
api.listCategories().then(setCategories).catch(() => {});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -53,10 +58,19 @@ export default function Products() {
|
|||||||
|
|
||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||||
|
|
||||||
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 420 }} onSubmit={onSearch}>
|
<form className="field-inline" style={{ marginBottom: "var(--sp-4)", maxWidth: 640 }} onSubmit={onSearch}>
|
||||||
<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 }}>
|
||||||
|
<select value={categoryId} onChange={(e) => { setCategoryId(e.target.value); load(e.target.value); }}>
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
<option value="0">Ohne Kategorie</option>
|
||||||
|
{asTree(categories).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -68,6 +82,7 @@ export default function Products() {
|
|||||||
<th></th>
|
<th></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Marke</th>
|
<th>Marke</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
<th>Einheit</th>
|
<th>Einheit</th>
|
||||||
<th className="num">Bestand</th>
|
<th className="num">Bestand</th>
|
||||||
<th className="num">Einheiten (ist / soll)</th>
|
<th className="num">Einheiten (ist / soll)</th>
|
||||||
@@ -93,6 +108,7 @@ export default function Products() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{p.brand || "–"}</td>
|
<td className="muted">{p.brand || "–"}</td>
|
||||||
|
<td className="muted">{p.category_name || "–"}</td>
|
||||||
<td>
|
<td>
|
||||||
{p.unit_name || unitShort(p.base_unit)}
|
{p.unit_name || unitShort(p.base_unit)}
|
||||||
{p.package_size
|
{p.package_size
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function ShoppingList() {
|
|||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Einkaufsliste</h1>
|
<h1>Einkaufsliste</h1>
|
||||||
<div className="sub">Produkte und Kategorien unter ihrem Mindestbestand</div>
|
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||||
@@ -42,7 +42,7 @@ export default function ShoppingList() {
|
|||||||
<li key={key} className={checked[key] ? "done" : ""}>
|
<li key={key} className={checked[key] ? "done" : ""}>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||||
<span className="badge accent">Kategorie</span>
|
<span className="badge accent">Gruppe</span>
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function Transfer() {
|
|||||||
<p className="muted small mt-0">
|
<p className="muted small mt-0">
|
||||||
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
|
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
|
||||||
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
|
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
|
||||||
Einheiten, Kategorien und Lagerorten.
|
Einheiten, Gruppen und Lagerorten.
|
||||||
</p>
|
</p>
|
||||||
<div className="field-inline">
|
<div className="field-inline">
|
||||||
<button className="btn primary" onClick={() => download("csv")}>
|
<button className="btn primary" onClick={() => download("csv")}>
|
||||||
@@ -84,7 +84,7 @@ export default function Transfer() {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
||||||
<p className="muted small mt-0">
|
<p className="muted small mt-0">
|
||||||
CSV oder JSON auswählen. Unbekannte Produkte, Kategorien und Lagerorte werden immer
|
CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer
|
||||||
angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.
|
angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={doImport}>
|
<form onSubmit={doImport}>
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ td select { width: auto; min-width: 132px; max-width: 100%; }
|
|||||||
.precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; }
|
.precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; }
|
||||||
.precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; }
|
.precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; }
|
||||||
/* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */
|
/* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */
|
||||||
|
.assign-box { margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
||||||
.assign-note {
|
.assign-note {
|
||||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||||
margin-top: 4px; padding: 4px 8px;
|
margin-top: 4px; padding: 4px 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user