Compare commits

...

7 Commits

Author SHA1 Message Date
Scarriffle
f8c0cec918 iOS: Kaufpreis + Belege am Einzelstück
ItemEditView bekommt einen Kaufpreis (Betrag + CHF/EUR) und eine Beleg-Sektion:
Rechnung/Garantieschein als Bild (Fotoauswahl) oder PDF (Datei-Import)
hochladen, ansehen (QuickLook) und loeschen. Nach einem PDF-Upload schlaegt der
Server "Garantie bis" und Kaufpreis vor – per Knopf uebernehmbar. Item-Model um
priceCents/currency/documents erweitert; Client-Methoden fuer Upload, Download
und Loeschen der Belege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:02:58 +02:00
Scarriffle
b9709d29bf Web: Kaufpreis + Belege an Einzelstücken
Je Einzelstück eine ausklappbare Detailzeile: Kaufpreis (Betrag + CHF/EUR) und
Belege (Rechnung/Garantieschein) hochladen, ansehen, löschen. Nach dem Upload
eines PDFs schlägt der Server „Garantie bis" und Kaufpreis vor – per Knopf
übernehmbar. Belege werden mit Anmeldung geladen (kein offener Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
ba65e48d47 Backend: Kaufpreis aus Beleg-PDF schätzen
guess_price_cents zieht den Kaufpreis aus dem Belegtext: bevorzugt den Betrag
nach einem Summen-Stichwort (Gesamtbetrag/Total/…), sonst den groessten Betrag.
Versteht Tausendertrenner (1'299.00) und deutsches Format (1.299,00). Der
Upload liefert den Vorschlag als suggested_price_cents mit. 5 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
61270e6d00 Backend: Kaufpreis + Belege an Einzelstücken, PDF-Garantie-Vorschlag
- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt.
- Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob
  wird verzoegert geladen, damit Item-Listen leicht bleiben.
- Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck.
  Dateiname beim Download gehaertet (keine Header-Injektion).
- services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende
  (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt.
  Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:49:08 +02:00
Scarriffle
9a11e3cfc6 iOS: Lagerorte umhängen, Unterkategorien anlegen, eigene Felder verwalten
- Lagerorte: LocationEditor hängt bestehende Orte jetzt um (Elternort auch beim
  Bearbeiten, eigene Unterorte ausgeschlossen); Client updateLocation +
  LocationUpdateRequest (parent_id wird immer gesendet, auch null).
- Kategorien: beim Anlegen optionale Oberkategorie wählbar (Unterkategorie);
  mit Eltern erbt sie deren Verwaltungsart, sonst wird sie gewählt.
- Eigene Felder: neue CategoryFieldsView + FieldEditor (anlegen/bearbeiten/
  löschen von Feldern je Kategorie), erreichbar aus dem Kategorie-Editor;
  Client ownFieldDefinitions/create/update/deleteFieldDefinition + Models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:10:14 +02:00
Scarriffle
f88a459e12 Web: Etiketten-CSV gegen Formel-Injektion härten
Zellen, die mit = + - @ (oder Tab/CR) beginnen, werden mit einem vorangestellten
Apostroph zu Text entschaerft. So fuehrt ein Produktname wie "=HYPERLINK(...)"
beim Oeffnen der Etiketten-CSV in Excel/P-touch nicht mehr als Formel aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 15:58:54 +02:00
Scarriffle
b49546b297 Lagerorte umhängen (Backend + Web)
Bestehende Lagerorte lassen sich jetzt nachträglich einem anderen Elternort
zuordnen oder auf die oberste Ebene holen.
- Backend: LocationUpdate um parent_id (Name jetzt optional); update_location
  haengt um, mit Schutz gegen Ringe (weder auf sich selbst noch auf einen
  eigenen Unterort) und Existenzpruefung des Ziels. 6 Tests.
- Web: Lagerorte-Seite bekommt je Ort Bearbeiten (Name + Elternort ueber den
  CategorySelect-Baum, eigene Unterorte ausgeschlossen); api.updateLocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 15:58:54 +02:00
18 changed files with 1457 additions and 82 deletions

View File

@@ -85,6 +85,9 @@ def _ensure_schema() -> None:
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt. # Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN " "ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
"NOT NULL DEFAULT FALSE", "NOT NULL DEFAULT FALSE",
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
] ]
with engine.begin() as conn: with engine.begin() as conn:
for stmt in stmts: for stmt in stmts:

View File

@@ -530,8 +530,35 @@ class Item(Base):
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
# Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung
# entsteht; Währung getrennt (z.B. CHF, EUR).
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship() product: Mapped[Product] = relationship()
location: Mapped[Location | None] = relationship() location: Mapped[Location | None] = relationship()
shop: Mapped[Shop | None] = relationship() shop: Mapped[Shop | None] = relationship()
documents: Mapped[list["ItemDocument"]] = relationship(
cascade="all, delete-orphan", order_by="ItemDocument.id"
)
class ItemDocument(Base):
"""Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild).
Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei
jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt.
"""
__tablename__ = "item_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
item_id: Mapped[int] = mapped_column(
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)

View File

@@ -5,17 +5,44 @@ Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
Gegenstands-Produkte mit ``Product.individual``. Gegenstands-Produkte mit ``Product.individual``.
""" """
from fastapi import APIRouter, Depends, HTTPException, status import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session 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 ..models import Item, Location, Movement, MovementType, Product, Shop, User from ..models import (
from ..schemas import ItemCreate, ItemOut, ItemRemove, ItemUpdate Item,
ItemDocument,
Location,
Movement,
MovementType,
Product,
Shop,
User,
)
from ..schemas import (
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
ItemRemove,
ItemUpdate,
)
from ..services.items import generate_uid, item_to_out from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_price_cents,
guess_warranty_until,
)
router = APIRouter(tags=["items"]) router = APIRouter(tags=["items"])
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
# zur nginx-Grenze (client_max_body_size 12m).
DOC_MAX_BYTES = 10 * 1024 * 1024
def _product_or_404(db: Session, product_id: int) -> Product: def _product_or_404(db: Session, product_id: int) -> Product:
product = db.get(Product, product_id) product = db.get(Product, product_id)
@@ -72,6 +99,8 @@ def create_items(
acquired_on=payload.acquired_on, acquired_on=payload.acquired_on,
warranty_until=payload.warranty_until, warranty_until=payload.warranty_until,
note=payload.note, note=payload.note,
price_cents=payload.price_cents,
currency=payload.currency,
) )
db.add(item) db.add(item)
db.flush() db.flush()
@@ -156,3 +185,106 @@ def delete_item(
"""Einzelstück ohne Grund löschen (Korrektur).""" """Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id)) db.delete(_item_or_404(db, item_id))
db.commit() db.commit()
# --------------------------------------------------------------------------
# Belege (Rechnung/Garantieschein) je Einzelstück
# --------------------------------------------------------------------------
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
doc = (
db.query(ItemDocument)
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
.first()
)
if doc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
return doc
def _safe_filename(name: str | None) -> str:
"""Steuerzeichen/Anführungszeichen raus sonst Header-Injektion im Download."""
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
return cleaned[:255] or "beleg"
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
)
content_type = file.content_type or "application/octet-stream"
if content_type != "application/pdf" and not content_type.startswith("image/"):
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
)
doc = ItemDocument(
item_id=item.id,
filename=_safe_filename(file.filename),
content_type=content_type,
data=data,
)
db.add(doc)
db.commit()
db.refresh(doc)
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
warranty = price = None
if content_type == "application/pdf":
text = extract_pdf_text(data)
warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
price = guess_price_cents(text)
return ItemDocumentUploadOut(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty,
suggested_price_cents=price,
)
@router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Beleg anzeigen/herunterladen."""
doc = _document_or_404(db, item_id, doc_id)
return Response(
content=doc.data,
media_type=doc.content_type,
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
)
@router.delete(
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.delete(_document_or_404(db, item_id, doc_id))
db.commit()

View File

@@ -10,6 +10,20 @@ from ..schemas import LocationCreate, LocationOut, LocationUpdate
router = APIRouter(prefix="/locations", tags=["locations"]) router = APIRouter(prefix="/locations", tags=["locations"])
def _descendant_ids(db: Session, location_id: int) -> set[int]:
"""Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen, sonst
entstünde ein Ring."""
result: set[int] = set()
stack = [location_id]
while stack:
cur = stack.pop()
for kid in db.query(Location).filter(Location.parent_id == cur).all():
if kid.id not in result:
result.add(kid.id)
stack.append(kid.id)
return result
@router.get("", response_model=list[LocationOut]) @router.get("", response_model=list[LocationOut])
def list_locations( def list_locations(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -37,21 +51,41 @@ def update_location(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> Location: ) -> Location:
"""Umbenennen. Chargen haengen an der ID, behalten ihren Lagerort also.""" """Umbenennen und/oder umhängen. Chargen haengen an der ID, behalten ihren
Lagerort also."""
loc = db.get(Location, location_id) loc = db.get(Location, location_id)
if loc is None: if loc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
name = payload.name.strip() data = payload.model_dump(exclude_unset=True)
doppelt = (
db.query(Location) if "name" in data and data["name"]:
.filter(func.lower(Location.name) == name.lower(), Location.id != location_id) name = data["name"].strip()
.first() doppelt = (
) db.query(Location)
if doppelt is not None: .filter(func.lower(Location.name) == name.lower(), Location.id != location_id)
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits") .first()
)
if doppelt is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits")
loc.name = name
# parent_id nur anfassen, wenn ausdrücklich mitgeschickt (None = oberste Ebene).
if "parent_id" in data:
neu = data["parent_id"]
if neu is not None:
if db.get(Location, neu) is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Übergeordneter Lagerort nicht gefunden"
)
if neu == location_id or neu in _descendant_ids(db, location_id):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Ein Lagerort kann nicht sich selbst oder einem seiner Unterorte "
"untergeordnet werden.",
)
loc.parent_id = neu
loc.name = name
db.commit() db.commit()
db.refresh(loc) db.refresh(loc)
return loc return loc

View File

@@ -235,7 +235,8 @@ class LocationCreate(BaseModel):
class LocationUpdate(BaseModel): class LocationUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None
# ---- Gebinde (Packung, Glas, …) ---- # ---- Gebinde (Packung, Glas, …) ----
@@ -511,6 +512,8 @@ class ItemCreate(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemUpdate(BaseModel): class ItemUpdate(BaseModel):
@@ -519,6 +522,8 @@ class ItemUpdate(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemRemove(BaseModel): class ItemRemove(BaseModel):
@@ -526,6 +531,21 @@ class ItemRemove(BaseModel):
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
class ItemDocumentOut(BaseModel):
"""Beleg-Metadaten (ohne Binärdaten)."""
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
content_type: str
uploaded_at: datetime
class ItemDocumentUploadOut(ItemDocumentOut):
"""Antwort nach dem Upload mit aus dem PDF geschätztem Garantieende und Preis."""
suggested_warranty_until: date | None = None
suggested_price_cents: int | None = None
class ItemOut(BaseModel): class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -538,10 +558,13 @@ class ItemOut(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = None note: str | None = None
price_cents: int | None = None
currency: str | None = None
created_at: datetime created_at: datetime
# Für QR-Auflösung/Anzeige mitgeliefert: # Für QR-Auflösung/Anzeige mitgeliefert:
product_name: str | None = None product_name: str | None = None
product_brand: str | None = None product_brand: str | None = None
documents: list[ItemDocumentOut] = []
# ---- Views ---- # ---- Views ----

View File

@@ -0,0 +1,179 @@
"""Aus einem Beleg-PDF ein mögliches Garantieende schätzen lokal, ohne KI.
Zwei Wege, in dieser Reihenfolge:
1. Garantiezeitraum ("24 Monate", "2 Jahre") + Kaufdatum (aus dem Beleg oder vom
Einzelstück) → Enddatum ausrechnen.
2. Ein Datum in der Nähe eines Garantie-Stichworts.
Bewusst konservativ: lieber nichts vorschlagen als etwas Falsches das Ergebnis
ist nur ein Vorschlag, den der Nutzer bestätigt. Reine Scan-PDFs ohne Textebene
liefern keinen Text und damit keinen Vorschlag (dafür bräuchte es OCR).
"""
from __future__ import annotations
import io
import re
from datetime import date
from dateutil.relativedelta import relativedelta
_KEYWORD_RE = re.compile(r"garantie|gew[äa]hrleistung|warranty", re.IGNORECASE)
_PERIOD_RE = re.compile(r"(\d{1,3})\s*(jahr|monat|year|month)", re.IGNORECASE)
_PURCHASE_KW = re.compile(
r"rechnungsdatum|rechnung|kaufdatum|bestelldatum|bestellt|belegdatum|"
r"datum|invoice|order date|purchase|receipt",
re.IGNORECASE,
)
# 2024-03-31 oder 31.03.2024 / 31/03/24
_DATE_RE = re.compile(
r"(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})"
)
_WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten
def extract_pdf_text(data: bytes) -> str:
"""Text aus einem PDF ziehen. Schlägt es fehl (kaputt, reiner Scan), ""."""
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
return "\n".join((page.extract_text() or "") for page in reader.pages)
except Exception:
return ""
def _match_to_date(m: re.Match) -> date | None:
g = m.groups()
try:
if g[0] is not None: # ISO yyyy-mm-dd
y, mo, d = int(g[0]), int(g[1]), int(g[2])
else: # dd.mm.yyyy / dd/mm/yy
d, mo, y = int(g[3]), int(g[4]), int(g[5])
if y < 100:
y += 2000
return date(y, mo, d)
except (TypeError, ValueError):
return None
def _all_dates(text: str) -> list[tuple[int, date]]:
out: list[tuple[int, date]] = []
for m in _DATE_RE.finditer(text):
d = _match_to_date(m)
if d is not None:
out.append((m.start(), d))
return out
def _find_period(text: str) -> tuple[int, str] | None:
"""(Anzahl, Einheit) in der Nähe eines Garantie-Stichworts beide Reihenfolgen."""
for kw in _KEYWORD_RE.finditer(text):
fenster = text[max(0, kw.start() - _WINDOW): kw.end() + _WINDOW]
p = _PERIOD_RE.search(fenster)
if p:
return int(p.group(1)), p.group(2).lower()
return None
def _find_purchase_date(text: str) -> date | None:
dates = _all_dates(text)
if not dates:
return None
best: date | None = None
best_dist = 10**9
for kw in _PURCHASE_KW.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist:
best, best_dist = d, dist
# Kein Kauf-Stichwort getroffen: das früheste Datum liegt am ehesten vor dem
# Garantieende.
return best if best is not None else min(d for _, d in dates)
def _find_date_near_keyword(text: str) -> date | None:
dates = _all_dates(text)
best: date | None = None
best_dist = 10**9
for kw in _KEYWORD_RE.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist:
best, best_dist = d, dist
return best
def _add_period(base: date, n: int, unit: str) -> date:
if unit.startswith(("jahr", "year")):
return base + relativedelta(years=n)
return base + relativedelta(months=n)
def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | None:
"""Bestes rät fürs Garantieende oder ``None``.
``acquired_on`` ist das am Einzelstück hinterlegte Kaufdatum es hat Vorrang
vor einem im Beleg gefundenen Datum.
"""
if not text:
return None
period = _find_period(text)
base = acquired_on or _find_purchase_date(text)
if period and base:
return _add_period(base, period[0], period[1])
return _find_date_near_keyword(text)
# --------------------------------------------------------------------------
# Kaufpreis aus dem Beleg schätzen
# --------------------------------------------------------------------------
_TOTAL_KW = re.compile(
r"gesamtbetrag|gesamtsumme|rechnungsbetrag|endbetrag|gesamt|summe|total|"
r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE,
)
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}")
def _amount_to_cents(raw: str) -> int | None:
""""1'299.00" / "1.299,00" / "49,90" -> Rappen/Cent."""
s = raw.replace(" ", "").replace("'", "")
m = re.search(r"[.,](\d{2})$", s) # letzter Trenner ist der Dezimalpunkt
if not m:
return None
dezimal = m.group(1)
ganz = re.sub(r"[.,]", "", s[: m.start()]) # Tausendertrenner entfernen
if not ganz.isdigit():
return None
return int(ganz) * 100 + int(dezimal)
def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert).
"""
if not text:
return None
betraege = [
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text)
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
return None
# Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") deshalb den
# nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten
# (sonst gewönne eine davor stehende Zwischenzeile).
best: int | None = None
best_dist = 10**9
for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege:
if pos >= kw.start():
dist = pos - kw.start()
if dist <= 40 and dist < best_dist:
best, best_dist = c, dist
return best if best is not None else max(c for _, c in betraege)

View File

@@ -9,4 +9,5 @@ bcrypt==4.2.1
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pypdf==5.1.0
pytest==8.3.4 pytest==8.3.4

View File

@@ -0,0 +1,55 @@
"""Einzelstücke: Kaufpreis und Belege (Metadaten in der Ausgabe, Dateinamen-Härtung)."""
import pytest
from app.models import Category, CategoryTracking, Item, ItemDocument, Product, Role, User
from app.routers.items import _safe_filename, create_items, get_item
from app.schemas import ItemCreate
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _product(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Kamera", category_id=cat.id, individual=True)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_item_kaufpreis_round_trip(db, admin):
product = _product(db)
out = create_items(
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
)
assert out[0].price_cents == 49900
assert out[0].currency == "CHF"
def test_item_out_listet_belege(db, admin):
product = _product(db)
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
content_type="application/pdf", data=b"%PDF-1.4 x"))
db.commit()
fresh = get_item(item_id, db=db, _=admin)
assert len(fresh.documents) == 1
assert fresh.documents[0].filename == "rechnung.pdf"
assert fresh.documents[0].content_type == "application/pdf"
def test_safe_filename_entfernt_header_zeichen():
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
assert _safe_filename(" ") == "beleg"
assert _safe_filename(None) == "beleg"

View File

@@ -0,0 +1,71 @@
"""Lagerorte: Umbenennen und Umhängen (mit Schutz vor Ringen)."""
import pytest
from fastapi import HTTPException
from app.models import Location, Role, User
from app.routers.locations import update_location
from app.schemas import LocationUpdate
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _orte(db):
"""Keller → Regal → Fach."""
keller = Location(name="Keller")
db.add(keller)
db.flush()
regal = Location(name="Regal", parent_id=keller.id)
db.add(regal)
db.flush()
fach = Location(name="Fach", parent_id=regal.id)
db.add(fach)
db.commit()
return keller, regal, fach
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
keller, regal, _ = _orte(db)
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
assert out.name == "Regal links"
assert out.parent_id == keller.id # Umhängen war nicht gemeint
def test_umhaengen_setzt_neuen_parent(db, admin):
keller, _, fach = _orte(db)
out = update_location(fach.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
assert out.parent_id == keller.id
def test_umhaengen_auf_oberste_ebene(db, admin):
_, regal, _ = _orte(db)
out = update_location(regal.id, LocationUpdate(parent_id=None), db=db, _=admin)
assert out.parent_id is None
def test_umhaengen_auf_sich_selbst_wird_abgelehnt(db, admin):
keller, _, _ = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(keller.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
assert ex.value.status_code == 400
def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin):
keller, _, fach = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(keller.id, LocationUpdate(parent_id=fach.id), db=db, _=admin)
assert ex.value.status_code == 400
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
_, _, fach = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(fach.id, LocationUpdate(parent_id=99999), db=db, _=admin)
assert ex.value.status_code == 404

View File

@@ -0,0 +1,66 @@
"""Lokale Garantieende-Schätzung aus Belegtext (ohne echtes PDF)."""
from datetime import date
from app.services.warranty import guess_price_cents, guess_warranty_until
def test_zeitraum_plus_kaufdatum_vom_stueck():
# Beleg nennt nur den Zeitraum; das Kaufdatum kommt vom Einzelstück.
text = "Vielen Dank für Ihren Einkauf. 24 Monate Garantie auf dieses Gerät."
assert guess_warranty_until(text, acquired_on=date(2024, 3, 31)) == date(2026, 3, 31)
def test_jahre_werden_verstanden():
text = "2 Jahre Gewährleistung"
assert guess_warranty_until(text, acquired_on=date(2023, 1, 15)) == date(2025, 1, 15)
def test_zeitraum_plus_rechnungsdatum_aus_dem_beleg():
text = "Rechnungsdatum: 05.06.2024\nGarantie: 12 Monate"
assert guess_warranty_until(text) == date(2025, 6, 5)
def test_warranty_keyword_before_period_english():
text = "Warranty period is 36 months from purchase."
assert guess_warranty_until(text, acquired_on=date(2022, 2, 1)) == date(2025, 2, 1)
def test_explizites_datum_neben_stichwort():
text = "Garantie gültig bis 31.12.2027."
assert guess_warranty_until(text) == date(2027, 12, 31)
def test_ohne_hinweise_kein_vorschlag():
assert guess_warranty_until("Nur ein Kassenbon ohne alles.") is None
def test_leerer_text_ist_none():
assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is None
# ---- Preis ----
def test_preis_nahe_total_stichwort():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90 CHF"
assert guess_price_cents(text) == 5490
def test_preis_mit_tausendertrenner_apostroph():
text = "Total CHF 1'299.00"
assert guess_price_cents(text) == 129900
def test_preis_deutsches_format():
text = "Rechnungsbetrag 1.299,00 EUR"
assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag():
text = "Position A 12,90\nPosition B 199,00\nDanke."
assert guess_price_cents(text) == 19900
def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None

View File

@@ -182,6 +182,36 @@ actor APIClient {
try await sendNoContent(request) try await sendNoContent(request)
} }
func uploadItemDocument(itemId: Int, data: Data, filename: String,
contentType: String) async throws -> ItemDocumentUpload {
var request = try makeRequest("/items/\(itemId)/documents", method: "POST")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let safeName = filename.replacingOccurrences(of: "\"", with: "")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: ItemDocumentUpload.self)
}
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return data
}
func deleteItemDocument(itemId: Int, docId: Int) async throws {
try await sendNoContent(
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
}
func deleteItem(id: Int) async throws { func deleteItem(id: Int) async throws {
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
} }
@@ -280,6 +310,29 @@ actor APIClient {
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self) try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
} }
/// Nur die *eigenen* Felder einer Kategorie (ohne vererbte) fuer die
/// Feldverwaltung, wo man sie anlegt, bearbeitet und loescht.
func ownFieldDefinitions(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/field-definitions?category_id=\(categoryId)"),
as: [FieldDefinition].self)
}
func createFieldDefinition(_ payload: FieldDefinitionCreate) async throws -> FieldDefinition {
var request = try makeRequest("/field-definitions", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: FieldDefinition.self)
}
func updateFieldDefinition(id: Int, _ payload: FieldDefinitionUpdate) async throws -> FieldDefinition {
var request = try makeRequest("/field-definitions/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: FieldDefinition.self)
}
func deleteFieldDefinition(id: Int) async throws {
try await sendNoContent(try makeRequest("/field-definitions/\(id)", method: "DELETE"))
}
/// Menge eines Gegenstands an einem Lagerort erhoehen. /// Menge eines Gegenstands an einem Lagerort erhoehen.
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse { func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin", method: "POST") var request = try makeRequest("/stock/checkin", method: "POST")
@@ -358,6 +411,12 @@ actor APIClient {
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func updateLocation(id: Int, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: StorageLocation.self)
}
func deleteLocation(id: Int) async throws { func deleteLocation(id: Int) async throws {
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
} }

View File

@@ -1,5 +1,8 @@
import SwiftUI import SwiftUI
import CoreImage import CoreImage
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit /// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
/// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet. /// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet.
@@ -96,11 +99,20 @@ struct ItemEditView: View {
@State private var hasWarranty = false @State private var hasWarranty = false
@State private var warranty = Date() @State private var warranty = Date()
@State private var note = "" @State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var busy = false @State private var busy = false
@State private var busyDoc = false
@State private var error: String? @State private var error: String?
@State private var showRemove = false @State private var showRemove = false
@State private var reason = "broken" @State private var reason = "broken"
@State private var removeNote = "" @State private var removeNote = ""
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
var body: some View { var body: some View {
Form { Form {
@@ -136,6 +148,52 @@ struct ItemEditView: View {
LabeledField(label: "Notiz", text: $note) LabeledField(label: "Notiz", text: $note)
} }
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section("Belege (Rechnung/Garantieschein)") {
if suggWarranty != nil || suggPrice != nil {
VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let w = suggWarranty { Text("Garantie bis \(w)") }
if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") }
HStack {
Button("Übernehmen") { applySuggestion() }
Spacer()
Button("Verwerfen") { suggWarranty = nil; suggPrice = nil }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section { Section {
@@ -150,6 +208,30 @@ struct ItemEditView: View {
.sheet(isPresented: $showRemove) { .sheet(isPresented: $showRemove) {
NavigationStack { removeSheet } NavigationStack { removeSheet }
} }
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.quickLookPreview($previewURL)
} }
private var removeSheet: some View { private var removeSheet: some View {
@@ -174,23 +256,84 @@ struct ItemEditView: View {
locationId = item.locationId locationId = item.locationId
shopId = item.shopId shopId = item.shopId
note = item.note ?? "" note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
} }
private func save() async { private func save() async {
busy = true; defer { busy = false }; error = nil busy = true; defer { busy = false }; error = nil
let cents = ItemEditView.parseCents(priceText)
do { do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest( _ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId, locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil, acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil, warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note)) note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
onChanged?() onChanged?()
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil
suggPrice = res.suggestedPriceCents
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func applySuggestion() {
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
suggWarranty = nil
suggPrice = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
private func remove() async { private func remove() async {
do { do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest( try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(

View File

@@ -430,9 +430,9 @@ struct LocationsView: View {
} }
} }
/// Lagerort anlegen (mit optionalem Elternort, um die Hierarchie aufzubauen) /// Lagerort anlegen oder bearbeiten: Name **und** übergeordneter Ort. So lässt
/// oder umbenennen. Umhängen unterstützt der Server nicht beim Bearbeiten /// sich die Hierarchie auch nachträglich ändern (umhängen). Beim Bearbeiten sind
/// gibt es deshalb nur den Namen. /// der Ort selbst und seine Unterorte als Ziel ausgeschlossen (kein Ring).
struct LocationEditor: View { struct LocationEditor: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -453,31 +453,33 @@ struct LocationEditor: View {
_parentId = State(initialValue: item?.parentId) _parentId = State(initialValue: item?.parentId)
} }
private var parentOptions: [StorageLocation] {
guard let item else { return all }
let verboten = LocationEditor.descendants(of: item.id, in: all).union([item.id])
return all.filter { !verboten.contains($0.id) }
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("Name") { Section("Name") {
TextField("z. B. Keller", text: $name) TextField("z. B. Keller", text: $name)
} }
// Elternort nur beim Anlegen: der Server kann bestehende Orte Section {
// nicht umhängen. Picker("Übergeordnet", selection: $parentId) {
if item == nil { Text(" oberste Ebene ").tag(Int?.none)
Section { ForEach(parentOptions) { loc in
Picker("Übergeordnet", selection: $parentId) { Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id))
Text(" oberste Ebene ").tag(Int?.none)
ForEach(all) { loc in
Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id))
}
} }
} header: {
Text("Übergeordneter Lagerort (optional)")
} }
} header: {
Text("Übergeordneter Lagerort (optional)")
} }
if let error { if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
} }
.navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort umbenennen") .navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort bearbeiten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
@@ -497,7 +499,8 @@ struct LocationEditor: View {
let n = name.trimmingCharacters(in: .whitespaces) let n = name.trimmingCharacters(in: .whitespaces)
do { do {
if let item { if let item {
_ = try await APIClient.shared.renameLocation(id: item.id, name: n) _ = try await APIClient.shared.updateLocation(
id: item.id, LocationUpdateRequest(name: n, parentId: parentId))
} else { } else {
_ = try await APIClient.shared.createLocation( _ = try await APIClient.shared.createLocation(
NewLocationRequest(name: n, parentId: parentId)) NewLocationRequest(name: n, parentId: parentId))
@@ -509,6 +512,18 @@ struct LocationEditor: View {
} }
} }
/// Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen.
private static func descendants(of id: Int, in all: [StorageLocation]) -> Set<Int> {
var result: Set<Int> = []
var stack = [id]
while let cur = stack.popLast() {
for kid in all where kid.parentId == cur {
if result.insert(kid.id).inserted { stack.append(kid.id) }
}
}
return result
}
/// "Keller Regal" der Pfad macht gleiche Namen im flachen Picker /// "Keller Regal" der Pfad macht gleiche Namen im flachen Picker
/// unterscheidbar. /// unterscheidbar.
private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String { private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String {
@@ -754,8 +769,8 @@ struct CategoriesView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
}, },
editor: { item, _, done in editor: { item, all, done in
CategoryEditor(item: item, done: done) CategoryEditor(item: item, all: all, done: done)
} }
) )
} }
@@ -765,44 +780,83 @@ struct CategoriesView: View {
} }
} }
/// Kategorie anlegen oder bearbeiten: Name und Verwaltungsart (Lebensmittel vs. /// Kategorie anlegen oder bearbeiten: Name, Verwaltungsart und beim Anlegen
/// Gegenstand). Neue Kategorien entstehen auf oberster Ebene; Verschachteln /// eine optionale Oberkategorie (Unterkategorie). Bei bestehenden Kategorien
/// bleibt der Web-Oberfläche vorbehalten. /// führt ein Link zur Verwaltung der eigenen Felder.
struct CategoryEditor: View { struct CategoryEditor: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let item: CategoryItem? let item: CategoryItem?
let all: [CategoryItem]
let done: () -> Void let done: () -> Void
@State private var name: String @State private var name: String
@State private var tracking: String @State private var tracking: String
@State private var parentId: Int? // nur beim Anlegen
@State private var error: String? @State private var error: String?
@State private var busy = false @State private var busy = false
init(item: CategoryItem?, done: @escaping () -> Void) { init(item: CategoryItem?, all: [CategoryItem], done: @escaping () -> Void) {
self.item = item self.item = item
self.all = all
self.done = done self.done = done
_name = State(initialValue: item?.name ?? "") _name = State(initialValue: item?.name ?? "")
_tracking = State(initialValue: item?.tracking ?? "object") _tracking = State(initialValue: item?.tracking ?? "object")
_parentId = State(initialValue: nil)
} }
// Mit gewählter Oberkategorie erbt die neue Kategorie deren Art dann ist
// die Art-Auswahl gegenstandslos.
private var erbtVonEltern: Bool { item == nil && parentId != nil }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("Name") { Section("Name") {
TextField("z. B. Elektronik", text: $name) TextField("z. B. Elektronik", text: $name)
} }
Section {
Picker("Art", selection: $tracking) { if item == nil {
Text("Lebensmittel").tag("food") Section {
Text("Gegenstand").tag("object") Picker("Übergeordnet", selection: $parentId) {
Text(" oberste Ebene ").tag(Int?.none)
ForEach(all) { c in
Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id))
}
}
} header: {
Text("Übergeordnete Kategorie (optional)")
} footer: {
Text(erbtVonEltern
? "Erbt die Verwaltungsart der Oberkategorie."
: "Ohne Oberkategorie unten die Verwaltungsart wählen.")
} }
.pickerStyle(.segmented)
} header: {
Text("Verwaltungsart")
} footer: {
Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.")
} }
if !erbtVonEltern {
Section {
Picker("Art", selection: $tracking) {
Text("Lebensmittel").tag("food")
Text("Gegenstand").tag("object")
}
.pickerStyle(.segmented)
} header: {
Text("Verwaltungsart")
} footer: {
Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.")
}
}
if let item {
Section {
NavigationLink {
CategoryFieldsView(categoryId: item.id, categoryName: item.name)
} label: {
Label("Eigene Felder verwalten", systemImage: "tag")
}
}
}
if let error { if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
@@ -830,8 +884,239 @@ struct CategoryEditor: View {
_ = try await APIClient.shared.updateCategory( _ = try await APIClient.shared.updateCategory(
id: item.id, CategoryUpdateRequest(name: n, tracking: tracking)) id: item.id, CategoryUpdateRequest(name: n, tracking: tracking))
} else { } else {
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
// gewählte Art.
_ = try await APIClient.shared.createCategory( _ = try await APIClient.shared.createCategory(
NewCategoryRequest(name: n, parentId: nil, tracking: tracking)) NewCategoryRequest(name: n, parentId: parentId,
tracking: parentId == nil ? tracking : nil))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String {
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
var teile = [c.name]
var pid = c.parentId
while let cur = pid, let parent = byId[cur] {
teile.insert(parent.name, at: 0)
pid = parent.parentId
}
return teile.joined(separator: "")
}
}
// MARK: - Eigene Felder je Kategorie
/// Eigene Felder einer Kategorie verwalten (anlegen, bearbeiten, löschen). Zeigt
/// nur die *eigenen* Felder geerbte werden in der Oberkategorie gepflegt.
struct CategoryFieldsView: View {
let categoryId: Int
let categoryName: String
@State private var fields: [FieldDefinition] = []
@State private var busy = true
@State private var error: String?
@State private var editing: FieldDefinition?
@State private var addShown = false
@State private var pendingDeletion: FieldDefinition?
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ForEach(fields) { f in
Button { editing = f } label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(f.label).foregroundStyle(.primary)
Text(CategoryFieldsView.untertitel(f))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if f.required {
Text("Pflicht").font(.caption2).foregroundStyle(.orange)
}
}
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { pendingDeletion = f }
}
}
if fields.isEmpty && !busy {
Text("Noch keine eigenen Felder.").foregroundStyle(.secondary)
}
}
.navigationTitle("Felder: \(categoryName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") { addShown = true }
}
}
.sheet(isPresented: $addShown) {
FieldEditor(categoryId: categoryId, field: nil) { Task { await reload() } }
}
.sheet(item: $editing) { f in
FieldEditor(categoryId: categoryId, field: f) { Task { await reload() } }
}
.confirmationDialog(
pendingDeletion.map { "\($0.label)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
if let f = pendingDeletion { Task { await remove(f) } }
pendingDeletion = nil
}
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
} message: {
Text("Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.")
}
.refreshable { await reload() }
.task { await reload() }
}
private static func untertitel(_ f: FieldDefinition) -> String {
var teile = [FieldEditor.typLabel(f.fieldType)]
if f.fieldType == "number", let u = f.unit, !u.isEmpty { teile.append(u) }
if f.fieldType == "select", !f.options.isEmpty {
teile.append(f.options.joined(separator: ", "))
}
return teile.joined(separator: " · ")
}
private func reload() async {
busy = true
defer { busy = false }
do {
fields = try await APIClient.shared.ownFieldDefinitions(categoryId: categoryId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
private func remove(_ f: FieldDefinition) async {
do {
try await APIClient.shared.deleteFieldDefinition(id: f.id)
error = nil
await reload()
} catch {
self.error = error.localizedDescription
}
}
}
/// Eigenes Feld anlegen oder bearbeiten: Name, Typ, (Einheit bei Zahl,
/// Auswahlmöglichkeiten bei Auswahlliste) und Pflicht.
struct FieldEditor: View {
@Environment(\.dismiss) private var dismiss
let categoryId: Int
let field: FieldDefinition?
let done: () -> Void
@State private var label: String
@State private var fieldType: String
@State private var unit: String
@State private var options: String // Komma-getrennt
@State private var required: Bool
@State private var error: String?
@State private var busy = false
static let typen: [(String, String)] = [
("text", "Text (einzeilig)"),
("textarea", "Text (mehrzeilig)"),
("number", "Zahl (mit Einheit)"),
("date", "Datum"),
("select", "Auswahlliste"),
("boolean", "Ja/Nein"),
]
static func typLabel(_ v: String) -> String { typen.first { $0.0 == v }?.1 ?? v }
init(categoryId: Int, field: FieldDefinition?, done: @escaping () -> Void) {
self.categoryId = categoryId
self.field = field
self.done = done
_label = State(initialValue: field?.label ?? "")
_fieldType = State(initialValue: field?.fieldType ?? "text")
_unit = State(initialValue: field?.unit ?? "")
_options = State(initialValue: (field?.options ?? []).joined(separator: ", "))
_required = State(initialValue: field?.required ?? false)
}
var body: some View {
NavigationStack {
Form {
Section("Feldname") {
TextField("z. B. Kapazität", text: $label)
}
Section("Typ") {
Picker("Typ", selection: $fieldType) {
ForEach(FieldEditor.typen, id: \.0) { Text($0.1).tag($0.0) }
}
}
if fieldType == "number" {
Section("Einheit (optional)") {
TextField("z. B. mAh", text: $unit)
}
}
if fieldType == "select" {
Section {
TextField("z. B. S, M, L, XL", text: $options)
} header: {
Text("Auswahlmöglichkeiten")
} footer: {
Text("Mit Komma trennen.")
}
}
Section {
Toggle("Pflichtfeld", isOn: $required)
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(field == nil ? "Feld anlegen" : "Feld bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || label.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func submit() async {
busy = true
defer { busy = false }
let l = label.trimmingCharacters(in: .whitespaces)
let u = fieldType == "number" ? unit.trimmingCharacters(in: .whitespaces) : ""
let opts = fieldType == "select"
? options.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
: []
do {
if let field {
_ = try await APIClient.shared.updateFieldDefinition(
id: field.id,
FieldDefinitionUpdate(label: l, fieldType: fieldType, unit: u,
options: opts, required: required))
} else {
_ = try await APIClient.shared.createFieldDefinition(
FieldDefinitionCreate(categoryId: categoryId, label: l, fieldType: fieldType,
unit: u, options: opts, required: required))
} }
done() done()
dismiss() dismiss()

View File

@@ -583,6 +583,19 @@ enum RemovalReasons {
// MARK: - Einzelstücke (Items mit UID/QR) // MARK: - Einzelstücke (Items mit UID/QR)
struct ItemDocument: Codable, Identifiable, Hashable {
let id: Int
let filename: String
let contentType: String
let uploadedAt: String
enum CodingKeys: String, CodingKey {
case id, filename
case contentType = "content_type"
case uploadedAt = "uploaded_at"
}
}
struct Item: Codable, Identifiable, Hashable { struct Item: Codable, Identifiable, Hashable {
let id: Int let id: Int
let uid: String let uid: String
@@ -594,12 +607,15 @@ struct Item: Codable, Identifiable, Hashable {
let acquiredOn: String? // "yyyy-MM-dd" let acquiredOn: String? // "yyyy-MM-dd"
let warrantyUntil: String? let warrantyUntil: String?
let note: String? let note: String?
let priceCents: Int?
let currency: String?
let createdAt: String let createdAt: String
let productName: String? let productName: String?
let productBrand: String? let productBrand: String?
let documents: [ItemDocument]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, uid, note case id, uid, note, currency, documents
case productId = "product_id" case productId = "product_id"
case locationId = "location_id" case locationId = "location_id"
case locationName = "location_name" case locationName = "location_name"
@@ -607,12 +623,26 @@ struct Item: Codable, Identifiable, Hashable {
case shopName = "shop_name" case shopName = "shop_name"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
case createdAt = "created_at" case createdAt = "created_at"
case productName = "product_name" case productName = "product_name"
case productBrand = "product_brand" case productBrand = "product_brand"
} }
} }
/// Antwort nach dem Beleg-Upload mit den aus dem PDF geschätzten Werten.
struct ItemDocumentUpload: Codable {
let id: Int
let suggestedWarrantyUntil: String?
let suggestedPriceCents: Int?
enum CodingKeys: String, CodingKey {
case id
case suggestedWarrantyUntil = "suggested_warranty_until"
case suggestedPriceCents = "suggested_price_cents"
}
}
struct ItemCreateRequest: Codable { struct ItemCreateRequest: Codable {
let count: Int let count: Int
let locationId: Int? let locationId: Int?
@@ -636,13 +666,16 @@ struct ItemUpdateRequest: Encodable {
var acquiredOn: String? var acquiredOn: String?
var warrantyUntil: String? var warrantyUntil: String?
var note: String? var note: String?
var priceCents: Int?
var currency: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case note case note, currency
case locationId = "location_id" case locationId = "location_id"
case shopId = "shop_id" case shopId = "shop_id"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
} }
// Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt. // Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt.
@@ -653,6 +686,8 @@ struct ItemUpdateRequest: Encodable {
try c.encode(acquiredOn, forKey: .acquiredOn) try c.encode(acquiredOn, forKey: .acquiredOn)
try c.encode(warrantyUntil, forKey: .warrantyUntil) try c.encode(warrantyUntil, forKey: .warrantyUntil)
try c.encode(note, forKey: .note) try c.encode(note, forKey: .note)
try c.encode(priceCents, forKey: .priceCents)
try c.encode(currency, forKey: .currency)
} }
} }
@@ -854,6 +889,54 @@ struct NewLocationRequest: Codable {
} }
} }
/// Lagerort umbenennen und/oder umhängen. `parentId` wird bewusst immer gesendet
/// (auch `null` = oberste Ebene), damit auf oberste Ebene holen" ankommt.
struct LocationUpdateRequest: Codable {
let name: String
let parentId: Int?
enum CodingKeys: String, CodingKey {
case name
case parentId = "parent_id"
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(parentId, forKey: .parentId) // encodet null bei nil
}
}
/// Eigenes Feld einer Kategorie anlegen. `unit`/`options` werden immer
/// mitgeschickt; der Server ignoriert sie bei unpassendem Typ.
struct FieldDefinitionCreate: Codable {
let categoryId: Int
let label: String
let fieldType: String
let unit: String
let options: [String]
let required: Bool
enum CodingKeys: String, CodingKey {
case label, unit, options, required
case categoryId = "category_id"
case fieldType = "field_type"
}
}
struct FieldDefinitionUpdate: Codable {
let label: String
let fieldType: String
let unit: String
let options: [String]
let required: Bool
enum CodingKeys: String, CodingKey {
case label, unit, options, required
case fieldType = "field_type"
}
}
struct NewUnitRequest: Codable { struct NewUnitRequest: Codable {
let name: String let name: String
let kind: String let kind: String

View File

@@ -162,6 +162,15 @@ export const api = {
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }), updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }), removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
// vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs).
uploadItemDocument: (itemId, file) => {
const fd = new FormData();
fd.append("file", file);
return request(`/items/${itemId}/documents`, { method: "POST", formData: fd });
},
deleteItemDocument: (itemId, docId) =>
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
// Bestand // Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
@@ -204,6 +213,7 @@ export const api = {
// Stammdaten // Stammdaten
listLocations: () => request("/locations"), listLocations: () => request("/locations"),
createLocation: (body) => request("/locations", { method: "POST", body }), createLocation: (body) => request("/locations", { method: "POST", body }),
updateLocation: (id, body) => request(`/locations/${id}`, { method: "PATCH", body }),
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }), deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
// Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar // Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { api } from "../api"; import { api, authorizedObjectUrl } from "../api";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "./Icon"; import Icon from "./Icon";
@@ -37,6 +37,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_ADD); const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [expanded, setExpanded] = useState(null); // Item-ID mit offenem Detail (Preis/Belege)
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const origin = window.location.origin; const origin = window.location.origin;
async function load() { async function load() {
@@ -68,6 +70,44 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
} catch (err) { onError(err.message); } } catch (err) { onError(err.message); }
} }
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) {
const roh = value.trim().replace(",", ".");
const cents = roh === "" ? null : Math.round(parseFloat(roh) * 100);
if (cents !== null && Number.isNaN(cents)) return;
if (cents === (item.price_cents ?? null)) return;
patchItem(item, { price_cents: cents, currency: cents == null ? null : (item.currency || "CHF") });
}
async function uploadDoc(item, file) {
try {
const res = await api.uploadItemDocument(item.id, file);
await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) {
setSuggestion({
itemId: item.id,
date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null,
});
}
toast("Beleg hochgeladen.");
} catch (err) { onError(err.message); }
}
async function deleteDoc(item, doc) {
try {
await api.deleteItemDocument(item.id, doc.id);
await load();
} catch (err) { onError(err.message); }
}
async function viewDoc(item, doc) {
try {
const url = await authorizedObjectUrl(`/items/${item.id}/documents/${doc.id}`);
if (url) window.open(url, "_blank", "noopener");
} catch (err) { onError(err.message); }
}
async function patchItem(item, body) { async function patchItem(item, body) {
try { try {
await api.updateItem(item.id, body); await api.updateItem(item.id, body);
@@ -144,7 +184,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</thead> </thead>
<tbody> <tbody>
{items.map((it) => ( {items.map((it) => (
<tr key={it.id}> <Fragment key={it.id}>
<tr>
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td> <td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
<td data-label="UID" className="strong nowrap">{it.uid}</td> <td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort"> <td data-label="Lagerort">
@@ -164,7 +205,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</td> </td>
<td data-label="Garantie bis"> <td data-label="Garantie bis">
{isAdmin ? ( {isAdmin ? (
<input type="date" defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }} <input key={it.warranty_until || "none"} type="date"
defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} /> onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
) : <span>{it.warranty_until || ""}</span>} ) : <span>{it.warranty_until || ""}</span>}
</td> </td>
@@ -184,20 +226,101 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
) : <span>{it.note || ""}</span>} ) : <span>{it.note || ""}</span>}
</td> </td>
<td className="num"> <td className="num">
{isAdmin && ( <div className="field-inline" style={{ justifyContent: "flex-end" }}>
<div className="field-inline" style={{ justifyContent: "flex-end" }}> <button className="btn-icon" title="Preis & Belege"
<button className="btn-icon" title="Mit Grund entfernen" onClick={() => setExpanded(expanded === it.id ? null : it.id)}>
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}> <Icon name={expanded === it.id ? "close" : "package"} size={16} />
<Icon name="checkout" size={16} /> </button>
</button> {isAdmin && (
<button className="btn-icon danger" title="Löschen (Korrektur)" <>
onClick={() => loeschen(it)}> <button className="btn-icon" title="Mit Grund entfernen"
<Icon name="trash" size={16} /> onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
</button> <Icon name="checkout" size={16} />
</div> </button>
)} <button className="btn-icon danger" title="Löschen (Korrektur)"
onClick={() => loeschen(it)}>
<Icon name="trash" size={16} />
</button>
</>
)}
</div>
</td> </td>
</tr> </tr>
{expanded === it.id && (
<tr>
<td colSpan={8}>
<div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<label style={{ margin: 0 }}>Kaufpreis
<div className="field-inline">
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
style={{ maxWidth: 140 }} disabled={!isAdmin}
defaultValue={it.price_cents != null ? (it.price_cents / 100) : ""}
placeholder="0.00" onBlur={(e) => savePrice(it, e.target.value)} />
<select defaultValue={it.currency || "CHF"} disabled={!isAdmin}
onChange={(e) => patchItem(it, { currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<div>
<label style={{ margin: 0 }}>Belege (Rechnung/Garantieschein)
{isAdmin && (
<input type="file" accept="application/pdf,image/*"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} />
)}
</label>
{suggestion && suggestion.itemId === it.id
&& (suggestion.date || suggestion.priceCents != null) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)" }}>
<Icon name="check" size={16} />
<span>
Im Beleg erkannt:
{suggestion.date ? ` Garantie bis ${suggestion.date}` : ""}
{suggestion.date && suggestion.priceCents != null ? "," : ""}
{suggestion.priceCents != null ? ` Kaufpreis ${(suggestion.priceCents / 100).toFixed(2)}` : ""}.
</span>
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.priceCents != null) {
body.price_cents = suggestion.priceCents;
body.currency = it.currency || "CHF";
}
patchItem(it, body);
setSuggestion(null);
}}>
Übernehmen
</button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
</div>
)}
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{(it.documents || []).map((d) => (
<li key={d.id} className="cell-row">
<button type="button" className="btn sm ghost" onClick={() => viewDoc(it, d)}>
<Icon name="download" size={14} />{d.filename}
</button>
{isAdmin && (
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
title="Beleg löschen" onClick={() => deleteDoc(it, d)}>
<Icon name="trash" size={15} />
</button>
)}
</li>
))}
{(it.documents || []).length === 0 && (
<li className="muted small">Noch keine Belege.</li>
)}
</ul>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))} ))}
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>} {items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody> </tbody>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import CategorySelect from "../components/CategorySelect";
import { QrImg, printQrLabels } from "../qr"; import { QrImg, printQrLabels } from "../qr";
export default function Locations() { export default function Locations() {
@@ -10,6 +11,10 @@ export default function Locations() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Inline-Bearbeiten (Umbenennen + Umhängen) eines vorhandenen Orts.
const [editId, setEditId] = useState(null);
const [editName, setEditName] = useState("");
const [editParent, setEditParent] = useState(null);
async function load() { async function load() {
try { try {
@@ -34,6 +39,44 @@ export default function Locations() {
} }
} }
function startEdit(l) {
setEditId(l.id);
setEditName(l.name);
setEditParent(l.parent_id ?? null);
setError(null);
}
function cancelEdit() {
setEditId(null);
}
async function saveEdit() {
setError(null);
try {
await api.updateLocation(editId, {
name: editName.trim(),
parent_id: editParent == null ? null : Number(editParent),
});
setEditId(null);
load();
} catch (err) {
setError(err.message);
}
}
// Eigene Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen.
function descendantIds(id) {
const result = new Set();
const stack = [id];
while (stack.length) {
const cur = stack.pop();
for (const c of locations.filter((l) => l.parent_id === cur)) {
if (!result.has(c.id)) { result.add(c.id); stack.push(c.id); }
}
}
return result;
}
async function remove(id) { async function remove(id) {
const ok = await confirm({ const ok = await confirm({
title: "Lagerort löschen?", title: "Lagerort löschen?",
@@ -65,6 +108,11 @@ export default function Locations() {
} }
for (const r of locations.filter(isRoot)) walk(r, 0); for (const r of locations.filter(isRoot)) walk(r, 0);
// Mögliche neue Elternorte beim Bearbeiten: alle außer dem Ort selbst und
// seinen Unterorten (sonst entstünde ein Ring).
const editDesc = editId != null ? descendantIds(editId) : new Set();
const editParentNodes = ordered.filter((o) => o.id !== editId && !editDesc.has(o.id));
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -106,22 +154,51 @@ export default function Locations() {
<ul className="simple-list"> <ul className="simple-list">
{ordered.map((l) => ( {ordered.map((l) => (
<li key={l.id}> editId === l.id ? (
<span style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: l.depth * 22 }}> <li key={l.id}>
{l.depth > 0 && <span className="muted"></span>} <div className="row" style={{ width: "100%", gap: 8, alignItems: "flex-end" }}>
<Icon name="location" size={15} className="muted" /> <label className="grow" style={{ margin: 0 }}>
{l.name} Name
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && ( <input value={editName} onChange={(e) => setEditName(e.target.value)} />
<span className="badge">in {nameById[l.parent_id]}</span> </label>
)} <label className="grow" style={{ margin: 0 }}>
</span> Übergeordnet
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <CategorySelect
<QrImg text={`${window.location.origin}/l/${l.id}`} size={44} /> value={editParent}
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen"> nodes={editParentNodes}
<Icon name="trash" size={16} /> rootLabel=" keiner (oberste Ebene) "
</button> onChange={(id) => setEditParent(id == null ? null : id)}
</span> />
</li> </label>
<span style={{ display: "flex", gap: 6 }}>
<button className="btn primary" onClick={saveEdit} disabled={!editName.trim()}>
<Icon name="check" size={16} />Speichern
</button>
<button type="button" className="btn ghost" onClick={cancelEdit}>Abbrechen</button>
</span>
</div>
</li>
) : (
<li key={l.id}>
<span style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: l.depth * 22 }}>
{l.depth > 0 && <span className="muted"></span>}
<Icon name="location" size={15} className="muted" />
{l.name}
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
<span className="badge">in {nameById[l.parent_id]}</span>
)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<QrImg text={`${window.location.origin}/l/${l.id}`} size={44} />
<button className="btn-icon" onClick={() => startEdit(l)} title="Bearbeiten">
<Icon name="edit" size={16} />
</button>
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
<Icon name="trash" size={16} />
</button>
</span>
</li>
)
))} ))}
{locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>} {locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
</ul> </ul>

View File

@@ -11,7 +11,11 @@ function buildLabelCsv(rows, origin, delim = ",") {
const delimRe = delim === "\t" ? "\\t" : delim; const delimRe = delim === "\t" ? "\\t" : delim;
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`); const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
const esc = (v) => { const esc = (v) => {
const s = String(v ?? ""); let s = String(v ?? "");
// Formel-Injektion verhindern: Beginnt eine Zelle mit = + - @ (oder Tab/CR),
// koennte Excel/P-touch sie als Formel ausfuehren. Ein vorangestelltes
// Apostroph macht sie zu reinem Text.
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s; return needsQuote.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}; };
const lines = [head.map(esc).join(delim)]; const lines = [head.map(esc).join(delim)];