Compare commits
4
Commits
ba84e4ea01
...
b2dda78a7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2dda78a7b | ||
|
|
aa72d8342a | ||
|
|
1332dd86fa | ||
|
|
467ef3c804 |
@@ -28,6 +28,11 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
||||
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
|
||||
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
|
||||
Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet.
|
||||
- **Einzelstücke verleihen** mit Name und Grund als Freitext und optionalem
|
||||
Rückgabedatum – auch direkt nach dem Scannen des QR/NFC-Tags. Der Lagerort
|
||||
bleibt stehen (dorthin gehört das Stück zurück), als verliehen zählt es aber
|
||||
nicht mehr zum Bestand. Zurückgegebene Verleihe bleiben als Verlauf erhalten;
|
||||
überfällige Rückgaben sind in Liste und Dashboard rot.
|
||||
- **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für
|
||||
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
|
||||
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
|
||||
@@ -51,6 +56,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
||||
### In der iOS-App zusätzlich
|
||||
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
||||
ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**.
|
||||
- **Verleihen direkt nach dem Scannen** – QR, eingenähtes NFC-Tag oder
|
||||
angetipptes Tag landen alle im Einzelstück, wo Herausgeben und Zurücknehmen
|
||||
gleich oben stehen.
|
||||
- **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei
|
||||
ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen.
|
||||
|
||||
|
||||
+4
-1
@@ -26,6 +26,7 @@ from .services.conversion import (
|
||||
display_unit_info,
|
||||
zweit_faktor,
|
||||
)
|
||||
from .services.loans import nicht_verliehen
|
||||
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||||
from .services.stock import current_stock, location_subtree_stock_base
|
||||
|
||||
@@ -164,8 +165,10 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
|
||||
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
|
||||
)
|
||||
item_counts = dict(
|
||||
nicht_verliehen(
|
||||
db.query(Item.product_id, func.count(Item.id))
|
||||
.filter(Item.product_id.in_(ids)).group_by(Item.product_id).all()
|
||||
.filter(Item.product_id.in_(ids))
|
||||
).group_by(Item.product_id).all()
|
||||
)
|
||||
# Abgelaufene Chargen je Artikel.
|
||||
heute = date.today()
|
||||
|
||||
@@ -15,6 +15,7 @@ from .routers import (
|
||||
field_definitions,
|
||||
groups,
|
||||
items,
|
||||
loans,
|
||||
locations,
|
||||
maintenance,
|
||||
oidc,
|
||||
@@ -305,3 +306,4 @@ app.include_router(dashboard.router)
|
||||
app.include_router(shops.router)
|
||||
app.include_router(field_definitions.router)
|
||||
app.include_router(items.router)
|
||||
app.include_router(loans.router)
|
||||
|
||||
@@ -750,6 +750,12 @@ class Item(Base):
|
||||
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||
cascade="all, delete-orphan", order_by="ItemDocument.id"
|
||||
)
|
||||
# Verleih-Historie, neueste zuerst. Der offene Verleih (returned_on NULL)
|
||||
# ist hoechstens einer, siehe ItemLoan.
|
||||
loans: Mapped[list["ItemLoan"]] = relationship(
|
||||
back_populates="item", cascade="all, delete-orphan",
|
||||
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
|
||||
)
|
||||
|
||||
|
||||
class ItemDocument(Base):
|
||||
@@ -772,6 +778,56 @@ class ItemDocument(Base):
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class ItemLoan(Base):
|
||||
"""Ein Verleih-Vorgang eines Einzelstücks: an wen, warum, bis wann.
|
||||
|
||||
Bewusst eine eigene Tabelle statt eines Lagerort-Werts „verliehen": Der
|
||||
Lagerort bleibt stehen und sagt weiter, wohin das Stück nach der Rückgabe
|
||||
zurückgehört. Zurückgegebene Verleihe bleiben als Zeile erhalten – damit ist
|
||||
die Tabelle gleichzeitig die Verleih-Historie je Stück.
|
||||
|
||||
``returned_on`` NULL heißt „noch draußen"; ein Stück kann nur einen solchen
|
||||
offenen Verleih haben (Teilindex unten). Verliehene Stücke zählen nirgends
|
||||
als Bestand, siehe ``services/loans.py``.
|
||||
"""
|
||||
|
||||
__tablename__ = "item_loans"
|
||||
__table_args__ = (
|
||||
# Zweimal gleichzeitig verliehen kann ein einzelnes Stück nicht sein.
|
||||
# Als Teilindex, weil nur die OFFENEN Verleihe eindeutig sein dürfen:
|
||||
# zurückgegebene bleiben ja beliebig oft je Stück stehen.
|
||||
Index(
|
||||
"uq_item_loan_offen",
|
||||
"item_id",
|
||||
unique=True,
|
||||
postgresql_where=text("returned_on IS NULL"),
|
||||
sqlite_where=text("returned_on IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Freitext: Entleiher sind keine Vorrania-Benutzer (Nachbar, Kollege …).
|
||||
borrower: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
lent_on: Mapped[date] = mapped_column(Date, nullable=False, default=date.today)
|
||||
# Erwartete Rückgabe – bewusst optional: „kurz mal mitgenommen" soll nicht
|
||||
# an einem Datumszwang scheitern.
|
||||
due_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
returned_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# Wer herausgegeben hat. SET NULL, damit ein gelöschtes Konto den Vorgang
|
||||
# nicht mitnimmt.
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
item: Mapped[Item] = relationship(back_populates="loans")
|
||||
user: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
class ProductLocationMinStock(Base):
|
||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ from ..schemas import (
|
||||
TimelinePoint,
|
||||
)
|
||||
from ..services.conversion import article_unit
|
||||
from ..services.loans import nicht_verliehen
|
||||
from ..services.stock import current_stock
|
||||
from .settings import get_expiry_warning_days
|
||||
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||||
@@ -323,7 +324,10 @@ def _bestand_beitraege(
|
||||
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
|
||||
|
||||
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
||||
anzahl = db.query(Item).filter(Item.product_id == product.id).count()
|
||||
# Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit.
|
||||
anzahl = nicht_verliehen(
|
||||
db.query(Item).filter(Item.product_id == product.id)
|
||||
).count()
|
||||
if anzahl <= 0:
|
||||
continue
|
||||
faktor, _label = article_unit(product)
|
||||
|
||||
@@ -16,6 +16,7 @@ from ..deps import get_current_user, require_admin
|
||||
from ..models import (
|
||||
Item,
|
||||
ItemDocument,
|
||||
ItemLoan,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
@@ -132,6 +133,9 @@ def list_all_items(
|
||||
joinedload(Item.location),
|
||||
joinedload(Item.shop),
|
||||
joinedload(Item.documents),
|
||||
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
|
||||
# verliehenem Stueck einzeln nach (N+1).
|
||||
joinedload(Item.loans).joinedload(ItemLoan.user),
|
||||
)
|
||||
.order_by(Item.id)
|
||||
.all()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Verleih von Einzelstücken: herausgeben, zurücknehmen, Übersicht, Historie.
|
||||
|
||||
Verleihen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
|
||||
angemeldete Benutzer es tun (wie Ein- und Auslagern), und es wird mit dem Konto
|
||||
protokolliert. Nur das Löschen eines Vorgangs bleibt Admins vorbehalten, weil es
|
||||
Historie vernichtet; korrigieren (PATCH) darf jeder.
|
||||
|
||||
Bewusst **keine** Bewegung (``Movement``) je Verleih: Der Verlauf im Dashboard
|
||||
rechnet den Bestand rückwärts aus vorzeichenbehafteten Bewegungen. Ein
|
||||
Verleih-Abgang ohne Gegenbuchung würde diese Kurve verfälschen. Die Tabelle
|
||||
``item_loans`` ist die Historie.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Item, ItemLoan, User
|
||||
from ..schemas import LoanCreate, LoanOut, LoanReturn, LoanUpdate
|
||||
from ..services.loans import loan_to_out, offener_verleih
|
||||
|
||||
router = APIRouter(tags=["loans"])
|
||||
|
||||
|
||||
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||
item = db.get(Item, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
def _loan_or_404(db: Session, loan_id: int) -> ItemLoan:
|
||||
loan = db.get(ItemLoan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Verleih nicht gefunden")
|
||||
return loan
|
||||
|
||||
|
||||
def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | None) -> None:
|
||||
"""Rückgabe und Frist dürfen nicht vor der Herausgabe liegen."""
|
||||
if due_on is not None and due_on < lent_on:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Rückgabedatum liegt vor dem Verleihdatum."
|
||||
)
|
||||
if returned_on is not None and returned_on < lent_on:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Rückgabe liegt vor dem Verleihdatum."
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def lend_item(
|
||||
item_id: int,
|
||||
payload: LoanCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> LoanOut:
|
||||
"""Einzelstück verleihen. Der Lagerort bleibt stehen – dorthin gehört es zurück."""
|
||||
item = _item_or_404(db, item_id)
|
||||
if offener_verleih(db, item.id) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
|
||||
)
|
||||
name = payload.borrower.strip()
|
||||
if not name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
|
||||
lent_on = payload.lent_on or date.today()
|
||||
_pruefe_zeitfolge(lent_on, payload.due_on, None)
|
||||
|
||||
loan = ItemLoan(
|
||||
item_id=item.id,
|
||||
borrower=name,
|
||||
reason=(payload.reason or "").strip() or None,
|
||||
lent_on=lent_on,
|
||||
due_on=payload.due_on,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(loan)
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/return", response_model=LoanOut)
|
||||
def return_item(
|
||||
item_id: int,
|
||||
payload: LoanReturn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> LoanOut:
|
||||
"""Rückgabe buchen. Das Stück zählt danach wieder an seinem Lagerort."""
|
||||
item = _item_or_404(db, item_id)
|
||||
loan = offener_verleih(db, item.id)
|
||||
if loan is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist nicht verliehen."
|
||||
)
|
||||
returned_on = payload.returned_on or date.today()
|
||||
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
||||
loan.returned_on = returned_on
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/loans", response_model=list[LoanOut])
|
||||
def item_loans(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[LoanOut]:
|
||||
"""Verleih-Historie eines Stücks, neueste zuerst."""
|
||||
item = _item_or_404(db, item_id)
|
||||
return [loan_to_out(loan) for loan in item.loans]
|
||||
|
||||
|
||||
@router.get("/loans", response_model=list[LoanOut])
|
||||
def list_loans(
|
||||
open_only: bool = True,
|
||||
due_in: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[LoanOut]:
|
||||
"""Verleihe – standardmäßig nur die offenen, fälligste zuerst.
|
||||
|
||||
``due_in`` grenzt auf die ein, deren Rückgabe in so vielen Tagen ansteht;
|
||||
schon überfällige sind dabei immer enthalten (wie bei ``/expiring``). Ohne
|
||||
Rückgabedatum fällt ein Verleih damit aus der Liste – gewollt: Er ist nie
|
||||
fällig. Diese Route ist die Datenquelle für die Übersicht und für die
|
||||
Rückgabe-Erinnerung der App.
|
||||
"""
|
||||
query = (
|
||||
db.query(ItemLoan)
|
||||
.options(
|
||||
joinedload(ItemLoan.user),
|
||||
joinedload(ItemLoan.item).joinedload(Item.product),
|
||||
joinedload(ItemLoan.item).joinedload(Item.location),
|
||||
)
|
||||
)
|
||||
if open_only:
|
||||
query = query.filter(ItemLoan.returned_on.is_(None))
|
||||
if due_in is not None:
|
||||
grenze = date.today() + timedelta(days=max(0, due_in))
|
||||
query = query.filter(ItemLoan.due_on.isnot(None), ItemLoan.due_on <= grenze)
|
||||
# Fälligste zuerst; Verleihe ohne Frist hinten nach Verleihdatum.
|
||||
rows = query.order_by(
|
||||
ItemLoan.due_on.is_(None), ItemLoan.due_on.asc(), ItemLoan.lent_on.asc()
|
||||
).all()
|
||||
heute = date.today()
|
||||
return [loan_to_out(loan, heute) for loan in rows]
|
||||
|
||||
|
||||
@router.patch("/loans/{loan_id}", response_model=LoanOut)
|
||||
def update_loan(
|
||||
loan_id: int,
|
||||
payload: LoanUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> LoanOut:
|
||||
"""Verleih korrigieren – Name, Grund, Daten. Auch nachträglich zurückbuchen."""
|
||||
loan = _loan_or_404(db, loan_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "borrower" in data:
|
||||
name = (data["borrower"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
|
||||
data["borrower"] = name
|
||||
if "reason" in data:
|
||||
data["reason"] = (data["reason"] or "").strip() or None
|
||||
if data.get("lent_on") is None:
|
||||
data.pop("lent_on", None) # Verleihdatum kann nicht geleert werden
|
||||
_pruefe_zeitfolge(
|
||||
data.get("lent_on", loan.lent_on),
|
||||
data.get("due_on", loan.due_on),
|
||||
data.get("returned_on", loan.returned_on),
|
||||
)
|
||||
# Wieder-Öffnen nur, solange kein anderer Verleih des Stücks offen ist.
|
||||
if "returned_on" in data and data["returned_on"] is None:
|
||||
offen = offener_verleih(db, loan.item_id)
|
||||
if offen is not None and offen.id != loan.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
|
||||
)
|
||||
for field, value in data.items():
|
||||
setattr(loan, field, value)
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
|
||||
|
||||
@router.delete("/loans/{loan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_loan(
|
||||
loan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Verleih ganz entfernen (Fehleingabe). Löscht Historie – daher nur Admin."""
|
||||
db.delete(_loan_or_404(db, loan_id))
|
||||
db.commit()
|
||||
@@ -770,6 +770,54 @@ class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||||
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||||
|
||||
|
||||
class LoanCreate(BaseModel):
|
||||
"""Ein Einzelstück verleihen. Nur der Name ist Pflicht."""
|
||||
borrower: str = Field(min_length=1, max_length=120)
|
||||
reason: str | None = Field(default=None, max_length=255)
|
||||
# Leer = heute; rückdatieren erlaubt (man trägt es oft später nach).
|
||||
lent_on: date | None = None
|
||||
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||||
due_on: date | None = None
|
||||
|
||||
|
||||
class LoanUpdate(BaseModel):
|
||||
"""Laufenden oder abgeschlossenen Verleih korrigieren (Tippfehler, Datum)."""
|
||||
borrower: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
reason: str | None = Field(default=None, max_length=255)
|
||||
lent_on: date | None = None
|
||||
due_on: date | None = None
|
||||
returned_on: date | None = None
|
||||
|
||||
|
||||
class LoanReturn(BaseModel):
|
||||
"""Rückgabe buchen. Leeres Datum = heute."""
|
||||
returned_on: date | None = None
|
||||
|
||||
|
||||
class LoanOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
item_id: int
|
||||
borrower: str
|
||||
reason: str | None = None
|
||||
lent_on: date
|
||||
due_on: date | None = None
|
||||
returned_on: date | None = None
|
||||
created_at: datetime
|
||||
# Wer herausgegeben hat (Konto kann später gelöscht sein).
|
||||
username: str | None = None
|
||||
# Tage über dem Rückgabedatum; None ohne Datum oder wenn noch nicht fällig.
|
||||
# Vom Server gerechnet, damit App und Web nicht getrennt rechnen.
|
||||
days_overdue: int | None = None
|
||||
# Für die Übersicht „was ist gerade draußen" mitgeliefert:
|
||||
item_uid: str | None = None
|
||||
product_id: int | None = None
|
||||
product_name: str | None = None
|
||||
product_brand: str | None = None
|
||||
location_id: str | None = None
|
||||
location_name: str | None = None
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -791,6 +839,9 @@ class ItemOut(BaseModel):
|
||||
category_id: int | None = None
|
||||
category_name: str | None = None
|
||||
documents: list[ItemDocumentOut] = []
|
||||
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
||||
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
||||
loan: LoanOut | None = None
|
||||
|
||||
|
||||
# ---- Views ----
|
||||
|
||||
@@ -6,8 +6,9 @@ import secrets
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Item
|
||||
from ..models import Item, ItemLoan
|
||||
from ..schemas import ItemOut
|
||||
from .loans import loan_to_out
|
||||
|
||||
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
@@ -21,8 +22,20 @@ def generate_uid(db: Session, length: int = 8) -> str:
|
||||
return code
|
||||
|
||||
|
||||
def _offener(item: Item) -> ItemLoan | None:
|
||||
"""Der laufende Verleih aus den schon geladenen Verleihen des Stücks.
|
||||
|
||||
Bewusst über die Beziehung statt per eigener Abfrage: So bleibt die
|
||||
Einzelstück-Liste bei einer Abfrage, wenn der Aufrufer ``loans`` mitlädt.
|
||||
"""
|
||||
for loan in item.loans:
|
||||
if loan.returned_on is None:
|
||||
return loan
|
||||
return None
|
||||
|
||||
|
||||
def item_to_out(item: Item) -> ItemOut:
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
|
||||
out = ItemOut.model_validate(item)
|
||||
out.location_name = item.location.name if item.location else None
|
||||
out.shop_name = item.shop.name if item.shop else None
|
||||
@@ -31,4 +44,6 @@ def item_to_out(item: Item) -> ItemOut:
|
||||
kategorie = item.product.category if item.product else None
|
||||
out.category_id = kategorie.id if kategorie else None
|
||||
out.category_name = kategorie.name if kategorie else None
|
||||
loan = _offener(item)
|
||||
out.loan = loan_to_out(loan) if loan is not None else None
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Verleih von Einzelstücken: Bestandsfilter, offener Verleih, Ausgabe.
|
||||
|
||||
Ein verliehenes Stück ist nicht im Haus. Es zählt deshalb **nirgends** als
|
||||
Bestand – weder am Lagerort noch im Gesamtbestand; ``nicht_verliehen`` ist die
|
||||
eine Stelle, an der das steht, und wird von allen Zählungen benutzt
|
||||
(``services/stock.py``, ``crud.products_to_out_bulk``, Dashboard-Auswertungen).
|
||||
|
||||
Der Lagerort am Stück bleibt dabei erhalten: Er sagt weiter, wohin es nach der
|
||||
Rückgabe zurückgehört.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.orm import Query, Session
|
||||
|
||||
from ..models import Item, ItemLoan
|
||||
from ..schemas import LoanOut
|
||||
|
||||
|
||||
def nicht_verliehen(query: Query) -> Query:
|
||||
"""Verliehene Einzelstücke aus einer Item-Abfrage nehmen.
|
||||
|
||||
Arbeitet als korreliertes NOT EXISTS und ist damit unabhängig davon, ob die
|
||||
Abfrage ganze Items oder nur Spalten (``count``) auswählt.
|
||||
"""
|
||||
return query.filter(~Item.loans.any(ItemLoan.returned_on.is_(None)))
|
||||
|
||||
|
||||
def offener_verleih(db: Session, item_id: int) -> ItemLoan | None:
|
||||
"""Der laufende Verleih eines Stücks, falls es gerade draußen ist."""
|
||||
return (
|
||||
db.query(ItemLoan)
|
||||
.filter(ItemLoan.item_id == item_id, ItemLoan.returned_on.is_(None))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def tage_ueberfaellig(loan: ItemLoan, heute: date | None = None) -> int | None:
|
||||
"""Tage über dem Rückgabedatum – None ohne Datum, zurück oder noch nicht fällig."""
|
||||
if loan.due_on is None or loan.returned_on is not None:
|
||||
return None
|
||||
tage = ((heute or date.today()) - loan.due_on).days
|
||||
return tage if tage > 0 else None
|
||||
|
||||
|
||||
def loan_to_out(loan: ItemLoan, heute: date | None = None) -> LoanOut:
|
||||
"""LoanOut inkl. Überfälligkeit und – für die Übersicht – Stück und Artikel."""
|
||||
out = LoanOut.model_validate(loan)
|
||||
out.days_overdue = tage_ueberfaellig(loan, heute)
|
||||
out.username = loan.user.username if loan.user else None
|
||||
item = loan.item
|
||||
if item is not None:
|
||||
out.item_uid = item.uid
|
||||
out.product_id = item.product_id
|
||||
out.location_id = item.location_id
|
||||
out.location_name = item.location.name if item.location else None
|
||||
if item.product is not None:
|
||||
out.product_name = item.product.name
|
||||
out.product_brand = item.product.brand
|
||||
return out
|
||||
@@ -20,6 +20,7 @@ from ..models import (
|
||||
)
|
||||
from .conversion import to_base
|
||||
from .dates import clean_precision, normalize_best_before
|
||||
from .loans import nicht_verliehen
|
||||
|
||||
|
||||
class StockError(ValueError):
|
||||
@@ -205,11 +206,14 @@ def current_stock(db: Session, product_id: int) -> float:
|
||||
"""Bestand eines Produkts (in Basiseinheiten).
|
||||
|
||||
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
||||
die Lot-Mengen.
|
||||
die Lot-Mengen. Verliehene Stücke sind nicht im Haus und zählen nicht mit
|
||||
(siehe ``services/loans.py``).
|
||||
"""
|
||||
product = db.get(Product, product_id)
|
||||
if product is not None and product.individual:
|
||||
return float(db.query(Item).filter(Item.product_id == product_id).count())
|
||||
return float(
|
||||
nicht_verliehen(db.query(Item).filter(Item.product_id == product_id)).count()
|
||||
)
|
||||
total = (
|
||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||
)
|
||||
@@ -242,7 +246,7 @@ def _summe_je_artikel(
|
||||
for pid, menge in abfrage.group_by(Lot.product_id).all():
|
||||
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
||||
if einzel:
|
||||
abfrage = (
|
||||
abfrage = nicht_verliehen(
|
||||
db.query(Item.product_id, func.count(Item.id))
|
||||
.filter(Item.product_id.in_(einzel))
|
||||
)
|
||||
@@ -304,13 +308,16 @@ def location_stock_base(db: Session, product: Product, location_id: str) -> floa
|
||||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||
|
||||
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||||
Ortes summiert.
|
||||
Ortes summiert. Ein verliehenes Stück zählt hier nicht – sein Lagerort bleibt
|
||||
aber gesetzt, weil es dorthin zurückgehört.
|
||||
"""
|
||||
if product.individual:
|
||||
return float(
|
||||
db.query(Item)
|
||||
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
||||
.count()
|
||||
nicht_verliehen(
|
||||
db.query(Item).filter(
|
||||
Item.product_id == product.id, Item.location_id == location_id
|
||||
)
|
||||
).count()
|
||||
)
|
||||
total = (
|
||||
db.query(Lot.quantity)
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Verleih von Einzelstücken: Bestand, offener Verleih, Fälligkeit, Historie."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import crud
|
||||
from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
ItemLoan,
|
||||
Location,
|
||||
Product,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.routers.items import create_items, get_item, list_all_items
|
||||
from app.routers.loans import (
|
||||
delete_loan,
|
||||
item_loans,
|
||||
lend_item,
|
||||
list_loans,
|
||||
return_item,
|
||||
update_loan,
|
||||
)
|
||||
from app.routers.views import location_contents
|
||||
from app.schemas import ItemCreate, LoanCreate, LoanReturn, LoanUpdate
|
||||
from app.services.stock import current_stock, location_stock_base
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def keller(db):
|
||||
ort = Location(name="Keller")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
db.refresh(ort)
|
||||
return ort
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kamera(db):
|
||||
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar.
|
||||
product = Product(name="Kamera", category_id=cat.id, individual=True, package_size=1)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _stueck(db, admin, product, ort=None):
|
||||
return create_items(
|
||||
product.id,
|
||||
ItemCreate(count=1, location_id=(ort.id if ort else None)),
|
||||
db=db, _=admin,
|
||||
)[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bestand: verliehen heisst nicht im Haus
|
||||
# --------------------------------------------------------------------------
|
||||
def test_verliehenes_stueck_zaehlt_nirgends_als_bestand(db, admin, kamera, keller):
|
||||
item = _stueck(db, admin, kamera, keller)
|
||||
assert current_stock(db, kamera.id) == 1.0
|
||||
assert location_stock_base(db, kamera, keller.id) == 1.0
|
||||
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
assert current_stock(db, kamera.id) == 0.0
|
||||
assert location_stock_base(db, kamera, keller.id) == 0.0
|
||||
# Auch die Artikel-Liste (gebuendelte Abfrage) darf es nicht mitzaehlen.
|
||||
assert crud.products_to_out_bulk(db, [kamera])[0].stock == 0.0
|
||||
|
||||
|
||||
def test_lagerort_inhalt_zeigt_verliehenes_nicht(db, admin, kamera, keller):
|
||||
item = _stueck(db, admin, kamera, keller)
|
||||
inhalt = location_contents(code=keller.id, db=db, _=admin)
|
||||
assert [e.name for e in inhalt.products] == ["Kamera"]
|
||||
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
# „Was liegt hier?" wuerde sonst behaupten, die Kamera liege im Keller.
|
||||
assert location_contents(code=keller.id, db=db, _=admin).products == []
|
||||
|
||||
|
||||
def test_rueckgabe_bringt_den_bestand_zurueck(db, admin, kamera, keller):
|
||||
item = _stueck(db, admin, kamera, keller)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
zurueck = return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
|
||||
assert zurueck.returned_on == date.today()
|
||||
assert current_stock(db, kamera.id) == 1.0
|
||||
# Der Lagerort stand die ganze Zeit still - dorthin gehoert es zurueck.
|
||||
assert location_stock_base(db, kamera, keller.id) == 1.0
|
||||
|
||||
|
||||
def test_lagerort_bleibt_waehrend_des_verleihs_stehen(db, admin, kamera, keller):
|
||||
item = _stueck(db, admin, kamera, keller)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
frisch = get_item(item.id, db=db, _=admin)
|
||||
assert frisch.location_id == keller.id
|
||||
assert frisch.location_name == "Keller"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verleihen und zuruecknehmen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_verleih_merkt_name_grund_und_frist(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
frist = date.today() + timedelta(days=14)
|
||||
|
||||
loan = lend_item(
|
||||
item.id,
|
||||
LoanCreate(borrower=" Markus ", reason=" Hochzeit ", due_on=frist),
|
||||
db=db, user=admin,
|
||||
)
|
||||
|
||||
assert loan.borrower == "Markus" # Freitext getrimmt
|
||||
assert loan.reason == "Hochzeit"
|
||||
assert loan.lent_on == date.today()
|
||||
assert loan.due_on == frist
|
||||
assert loan.returned_on is None
|
||||
assert loan.username == "admin" # wer herausgegeben hat
|
||||
assert loan.item_uid == item.uid
|
||||
assert loan.product_name == "Kamera"
|
||||
|
||||
|
||||
def test_verleih_ohne_frist_ist_erlaubt(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
assert loan.due_on is None
|
||||
assert loan.days_overdue is None
|
||||
|
||||
|
||||
def test_item_out_zeigt_den_offenen_verleih(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus", reason="Ferien"), db=db, user=admin)
|
||||
|
||||
frisch = get_item(item.id, db=db, _=admin)
|
||||
assert frisch.loan is not None
|
||||
assert frisch.loan.borrower == "Markus"
|
||||
assert frisch.loan.reason == "Ferien"
|
||||
|
||||
return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
assert get_item(item.id, db=db, _=admin).loan is None
|
||||
|
||||
|
||||
def test_liste_aller_stuecke_zeigt_den_verleih(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
alle = list_all_items(db=db, _=admin)
|
||||
assert len(alle) == 1
|
||||
assert alle[0].loan is not None
|
||||
assert alle[0].loan.borrower == "Markus"
|
||||
|
||||
|
||||
def test_zweimal_verleihen_ohne_rueckgabe_scheitert(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_nach_rueckgabe_wieder_verleihbar(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
|
||||
zweiter = lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
|
||||
assert zweiter.borrower == "Anna"
|
||||
assert current_stock(db, kamera.id) == 0.0
|
||||
|
||||
|
||||
def test_rueckgabe_ohne_offenen_verleih_scheitert(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_leerer_name_wird_abgelehnt(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(item.id, LoanCreate(borrower=" "), db=db, user=admin)
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
def test_frist_vor_verleihdatum_wird_abgelehnt(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(
|
||||
item.id,
|
||||
LoanCreate(borrower="Markus", due_on=date.today() - timedelta(days=1)),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
def test_rueckgabe_vor_verleihdatum_wird_abgelehnt(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
return_item(
|
||||
item.id, LoanReturn(returned_on=date.today() - timedelta(days=1)),
|
||||
db=db, _=admin,
|
||||
)
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
def test_verleih_eines_unbekannten_stuecks_ist_404(db, admin):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(999, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
assert fehler.value.status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Faelligkeit und Uebersicht
|
||||
# --------------------------------------------------------------------------
|
||||
def test_ueberfaellige_tage_werden_gerechnet(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(
|
||||
item.id,
|
||||
LoanCreate(
|
||||
borrower="Markus",
|
||||
lent_on=date.today() - timedelta(days=10),
|
||||
due_on=date.today() - timedelta(days=3),
|
||||
),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert get_item(item.id, db=db, _=admin).loan.days_overdue == 3
|
||||
|
||||
|
||||
def test_frist_heute_ist_noch_nicht_ueberfaellig(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus", due_on=date.today()),
|
||||
db=db, user=admin)
|
||||
assert get_item(item.id, db=db, _=admin).loan.days_overdue is None
|
||||
|
||||
|
||||
def test_uebersicht_zeigt_offene_faelligste_zuerst(db, admin, kamera):
|
||||
spaet = _stueck(db, admin, kamera)
|
||||
frueh = _stueck(db, admin, kamera)
|
||||
ohne = _stueck(db, admin, kamera)
|
||||
zurueck = _stueck(db, admin, kamera)
|
||||
|
||||
lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=20)),
|
||||
db=db, user=admin)
|
||||
lend_item(frueh.id, LoanCreate(borrower="Frueh", due_on=date.today() + timedelta(days=2)),
|
||||
db=db, user=admin)
|
||||
lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin)
|
||||
lend_item(zurueck.id, LoanCreate(borrower="Zurueck"), db=db, user=admin)
|
||||
return_item(zurueck.id, LoanReturn(), db=db, _=admin)
|
||||
|
||||
offen = list_loans(db=db, _=admin)
|
||||
# Fristen aufsteigend, Verleihe ohne Frist hinten, Zurueckgegebene gar nicht.
|
||||
assert [v.borrower for v in offen] == ["Frueh", "Spaet", "Ohne"]
|
||||
|
||||
alle = list_loans(open_only=False, db=db, _=admin)
|
||||
assert "Zurueck" in [v.borrower for v in alle]
|
||||
|
||||
|
||||
def test_uebersicht_grenzt_auf_faellige_ein(db, admin, kamera):
|
||||
bald = _stueck(db, admin, kamera)
|
||||
spaet = _stueck(db, admin, kamera)
|
||||
ueberfaellig = _stueck(db, admin, kamera)
|
||||
ohne = _stueck(db, admin, kamera)
|
||||
|
||||
lend_item(bald.id, LoanCreate(borrower="Bald", due_on=date.today() + timedelta(days=3)),
|
||||
db=db, user=admin)
|
||||
lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=30)),
|
||||
db=db, user=admin)
|
||||
lend_item(
|
||||
ueberfaellig.id,
|
||||
LoanCreate(borrower="Ueberfaellig", lent_on=date.today() - timedelta(days=9),
|
||||
due_on=date.today() - timedelta(days=2)),
|
||||
db=db, user=admin,
|
||||
)
|
||||
lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin)
|
||||
|
||||
namen = [v.borrower for v in list_loans(due_in=7, db=db, _=admin)]
|
||||
# Ueberfaellige sind immer dabei, „Ohne Frist" ist nie faellig.
|
||||
assert namen == ["Ueberfaellig", "Bald"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Historie und Korrektur
|
||||
# --------------------------------------------------------------------------
|
||||
def test_historie_bleibt_nach_der_rueckgabe(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
|
||||
|
||||
historie = item_loans(item.id, db=db, _=admin)
|
||||
assert {v.borrower for v in historie} == {"Markus", "Anna"}
|
||||
assert len(historie) == 2
|
||||
|
||||
|
||||
def test_korrektur_aendert_name_und_frist(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
neu = date.today() + timedelta(days=5)
|
||||
korrigiert = update_loan(
|
||||
loan.id, LoanUpdate(borrower="Markus Meier", due_on=neu), db=db, _=admin
|
||||
)
|
||||
assert korrigiert.borrower == "Markus Meier"
|
||||
assert korrigiert.due_on == neu
|
||||
|
||||
|
||||
def test_nachtraegliche_rueckgabe_per_korrektur(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
loan = lend_item(
|
||||
item.id,
|
||||
LoanCreate(borrower="Markus", lent_on=date.today() - timedelta(days=5)),
|
||||
db=db, user=admin,
|
||||
)
|
||||
|
||||
gestern = date.today() - timedelta(days=1)
|
||||
update_loan(loan.id, LoanUpdate(returned_on=gestern), db=db, _=admin)
|
||||
|
||||
assert get_item(item.id, db=db, _=admin).loan is None
|
||||
assert current_stock(db, kamera.id) == 1.0
|
||||
|
||||
|
||||
def test_wieder_oeffnen_scheitert_bei_offenem_zweitverleih(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
erster = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
return_item(item.id, LoanReturn(), db=db, _=admin)
|
||||
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
update_loan(erster.id, LoanUpdate(returned_on=None), db=db, _=admin)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_loeschen_entfernt_den_vorgang(db, admin, kamera):
|
||||
item = _stueck(db, admin, kamera)
|
||||
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
delete_loan(loan.id, db=db, _=admin)
|
||||
|
||||
assert item_loans(item.id, db=db, _=admin) == []
|
||||
assert current_stock(db, kamera.id) == 1.0
|
||||
|
||||
|
||||
def test_datenbank_verhindert_zwei_offene_verleihe(db, admin, kamera):
|
||||
"""Der Teilindex schuetzt die Regel auch ohne den Router-Check."""
|
||||
item = _stueck(db, admin, kamera)
|
||||
db.add(ItemLoan(item_id=item.id, borrower="Markus", lent_on=date.today()))
|
||||
db.commit()
|
||||
|
||||
db.add(ItemLoan(item_id=item.id, borrower="Anna", lent_on=date.today()))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_zurueckgegebene_verleihe_duerfen_sich_haeufen(db, admin, kamera):
|
||||
"""Der Index gilt nur fuer offene – Historie darf beliebig lang werden."""
|
||||
item = _stueck(db, admin, kamera)
|
||||
for name in ("Markus", "Anna", "Beat"):
|
||||
db.add(ItemLoan(item_id=item.id, borrower=name, lent_on=date.today(),
|
||||
returned_on=date.today()))
|
||||
db.commit()
|
||||
assert len(item_loans(item.id, db=db, _=admin)) == 3
|
||||
@@ -89,6 +89,60 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
||||
*Bekannte Ungenauigkeit:* Die Umrechnung nutzt die heutige Packungsgröße; wird
|
||||
sie später geändert, verschiebt sich auch die Historie.
|
||||
|
||||
## Schritt 2e – Verleih von Einzelstücken (Backend ✅, Oberfläche offen)
|
||||
- **Eigene Tabelle `item_loans` statt eines Lagerort-Werts „verliehen".** Der
|
||||
Lagerort am Stück bleibt stehen und sagt weiter, wohin es nach der Rückgabe
|
||||
zurückgehört; ein Lagerort-Wert hätte diese Information vernichtet.
|
||||
Je Vorgang: Entleiher (Freitext), Grund (Freitext, optional), Verleihdatum,
|
||||
erwartetes Rückgabedatum (**optional** – „kurz mal mitgenommen" soll nicht an
|
||||
einem Datumszwang scheitern) und das Konto, das herausgegeben hat.
|
||||
- **Zurückgegebene Verleihe bleiben stehen** (`returned_on` gesetzt) – damit ist
|
||||
die Tabelle gleichzeitig die Verleih-Historie je Stück. Ein Teilindex auf die
|
||||
offenen Vorgänge sichert ab, dass ein Stück nicht zweimal gleichzeitig
|
||||
verliehen ist.
|
||||
- **Verliehen heißt nicht im Bestand.** Ein verliehenes Stück zählt nirgends
|
||||
mehr mit – weder am Lagerort noch im Gesamtbestand. Entschieden wird das an
|
||||
einer Stelle (`services/loans.py::nicht_verliehen`), die alle fünf Zählungen
|
||||
benutzen. „Was liegt hier?" behauptet damit nicht länger, das Stück liege im
|
||||
Regal. *Folge:* Fällt es unter den Mindestbestand, erscheint der Artikel auf
|
||||
der Einkaufsliste – gewollt, aber gut zu wissen.
|
||||
- **Verleihen darf jeder** angemeldete Benutzer (es ist eine Buchung wie Ein- und
|
||||
Auslagern, keine Stammdatenänderung) und wird mit dem Konto protokolliert. Nur
|
||||
das *Löschen* eines Vorgangs bleibt Admins vorbehalten, weil es Historie
|
||||
vernichtet.
|
||||
- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den
|
||||
Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne
|
||||
Gegenbuchung würde diese Kurve verfälschen.
|
||||
- **Web-Oberfläche:** Verleihen und Zurücknehmen in der Einzelstück-Tabelle
|
||||
eines Artikels und in der Liste aller Einzelstücke (Spalten „Verliehen an" und
|
||||
„Zurück bis", filter- und sortierbar), zwei Dashboard-Karten („Verliehen",
|
||||
„Überfällige Verleihe") und der Verleih-Verlauf je Stück. Überfällig ist rot,
|
||||
demnächst fällig gelb – dieselben Zustandsfarben wie beim MHD.
|
||||
- **Nach dem Scannen direkt verleihen:** Das Ziel eines Einzelstück-QR bzw.
|
||||
NFC-Tags (`/i/<uid>`) leitete vorher nur zum Artikel weiter. Jetzt zeigt es,
|
||||
was man in der Hand hält, und bietet Verleihen bzw. Zurücknehmen an der Stelle
|
||||
an – Gegenstück zur Lagerort-Seite (`/l/<code>`).
|
||||
- **iOS-App:** Verleih-Abschnitt im Einzelstück mit Herausgeben und
|
||||
Zurücknehmen, Verleih-Verlauf, Zustand in der Einzelstück-Liste und ein Filter
|
||||
*Verliehen / Überfällig / Im Haus*. Weil alle Scan-Wege (QR über
|
||||
*Nachschlagen*, NFC-Tag, `vorrania://i/<UID>`) in derselben Ansicht enden, ist
|
||||
„scannen und verleihen" damit überall möglich, ohne einen eigenen Weg dafür.
|
||||
- **Rückgabe-Erinnerung** in der App, auf dem Weg der Ablauf-Meldungen: lokale
|
||||
Benachrichtigung, geplant aus `GET /loans?due_in=…`. Einstellbar je Server:
|
||||
„X vorher", am Rückgabetag, und täglich weiter, solange etwas überfällig ist.
|
||||
Echtes Push (APNs) fehlt weiterhin, siehe Schritt 3.
|
||||
- **Eine Meldung je Meldetag und Server** statt einer je Meldungsart. Vorher gab
|
||||
es an einem Tag getrennte Mitteilungen für „bald ablaufend" und „abgelaufen";
|
||||
mit dem Verleih wären es vier geworden. Der Grund ist nicht nur weniger Lärm:
|
||||
iOS erlaubt 64 geplante Mitteilungen je App, und ein Platz je *Tag* statt je
|
||||
Art ist die Voraussetzung dafür, dass der volle Planungshorizont hineinpasst.
|
||||
- **Horizont auf 30 Tage** (vorher 60). Er ist die Zusage, wie lange die App
|
||||
ungeöffnet bleiben darf, ohne dass Erinnerungen ausfallen – der
|
||||
`BGAppRefreshTask` ist keine Garantie, iOS führt ihn nur aus, wenn es will.
|
||||
Ein längerer Horizont hätte nur spätere Tage eingeplant und dafür die nächsten
|
||||
verdrängt. *Preis:* Eine MHD-Regel mit mehr als 30 Tagen Vorlauf greift erst,
|
||||
wenn das Datum in dieses Fenster rückt.
|
||||
|
||||
## Schritt 3 – Weitere Ideen – offen
|
||||
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
||||
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
||||
|
||||
+41
-1
@@ -6,7 +6,8 @@ REST-API wie die Web-Oberfläche.
|
||||
> **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per
|
||||
> Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
|
||||
> Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit
|
||||
> UID/QR, Kaufdatum, Garantie und Beleg; Verbrauchsgegenstände als Charge;
|
||||
> UID/QR, Kaufdatum, Garantie und Beleg – auch **verleihen** und
|
||||
> zurücknehmen; Verbrauchsgegenstände als Charge;
|
||||
> Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte
|
||||
> Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung;
|
||||
> Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt;
|
||||
@@ -63,6 +64,45 @@ Es gibt zwei Wege direkt in den Scan-Bildschirm:
|
||||
|
||||
Beide Wege öffnen die App **direkt in der Kamera**.
|
||||
|
||||
## Einzelstücke verleihen
|
||||
|
||||
In der Ansicht eines Einzelstücks steht **Verleih** ganz oben: verliehen an wen,
|
||||
seit wann, bis wann – und der Knopf zum Herausgeben bzw. Zurücknehmen. Pflicht
|
||||
ist nur der Name; Grund und Rückgabedatum sind optional, denn „kurz mal
|
||||
mitgenommen" soll nicht an einem Datum scheitern.
|
||||
|
||||
Dass der Knopf genau dort sitzt, macht **scannen und verleihen** möglich, ohne
|
||||
einen eigenen Weg dafür zu bauen: Alle Scan-Wege der App enden in dieser
|
||||
Ansicht – der QR über *Nachschlagen*, ein eingenähtes NFC-Tag über *NFC-Tag
|
||||
lesen* und ein angetipptes Tag über `vorrania://i/<UID>`.
|
||||
|
||||
Der Lagerort bleibt beim Verleihen stehen – dorthin gehört das Stück zurück. Als
|
||||
verliehen zählt es aber nicht mehr zum Bestand, es ist ja nicht im Haus. In der
|
||||
Einzelstück-Liste steht der Verleih unter dem Stück (überfällig in Rot) und lässt
|
||||
sich über das Filter-Menü auf *Verliehen*, *Überfällig* oder *Im Haus* eingrenzen.
|
||||
|
||||
### Rückgabe-Erinnerung
|
||||
|
||||
Unter *Benachrichtigungen* (je Server) gibt es neben den MHD-Regeln den Abschnitt
|
||||
**Verliehene Einzelstücke**: „X vorher", am Rückgabetag, und täglich weiter,
|
||||
solange etwas überfällig ist. Ein Verleih ohne Rückgabedatum ist nie fällig und
|
||||
kommt nicht vor.
|
||||
|
||||
Zwei Dinge dazu, die man wissen sollte:
|
||||
|
||||
- **Ein Meldetag ist eine Meldung.** Alles, was an einem Tag auf einem Server
|
||||
anliegt – ablaufende Chargen *und* fällige Rückgaben – steht in derselben
|
||||
Mitteilung („2 Produkte laufen bald ab: … / Kamera (Markus) ist überfällig").
|
||||
Das ist nicht nur weniger Lärm: iOS erlaubt 64 geplante Mitteilungen je App,
|
||||
und ein Platz je Tag statt einer je Meldungsart ist der Grund, warum der ganze
|
||||
Monat hineinpasst.
|
||||
- **Der Plan steht einen Monat.** Geplant werden die nächsten 30 Tage, damit die
|
||||
Erinnerungen auch kommen, wenn die App einen Monat nicht geöffnet wird – der
|
||||
Hintergrundlauf (`BGAppRefreshTask`) ist keine Garantie, iOS führt ihn nur aus,
|
||||
wenn es will. Weiter entfernte Tage plant die App beim nächsten Start. Eine
|
||||
MHD-Regel mit mehr als 30 Tagen Vorlauf greift deshalb erst, wenn das Datum in
|
||||
dieses Fenster rückt.
|
||||
|
||||
## NFC-Tags für Kleidung
|
||||
Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es
|
||||
deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag
|
||||
|
||||
@@ -276,6 +276,34 @@ actor APIClient {
|
||||
return try await send(request, as: Item.self)
|
||||
}
|
||||
|
||||
// MARK: - Verleih von Einzelstuecken
|
||||
|
||||
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
|
||||
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
|
||||
var request = try makeRequest("/items/\(id)/loan", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: Loan.self)
|
||||
}
|
||||
|
||||
/// Rueckgabe buchen. Leeres Datum = heute.
|
||||
func returnItem(id: Int, _ payload: LoanReturnRequest = LoanReturnRequest()) async throws -> Loan {
|
||||
var request = try makeRequest("/items/\(id)/return", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
return try await send(request, as: Loan.self)
|
||||
}
|
||||
|
||||
/// Verleih-Historie eines Stuecks, neueste zuerst.
|
||||
func itemLoans(id: Int) async throws -> [Loan] {
|
||||
try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self)
|
||||
}
|
||||
|
||||
/// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren
|
||||
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
|
||||
func loans(dueIn: Int? = nil) async throws -> [Loan] {
|
||||
let pfad = dueIn.map { "/loans?due_in=\($0)" } ?? "/loans"
|
||||
return try await send(try makeRequest(pfad), as: [Loan].self)
|
||||
}
|
||||
|
||||
func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws {
|
||||
var request = try makeRequest("/items/\(id)/remove", method: "POST")
|
||||
try jsonBody(&request, payload)
|
||||
|
||||
@@ -10,6 +10,22 @@ struct ItemListView: View {
|
||||
@State private var query = ""
|
||||
@State private var locationFilter: String? // Lagerort-ID
|
||||
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
|
||||
@State private var verleihFilter: VerleihFilter = .alle
|
||||
|
||||
/// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die
|
||||
/// Frage, die man im Alltag an diese Liste stellt.
|
||||
enum VerleihFilter: String, CaseIterable {
|
||||
case alle, verliehen, ueberfaellig, imHaus
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .alle: return "Alle"
|
||||
case .verliehen: return "Verliehen"
|
||||
case .ueberfaellig: return "Überfällig"
|
||||
case .imHaus: return "Im Haus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
|
||||
@@ -73,6 +89,12 @@ struct ItemListView: View {
|
||||
return items.filter { it in
|
||||
if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false }
|
||||
if let loc = locationFilter, it.locationId != loc { return false }
|
||||
switch verleihFilter {
|
||||
case .alle: break
|
||||
case .verliehen: if it.loan == nil { return false }
|
||||
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
|
||||
case .imHaus: if it.loan != nil { return false }
|
||||
}
|
||||
if !q.isEmpty {
|
||||
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
||||
.compactMap { $0 }.joined(separator: " ").lowercased()
|
||||
@@ -82,7 +104,9 @@ struct ItemListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil }
|
||||
private var hatFilter: Bool {
|
||||
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -117,6 +141,11 @@ struct ItemListView: View {
|
||||
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id }
|
||||
}
|
||||
}
|
||||
Menu("Verleih") {
|
||||
ForEach(VerleihFilter.allCases, id: \.self) { f in
|
||||
check(f.label, verleihFilter == f) { verleihFilter = f }
|
||||
}
|
||||
}
|
||||
if !filterKategorien.isEmpty {
|
||||
Menu("Kategorie") {
|
||||
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
||||
@@ -127,7 +156,9 @@ struct ItemListView: View {
|
||||
}
|
||||
if hatFilter {
|
||||
Divider()
|
||||
Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: {
|
||||
Button(role: .destructive) {
|
||||
locationFilter = nil; categoryFilter = nil; verleihFilter = .alle
|
||||
} label: {
|
||||
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
||||
}
|
||||
}
|
||||
@@ -175,6 +206,14 @@ struct ItemRow: View {
|
||||
Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
// Verliehen ist die Antwort auf „wo ist es?" - deshalb eigene
|
||||
// Zeile, ueberfaellig in Rot.
|
||||
if let loan = item.loan {
|
||||
Text("verliehen an \(verleihZeile(loan))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
+197
-1
@@ -9,6 +9,29 @@ private func dateToString(_ d: Date) -> String {
|
||||
private func stringToDate(_ s: String) -> Date? {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
|
||||
}
|
||||
/// "2026-09-20" -> "20.09.2026". Faellt auf den Rohwert zurueck, damit nie
|
||||
/// eine leere Zeile steht.
|
||||
private func anzeigeDatum(_ iso: String?) -> String {
|
||||
guard let iso else { return "–" }
|
||||
guard let d = stringToDate(iso) else { return iso }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM.yyyy"
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
|
||||
/// Ueberfaelliges kommt beim Aufrufer rot daher.
|
||||
func verleihZeile(_ loan: Loan) -> String {
|
||||
var teile = [loan.borrower]
|
||||
if let tage = loan.daysOverdue {
|
||||
teile.append("überfällig seit \(tage) \(tage == 1 ? "Tag" : "Tagen")")
|
||||
} else if let due = loan.dueOn {
|
||||
teile.append("zurück bis \(anzeigeDatum(due))")
|
||||
}
|
||||
if let grund = loan.reason, !grund.isEmpty { teile.append(grund) }
|
||||
return teile.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
|
||||
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester
|
||||
@@ -88,7 +111,12 @@ struct ProductItemsSection: View {
|
||||
}
|
||||
|
||||
private func untertitel(_ it: Item) -> String {
|
||||
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
|
||||
// Ist das Stueck draussen, ist das die wichtigste Angabe - der Lagerort
|
||||
// steht daneben, weil es dorthin zurueckgehoert.
|
||||
if let loan = it.loan {
|
||||
return "verliehen an \(verleihZeile(loan))"
|
||||
}
|
||||
return [it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
|
||||
.compactMap { $0 }.joined(separator: " · ")
|
||||
}
|
||||
private func reload() async {
|
||||
@@ -195,6 +223,15 @@ struct ItemEditView: View {
|
||||
@State private var previewURL: URL?
|
||||
@State private var locScanShown = false
|
||||
@State private var nfcShown = false
|
||||
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||||
@State private var loan: Loan?
|
||||
@State private var loanHistory: [Loan] = []
|
||||
@State private var showLend = false
|
||||
@State private var borrower = ""
|
||||
@State private var loanReason = ""
|
||||
@State private var hasDue = false
|
||||
@State private var due = Date()
|
||||
@State private var busyLoan = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -221,6 +258,44 @@ struct ItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
|
||||
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
|
||||
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
|
||||
// Knopf hier, um „scannen und verleihen" moeglich zu machen.
|
||||
Section("Verleih") {
|
||||
if let loan {
|
||||
LabeledContent("Verliehen an", value: loan.borrower)
|
||||
if let grund = loan.reason, !grund.isEmpty {
|
||||
LabeledContent("Grund", value: grund)
|
||||
}
|
||||
LabeledContent("Seit", value: anzeigeDatum(loan.lentOn))
|
||||
if let tage = loan.daysOverdue {
|
||||
LabeledContent("Zurück bis") {
|
||||
Text("\(anzeigeDatum(loan.dueOn)) · überfällig seit "
|
||||
+ "\(tage) \(tage == 1 ? "Tag" : "Tagen")")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
LabeledContent("Zurück bis",
|
||||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
||||
}
|
||||
Button { Task { await nimmZurueck() } } label: {
|
||||
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.disabled(busyLoan)
|
||||
} else {
|
||||
Text("Im Haus.").foregroundStyle(.secondary)
|
||||
Button { starteVerleih() } label: {
|
||||
Label("Verleihen", systemImage: "hand.raised")
|
||||
}
|
||||
}
|
||||
if !loanHistory.isEmpty {
|
||||
NavigationLink("Verleih-Verlauf (\(loanHistory.count))") {
|
||||
LoanHistoryView(uid: item.uid, loans: loanHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
@@ -328,6 +403,9 @@ struct ItemEditView: View {
|
||||
.sheet(isPresented: $showRemove) {
|
||||
NavigationStack { removeSheet }
|
||||
}
|
||||
.sheet(isPresented: $showLend) {
|
||||
NavigationStack { lendSheet }
|
||||
}
|
||||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||||
if case .success(let url) = result {
|
||||
Task {
|
||||
@@ -354,6 +432,32 @@ struct ItemEditView: View {
|
||||
.quickLookPreview($previewURL)
|
||||
}
|
||||
|
||||
/// Herausgeben: Nur der Name ist Pflicht. Das Rueckgabedatum ist bewusst
|
||||
/// abschaltbar - „kurz mal mitgenommen" soll nicht an einem Datum scheitern.
|
||||
private var lendSheet: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledField(label: "Verliehen an", text: $borrower)
|
||||
LabeledField(label: "Grund (optional)", text: $loanReason)
|
||||
Toggle("Rückgabe bis", isOn: $hasDue)
|
||||
if hasDue { DateRow(label: "Zurück bis", date: $due) }
|
||||
} footer: {
|
||||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
||||
}
|
||||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||
Section {
|
||||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||||
.disabled(busyLoan || borrower.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verleihen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showLend = false } }
|
||||
}
|
||||
}
|
||||
|
||||
private var removeSheet: some View {
|
||||
Form {
|
||||
Picker("Grund", selection: $reason) {
|
||||
@@ -369,6 +473,47 @@ struct ItemEditView: View {
|
||||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
|
||||
}
|
||||
|
||||
// MARK: - Verleih
|
||||
|
||||
private func starteVerleih() {
|
||||
borrower = ""
|
||||
loanReason = ""
|
||||
hasDue = false
|
||||
due = Date()
|
||||
error = nil
|
||||
showLend = true
|
||||
}
|
||||
|
||||
private func verleihe() async {
|
||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||
do {
|
||||
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
|
||||
borrower: borrower.trimmingCharacters(in: .whitespaces),
|
||||
reason: loanReason.isEmpty ? nil : loanReason,
|
||||
dueOn: hasDue ? dateToString(due) : nil))
|
||||
showLend = false
|
||||
await ladeVerleihe()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func nimmZurueck() async {
|
||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(id: item.id)
|
||||
loan = nil
|
||||
await ladeVerleihe()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
|
||||
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
|
||||
private func ladeVerleihe() async {
|
||||
loanHistory = (try? await APIClient.shared.itemLoans(id: item.id)) ?? []
|
||||
loan = loanHistory.first(where: { $0.istOffen })
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
shops = (try? await APIClient.shared.shops()) ?? []
|
||||
@@ -381,6 +526,8 @@ struct ItemEditView: View {
|
||||
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.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||||
loan = item.loan
|
||||
await ladeVerleihe()
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
@@ -677,3 +824,52 @@ struct ItemAddSheet: View {
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verleih-Verlauf
|
||||
|
||||
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
|
||||
/// erhalten - so ist nachvollziehbar, wer das Stueck wann hatte.
|
||||
struct LoanHistoryView: View {
|
||||
let uid: String
|
||||
let loans: [Loan]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(loans) { loan in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(loan.borrower).bold()
|
||||
Spacer()
|
||||
if loan.istOffen {
|
||||
Text(loan.istUeberfaellig ? "überfällig" : "offen")
|
||||
.font(.caption)
|
||||
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
||||
}
|
||||
}
|
||||
Text(zeitraum(loan)).font(.caption).foregroundStyle(.secondary)
|
||||
if let grund = loan.reason, !grund.isEmpty {
|
||||
Text(grund).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if let von = loan.username {
|
||||
Text("herausgegeben von \(von)").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if loans.isEmpty {
|
||||
Text("Noch nie verliehen.").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verlauf \(uid)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func zeitraum(_ loan: Loan) -> String {
|
||||
let von = anzeigeDatum(loan.lentOn)
|
||||
guard let zurueck = loan.returnedOn else {
|
||||
return loan.dueOn == nil
|
||||
? "seit \(von)"
|
||||
: "seit \(von) · zurück bis \(anzeigeDatum(loan.dueOn))"
|
||||
}
|
||||
return "\(von) – \(anzeigeDatum(zurueck))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,68 @@ struct ItemDocument: Codable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Verleih-Vorgang eines Einzelstuecks. `returnedOn == nil` heisst „noch
|
||||
/// draussen"; je Stueck gibt es hoechstens einen solchen offenen Verleih.
|
||||
///
|
||||
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
|
||||
/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig.
|
||||
struct Loan: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let itemId: Int
|
||||
let borrower: String
|
||||
let reason: String?
|
||||
let lentOn: String // "yyyy-MM-dd"
|
||||
let dueOn: String?
|
||||
let returnedOn: String?
|
||||
let createdAt: String
|
||||
let username: String?
|
||||
let daysOverdue: Int?
|
||||
let itemUid: String?
|
||||
let productId: Int?
|
||||
let productName: String?
|
||||
let productBrand: String?
|
||||
let locationId: String?
|
||||
let locationName: String?
|
||||
|
||||
var istOffen: Bool { returnedOn == nil }
|
||||
var istUeberfaellig: Bool { (daysOverdue ?? 0) > 0 }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, borrower, reason, username
|
||||
case itemId = "item_id"
|
||||
case lentOn = "lent_on"
|
||||
case dueOn = "due_on"
|
||||
case returnedOn = "returned_on"
|
||||
case createdAt = "created_at"
|
||||
case daysOverdue = "days_overdue"
|
||||
case itemUid = "item_uid"
|
||||
case productId = "product_id"
|
||||
case productName = "product_name"
|
||||
case productBrand = "product_brand"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoanCreateRequest: Encodable {
|
||||
let borrower: String
|
||||
var reason: String? = nil
|
||||
var lentOn: String? = nil
|
||||
var dueOn: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case borrower, reason
|
||||
case lentOn = "lent_on"
|
||||
case dueOn = "due_on"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoanReturnRequest: Encodable {
|
||||
var returnedOn: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
|
||||
}
|
||||
|
||||
struct Item: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let uid: String
|
||||
@@ -1065,9 +1127,14 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
let categoryId: Int?
|
||||
let categoryName: String?
|
||||
let documents: [ItemDocument]
|
||||
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
|
||||
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
||||
let loan: Loan?
|
||||
|
||||
var istVerliehen: Bool { loan != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, note, currency, documents
|
||||
case id, uid, note, currency, documents, loan
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
|
||||
@@ -7,7 +7,23 @@ import UserNotifications
|
||||
/// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen
|
||||
/// laesst.
|
||||
struct PlannedNotification: Equatable {
|
||||
enum Kind { case upcoming, expired }
|
||||
enum Kind {
|
||||
case upcoming // Charge laeuft bald ab
|
||||
case expired // Charge laeuft heute ab
|
||||
case loanDue // verliehenes Stueck soll zurueck
|
||||
case loanOverdue // Rueckgabe ist ueberfaellig
|
||||
|
||||
/// Was am selben Tag zuerst gemeldet wird. Versaeumtes vor Anstehendem:
|
||||
/// „ist ueberfaellig" ist dringender als „wird faellig".
|
||||
var dringlichkeit: Int {
|
||||
switch self {
|
||||
case .expired: return 0
|
||||
case .loanOverdue: return 1
|
||||
case .upcoming: return 2
|
||||
case .loanDue: return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profileID: UUID
|
||||
let serverName: String
|
||||
@@ -111,10 +127,10 @@ enum ExpiryPlanner {
|
||||
profileID: profileID, serverName: serverName, day: tag,
|
||||
kind: .expired, products: namen))
|
||||
}
|
||||
// Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst).
|
||||
// Nach Datum, dann nach Dringlichkeit (abgelaufen vor bald-ablaufend).
|
||||
return result.sorted {
|
||||
$0.day != $1.day ? $0.day < $1.day
|
||||
: ($0.kind == .expired && $1.kind == .upcoming)
|
||||
: $0.kind.dringlichkeit < $1.kind.dringlichkeit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +162,100 @@ enum ExpiryPlanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechnet aus den faelligen Verleihen die Rueckgabe-Erinnerungen aus.
|
||||
///
|
||||
/// Gleiche Bauweise wie der `ExpiryPlanner` - netzfrei und ohne iOS-Typen,
|
||||
/// damit sich die Entscheidungen ohne Geraet nachrechnen lassen - und gleiche
|
||||
/// Buendelung: **ein Meldetag ist eine Meldung**, alle an diesem Tag faelligen
|
||||
/// Stuecke stehen darin. Ein Verleih kostet also keinen eigenen Platz in den 60,
|
||||
/// die iOS je App zulaesst; ein Tag kostet einen.
|
||||
enum LoanPlanner {
|
||||
static func plan(
|
||||
profileID: UUID,
|
||||
serverName: String,
|
||||
config: ServerNotificationConfig,
|
||||
loans: [Loan],
|
||||
today: Date,
|
||||
horizonDays: Int,
|
||||
calendar: Calendar = .current
|
||||
) -> [PlannedNotification] {
|
||||
guard config.enabled, config.loansEnabled else { return [] }
|
||||
|
||||
let heute = calendar.startOfDay(for: today)
|
||||
guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Tag -> Bezeichnungen der betroffenen Stuecke, getrennt nach faellig
|
||||
// und ueberfaellig.
|
||||
var faellig: [Date: [String]] = [:]
|
||||
var ueberfaellig: [Date: [String]] = [:]
|
||||
|
||||
func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) {
|
||||
var liste = eimer[tag] ?? []
|
||||
if !liste.contains(name) { liste.append(name) }
|
||||
eimer[tag] = liste
|
||||
}
|
||||
|
||||
for loan in loans {
|
||||
// Ohne Rueckgabedatum ist nichts faellig, Zurueckgegebenes ohnehin nicht.
|
||||
guard loan.istOffen, let rohDatum = loan.dueOn,
|
||||
let due = ExpiryPlanner.parseTag(rohDatum, calendar: calendar) else { continue }
|
||||
let dueTag = calendar.startOfDay(for: due)
|
||||
let name = bezeichnung(loan)
|
||||
|
||||
// 1. Vorlauf: „X vorher" - nur wenn dieser Tag noch bevorsteht.
|
||||
if let vorher = calendar.date(byAdding: .day, value: -config.loanLeadDays, to: dueTag),
|
||||
vorher >= heute, vorher <= horizont, vorher < dueTag {
|
||||
ergaenze(&faellig, vorher, name)
|
||||
}
|
||||
|
||||
// 2. Am Rueckgabetag selbst.
|
||||
if config.loanOnDueDate, dueTag >= heute, dueTag <= horizont {
|
||||
ergaenze(&faellig, dueTag, name)
|
||||
}
|
||||
|
||||
// 3. Danach taeglich weiter, solange das Stueck draussen bleibt -
|
||||
// durch den ganzen Horizont. Das kostet nicht pro Verleih einen
|
||||
// Platz, sondern pro Tag einen: Alle an einem Tag ueberfaelligen
|
||||
// Stuecke stehen in derselben Meldung. So kommt die Erinnerung
|
||||
// auch, wenn die App wochenlang nicht geoeffnet wird.
|
||||
if config.loanOverdue {
|
||||
let start = max(calendar.date(byAdding: .day, value: 1, to: dueTag) ?? dueTag, heute)
|
||||
var tag = start
|
||||
while tag <= horizont {
|
||||
ergaenze(&ueberfaellig, tag, name)
|
||||
guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break }
|
||||
tag = naechster
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result: [PlannedNotification] = []
|
||||
for (tag, namen) in faellig {
|
||||
result.append(PlannedNotification(
|
||||
profileID: profileID, serverName: serverName, day: tag,
|
||||
kind: .loanDue, products: namen))
|
||||
}
|
||||
for (tag, namen) in ueberfaellig {
|
||||
result.append(PlannedNotification(
|
||||
profileID: profileID, serverName: serverName, day: tag,
|
||||
kind: .loanOverdue, products: namen))
|
||||
}
|
||||
return result.sorted {
|
||||
$0.day != $1.day ? $0.day < $1.day
|
||||
: $0.kind.dringlichkeit < $1.kind.dringlichkeit
|
||||
}
|
||||
}
|
||||
|
||||
/// „Kamera (Markus)" – der Entleiher gehoert dazu, sonst weiss man beim
|
||||
/// Lesen der Meldung nicht, wen man fragen muss.
|
||||
static func bezeichnung(_ loan: Loan) -> String {
|
||||
let stueck = loan.productName ?? loan.itemUid ?? "Einzelstück"
|
||||
return "\(stueck) (\(loan.borrower))"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS-Planung
|
||||
|
||||
/// Plant die berechneten Meldungen als lokale Benachrichtigungen ein.
|
||||
@@ -153,9 +263,26 @@ enum ExpiryPlanner {
|
||||
final class NotificationScheduler {
|
||||
static let shared = NotificationScheduler()
|
||||
|
||||
/// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS
|
||||
/// erlaubt 64 ausstehende je App, ueber alle Server zusammen.
|
||||
static let horizonDays = 60
|
||||
/// Wie weit im Voraus geplant wird - und wie viele Meldungen hoechstens.
|
||||
///
|
||||
/// iOS erlaubt 64 ausstehende Meldungen je App, ueber alle Server zusammen.
|
||||
/// Ein Platz ist dabei **ein Meldetag je Server**, nicht ein Produkt und
|
||||
/// nicht ein Verleih: Alles, was an einem Tag zu melden ist, steht in
|
||||
/// derselben Meldung.
|
||||
///
|
||||
/// Der Horizont ist bewusst ein Monat. Er ist die Zusage, wie lange die App
|
||||
/// ungeoeffnet bleiben darf, ohne dass Erinnerungen ausfallen - denn der
|
||||
/// Hintergrundlauf (`BGAppRefreshTask`) ist keine Garantie, iOS fuehrt ihn
|
||||
/// nur aus, wenn es will. 30 Tage × mehrere Server passen in die 64; ein
|
||||
/// laengerer Horizont wuerde nur die spaeteren Tage einplanen und dafuer die
|
||||
/// naechsten verdraengen. Was weiter als 30 Tage weg ist, wird beim
|
||||
/// naechsten Start (oder Hintergrundlauf) geplant.
|
||||
///
|
||||
/// Ein Server passt damit mit dem vollen Monat hinein (31 Meldetage, heute
|
||||
/// mitgezaehlt). Wer mehrere Server eingerichtet hat, bekommt die naechsten
|
||||
/// 60 Meldetage - gekappt wird immer am fernen Ende, nie an den naechsten
|
||||
/// Tagen, und bis dahin ist laengst neu geplant.
|
||||
static let horizonDays = 30
|
||||
static let maxScheduled = 60
|
||||
|
||||
private var laeuftGerade = false
|
||||
@@ -207,20 +334,71 @@ final class NotificationScheduler {
|
||||
geplant += ExpiryPlanner.plan(
|
||||
profileID: prof.id, serverName: prof.name, config: config,
|
||||
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
|
||||
geplant += LoanPlanner.plan(
|
||||
profileID: prof.id, serverName: prof.name, config: config,
|
||||
loans: snapshot.loans, today: heute, horizonDays: Self.horizonDays)
|
||||
}
|
||||
|
||||
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.
|
||||
geplant.sort { $0.day != $1.day ? $0.day < $1.day
|
||||
: ($0.kind == .expired && $1.kind == .upcoming) }
|
||||
// Alles, was an einem Tag auf demselben Server zu melden ist, wird EINE
|
||||
// Meldung. Das ist nicht nur weniger Laerm: Ein Platz je Tag statt einer
|
||||
// je Meldungsart ist der Grund, warum der ganze Monat in die 64 passt -
|
||||
// sonst koennten vier Arten den Monat auf eine Woche zusammenschrumpfen.
|
||||
var gruppen: [Gruppenschluessel: [PlannedNotification]] = [:]
|
||||
for meldung in geplant {
|
||||
gruppen[Gruppenschluessel(profileID: meldung.profileID, day: meldung.day),
|
||||
default: []].append(meldung)
|
||||
}
|
||||
// Die naechsten Tage zuerst - gekappt wird also am fernen Ende, und das
|
||||
// wird bis dahin ohnehin neu geplant.
|
||||
let sortiert = gruppen.sorted { $0.key.day < $1.key.day }
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.removeAllPendingNotificationRequests()
|
||||
for meldung in geplant.prefix(Self.maxScheduled) {
|
||||
center.add(request(for: meldung), withCompletionHandler: nil)
|
||||
for (schluessel, teile) in sortiert.prefix(Self.maxScheduled) {
|
||||
guard let anfrage = request(profileID: schluessel.profileID, day: schluessel.day,
|
||||
teile: teile) else { continue }
|
||||
center.add(anfrage, withCompletionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(for meldung: PlannedNotification) -> UNNotificationRequest {
|
||||
private struct Gruppenschluessel: Hashable {
|
||||
let profileID: UUID
|
||||
let day: Date
|
||||
}
|
||||
|
||||
/// Eine Meldung fuer einen Tag und Server, zusammengesetzt aus allem, was
|
||||
/// an diesem Tag anliegt: ablaufende Chargen und faellige Rueckgaben.
|
||||
private func request(profileID: UUID, day: Date,
|
||||
teile: [PlannedNotification]) -> UNNotificationRequest? {
|
||||
guard let erstes = teile.first else { return nil }
|
||||
// Dringendes zuerst - es bestimmt auch den Titel.
|
||||
let sortiert = teile.sorted { $0.kind.dringlichkeit < $1.kind.dringlichkeit }
|
||||
let serverName = erstes.serverName
|
||||
let dringend = sortiert.contains { $0.kind == .expired || $0.kind == .loanOverdue }
|
||||
|
||||
let inhalt = UNMutableNotificationContent()
|
||||
inhalt.title = dringend ? "ACHTUNG – \(serverName)" : serverName
|
||||
inhalt.body = sortiert.map(Self.satz).joined(separator: "\n")
|
||||
inhalt.sound = .default
|
||||
inhalt.userInfo = ["profileID": profileID.uuidString]
|
||||
|
||||
let config = NotificationStore.config(for: profileID)
|
||||
var wann = Calendar.current.dateComponents([.year, .month, .day], from: day)
|
||||
wann.hour = config.hour
|
||||
wann.minute = config.minute
|
||||
|
||||
// Ein Tag je Server ist eine Meldung – die Kennung braucht die Art
|
||||
// deshalb nicht mehr (und kann sich so auch nicht selbst ueberschreiben).
|
||||
let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)"
|
||||
let id = "vorrania.\(profileID.uuidString).\(tag)"
|
||||
|
||||
return UNNotificationRequest(
|
||||
identifier: id,
|
||||
content: inhalt,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false))
|
||||
}
|
||||
|
||||
/// Ein Satz je Meldungsart, mit hoechstens vier Namen – danach „u. a.".
|
||||
private static func satz(_ meldung: PlannedNotification) -> String {
|
||||
let namen = meldung.products
|
||||
let anzahl = namen.count
|
||||
let liste = namen.prefix(4).joined(separator: ", ")
|
||||
@@ -228,33 +406,21 @@ final class NotificationScheduler {
|
||||
|
||||
switch meldung.kind {
|
||||
case .upcoming:
|
||||
inhalt.title = meldung.serverName
|
||||
inhalt.body = anzahl == 1
|
||||
return anzahl == 1
|
||||
? "\(liste) läuft bald ab."
|
||||
: "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)"
|
||||
case .expired:
|
||||
inhalt.title = "ACHTUNG – \(meldung.serverName)"
|
||||
inhalt.body = anzahl == 1
|
||||
return anzahl == 1
|
||||
? "\(liste) ist heute abgelaufen."
|
||||
: "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)"
|
||||
case .loanDue:
|
||||
return anzahl == 1
|
||||
? "\(liste) sollte zurückkommen."
|
||||
: "\(anzahl) verliehene Stücke sollten zurückkommen: \(liste)\(mehr)"
|
||||
case .loanOverdue:
|
||||
return anzahl == 1
|
||||
? "\(liste) ist überfällig."
|
||||
: "\(anzahl) verliehene Stücke sind überfällig: \(liste)\(mehr)"
|
||||
}
|
||||
inhalt.sound = .default
|
||||
inhalt.userInfo = ["profileID": meldung.profileID.uuidString]
|
||||
|
||||
let config = NotificationStore.config(for: meldung.profileID)
|
||||
var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day)
|
||||
wann.hour = config.hour
|
||||
wann.minute = config.minute
|
||||
|
||||
// Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene
|
||||
// Kennungen, sonst ueberschreibt die zweite die erste.
|
||||
let art = meldung.kind == .expired ? "expired" : "upcoming"
|
||||
let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)"
|
||||
let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)"
|
||||
|
||||
return UNNotificationRequest(
|
||||
identifier: id,
|
||||
content: inhalt,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,52 @@ struct ServerNotificationConfig: Codable, Equatable {
|
||||
var lastWarningOnExpiry: Bool = true
|
||||
var rules: [NotificationRule] = []
|
||||
|
||||
/// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen.
|
||||
// MARK: Rueckgabe-Erinnerung fuer verliehene Einzelstuecke
|
||||
//
|
||||
// Bewusst schlichter als die MHD-Regeln: Ein Verleih hat genau ein Datum,
|
||||
// und nach Kategorien zu unterscheiden waere hier erfunden statt gebraucht.
|
||||
// Ein Verleih ohne Rueckgabedatum ist nie faellig und kommt nie vor.
|
||||
|
||||
var loansEnabled: Bool = false
|
||||
/// „X vorher" – ein Hinweis, bevor die Rueckgabe ansteht.
|
||||
var loanLeadAmount: Int = 1
|
||||
var loanLeadUnit: LeadUnit = .days
|
||||
/// Am Rueckgabetag selbst noch einmal.
|
||||
var loanOnDueDate: Bool = true
|
||||
/// Taeglich weiter, solange das Stueck ueberfaellig ist.
|
||||
var loanOverdue: Bool = true
|
||||
|
||||
/// Vorlauf der Rueckgabe-Erinnerung in Tagen.
|
||||
var loanLeadDays: Int { loanLeadUnit == .weeks ? loanLeadAmount * 7 : loanLeadAmount }
|
||||
|
||||
/// Alte gespeicherte Konfigurationen kennen die Verleih-Schluessel nicht –
|
||||
/// deshalb alle mit decodeIfPresent und Standardwert, sonst waere eine
|
||||
/// bestehende Einstellung nach dem Update nicht mehr lesbar.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enabled, hour, minute, lastWarningOnExpiry, rules
|
||||
case loansEnabled, loanLeadAmount, loanLeadUnit, loanOnDueDate, loanOverdue
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
|
||||
hour = try c.decodeIfPresent(Int.self, forKey: .hour) ?? 9
|
||||
minute = try c.decodeIfPresent(Int.self, forKey: .minute) ?? 0
|
||||
lastWarningOnExpiry = try c.decodeIfPresent(Bool.self, forKey: .lastWarningOnExpiry) ?? true
|
||||
rules = try c.decodeIfPresent([NotificationRule].self, forKey: .rules) ?? []
|
||||
loansEnabled = try c.decodeIfPresent(Bool.self, forKey: .loansEnabled) ?? false
|
||||
loanLeadAmount = try c.decodeIfPresent(Int.self, forKey: .loanLeadAmount) ?? 1
|
||||
loanLeadUnit = try c.decodeIfPresent(LeadUnit.self, forKey: .loanLeadUnit) ?? .days
|
||||
loanOnDueDate = try c.decodeIfPresent(Bool.self, forKey: .loanOnDueDate) ?? true
|
||||
loanOverdue = try c.decodeIfPresent(Bool.self, forKey: .loanOverdue) ?? true
|
||||
}
|
||||
|
||||
/// Ohne aktive Regel, ohne letzte Warnung und ohne Verleih-Erinnerung gibt
|
||||
/// es nichts zu planen – dann muss der Server auch nicht abgefragt werden.
|
||||
var hasSomethingToSchedule: Bool {
|
||||
enabled && (!rules.isEmpty || lastWarningOnExpiry)
|
||||
enabled && (!rules.isEmpty || lastWarningOnExpiry || loansEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ struct NotificationSettingsView: View {
|
||||
set: { an in Task { await setzeAktiv(an) } }
|
||||
))
|
||||
} footer: {
|
||||
Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.")
|
||||
Text("Erinnert vor dem Ablauf von Produkten und an fällige Rückgaben auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.")
|
||||
}
|
||||
|
||||
if berechtigungFehlt {
|
||||
@@ -91,6 +91,39 @@ struct NotificationSettingsView: View {
|
||||
} footer: {
|
||||
Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Rückgabe-Erinnerung", isOn: Binding(
|
||||
get: { config.loansEnabled },
|
||||
set: { config.loansEnabled = $0; speichern() }
|
||||
))
|
||||
if config.loansEnabled {
|
||||
Stepper("\(config.loanLeadAmount) \(config.loanLeadUnit.label) vorher",
|
||||
value: Binding(
|
||||
get: { config.loanLeadAmount },
|
||||
set: { config.loanLeadAmount = $0; speichern() }
|
||||
), in: 1...99)
|
||||
Picker("Einheit", selection: Binding(
|
||||
get: { config.loanLeadUnit },
|
||||
set: { config.loanLeadUnit = $0; speichern() }
|
||||
)) {
|
||||
ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Toggle("Am Rückgabetag", isOn: Binding(
|
||||
get: { config.loanOnDueDate },
|
||||
set: { config.loanOnDueDate = $0; speichern() }
|
||||
))
|
||||
Toggle("Täglich, solange überfällig", isOn: Binding(
|
||||
get: { config.loanOverdue },
|
||||
set: { config.loanOverdue = $0; speichern() }
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Verliehene Einzelstücke")
|
||||
} footer: {
|
||||
Text("Erinnert an Stücke, die zurückkommen sollen. Ein Verleih ohne Rückgabedatum ist nie fällig und kommt hier nicht vor – das Datum ist beim Verleihen optional.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Benachrichtigungen")
|
||||
|
||||
@@ -8,11 +8,13 @@ import Foundation
|
||||
/// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`.
|
||||
enum ServerFetch {
|
||||
/// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont,
|
||||
/// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum.
|
||||
/// die Produkte (fuer die Kategoriezuordnung), der Kategorie-Baum und die
|
||||
/// faelligen Verleihe.
|
||||
struct Snapshot {
|
||||
let expiring: [ExpiringItem]
|
||||
let products: [Product]
|
||||
let categories: [CategoryItem]
|
||||
let loans: [Loan]
|
||||
}
|
||||
|
||||
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot {
|
||||
@@ -20,12 +22,18 @@ enum ServerFetch {
|
||||
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
|
||||
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
|
||||
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
|
||||
// due_in filtert serverseitig auf den Horizont; ueberfaellige sind dabei
|
||||
// immer enthalten, und Verleihe ohne Rueckgabedatum nie - sie sind nie
|
||||
// faellig, es gibt also nichts zu erinnern.
|
||||
async let loans: [Loan] = get(
|
||||
"/loans?due_in=\(horizonDays)", baseURL: baseURL, token: token)
|
||||
// Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die
|
||||
// Kategorien nur, weil die Produktliste langsam war).
|
||||
return await Snapshot(
|
||||
expiring: (try? await expiring) ?? [],
|
||||
products: (try? await products) ?? [],
|
||||
categories: (try? await categories) ?? []
|
||||
categories: (try? await categories) ?? [],
|
||||
loans: (try? await loans) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,23 @@ export const api = {
|
||||
deleteItemDocument: (itemId, docId) =>
|
||||
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
|
||||
|
||||
// Verleih von Einzelstücken. Der Lagerort bleibt dabei stehen – dorthin
|
||||
// gehört das Stück zurück; verliehen zählt es aber nicht mehr als Bestand.
|
||||
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
|
||||
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
|
||||
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
|
||||
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
|
||||
// in so vielen Tagen ansteht (überfällige sind immer dabei).
|
||||
loans: ({ openOnly = true, dueIn } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (!openOnly) p.set("open_only", "false");
|
||||
if (dueIn != null) p.set("due_in", String(dueIn));
|
||||
const q = p.toString();
|
||||
return request(`/loans${q ? `?${q}` : ""}`);
|
||||
},
|
||||
updateLoan: (loanId, body) => request(`/loans/${loanId}`, { method: "PATCH", body }),
|
||||
deleteLoan: (loanId) => request(`/loans/${loanId}`, { method: "DELETE" }),
|
||||
|
||||
// Bestand
|
||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
|
||||
|
||||
@@ -7,6 +7,13 @@ import Icon from "./Icon";
|
||||
import { REASONS } from "./ObjektBestand";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { itemDeepLink } from "../qr";
|
||||
import {
|
||||
VerleihAktion,
|
||||
VerleihForm,
|
||||
VerleihHistorie,
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "./Verleih";
|
||||
|
||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
||||
|
||||
@@ -40,6 +47,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
const [items, setItems] = useState([]);
|
||||
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
||||
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
|
||||
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
|
||||
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
||||
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
|
||||
@@ -263,14 +271,15 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th>
|
||||
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
|
||||
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
|
||||
<th>Notiz</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<Fragment key={it.id}>
|
||||
<tr className="einzel-mainrow">
|
||||
<tr className={`einzel-mainrow ${verleihRowClass(it.loan)}`.trim()}>
|
||||
<td><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
|
||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||
<td data-label="Lagerort">
|
||||
@@ -282,6 +291,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</select>
|
||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
||||
<td data-label="Gekauft am">
|
||||
{isAdmin ? (
|
||||
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
|
||||
@@ -311,8 +321,12 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
) : <span>{it.note || "–"}</span>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
|
||||
onError={onError} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button className="btn-icon" title="Mit Grund entfernen"
|
||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||
<Icon name="checkout" size={16} />
|
||||
@@ -321,14 +335,21 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
onClick={() => loeschen(it)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="einzel-detailrow">
|
||||
<td colSpan={8}>
|
||||
<td colSpan={9}>
|
||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
||||
<div>
|
||||
<span className="muted small">Verleih</span>
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<VerleihHistorie item={it} onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ margin: 0 }}>Kaufpreis
|
||||
<div className="field-inline">
|
||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
||||
@@ -409,7 +430,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||
{items.length === 0 && <tr><td colSpan={9} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -506,6 +527,11 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</form>
|
||||
)}
|
||||
|
||||
{lend && (
|
||||
<VerleihForm item={lend} onError={onError} onCancel={() => setLend(null)}
|
||||
onDone={async () => { setLend(null); await nachAktion(); }} />
|
||||
)}
|
||||
|
||||
{isAdmin && remove && (
|
||||
<form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="card-head"><Icon name="trash" /><h3>Einzelstück {remove.item.uid} entfernen</h3>
|
||||
|
||||
@@ -62,6 +62,15 @@ const PATHS = {
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</>
|
||||
),
|
||||
// Verleih: Paket auf offener Hand – gibt etwas heraus, ohne es abzugeben.
|
||||
loan: (
|
||||
<>
|
||||
<rect x="7" y="3" width="10" height="7" rx="1" />
|
||||
<path d="M12 3v7" />
|
||||
<path d="M3 14h4l2.5 2.5" />
|
||||
<path d="M7 14c1.5 0 2.7.5 3.8 1.2l1.4.9c.6.4 1.4.4 2 0l3.3-2c.8-.5 1.9-.3 2.4.5.5.8.3 1.9-.5 2.4l-5.2 3.3c-.9.6-2 .7-3 .3L7 18.5" />
|
||||
</>
|
||||
),
|
||||
cart: (
|
||||
<>
|
||||
<circle cx="8" cy="21" r="1" />
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useToast } from "../toast";
|
||||
import { daysUntil, relativeExpiry } from "../units";
|
||||
|
||||
/**
|
||||
* Verleih eines Einzelstücks – Anzeige und Aktionen, an drei Stellen benutzt:
|
||||
* in der Einzelstück-Tabelle eines Artikels, in der Liste aller Einzelstücke und
|
||||
* auf der Scan-Zielseite (/i/<uid>).
|
||||
*
|
||||
* Der Lagerort wird dabei nie angetastet: Er sagt weiter, wohin das Stück nach
|
||||
* der Rückgabe zurückgehört. Verliehen zählt es nur nicht mehr als Bestand.
|
||||
*/
|
||||
|
||||
/** Fällig-Text mit Zustandsfarbe: rot überfällig, gelb demnächst, sonst schlicht. */
|
||||
export function FristBadge({ loan, warnTage = 7 }) {
|
||||
if (!loan || !loan.due_on) return null;
|
||||
const tage = daysUntil(loan.due_on);
|
||||
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
|
||||
const text = loan.days_overdue
|
||||
? `überfällig (${relativeExpiry(-loan.days_overdue)})`
|
||||
: `zurück ${relativeExpiry(tage)}`;
|
||||
return art
|
||||
? <span className={`badge ${art}`}>{text}</span>
|
||||
: <span className="muted small">{text}</span>;
|
||||
}
|
||||
|
||||
/** Einzeiler „verliehen an X" mit Frist – oder ein Strich, wenn das Stück da ist. */
|
||||
export function VerleihText({ loan }) {
|
||||
if (!loan) return <span className="muted">–</span>;
|
||||
return (
|
||||
<span className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
|
||||
<span className="strong">{loan.borrower}</span>
|
||||
<FristBadge loan={loan} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeilenklasse für Tabellen: überfällig rot, demnächst fällig gelb. */
|
||||
export function verleihRowClass(loan, warnTage = 7) {
|
||||
if (!loan) return "";
|
||||
if (loan.days_overdue) return "row-danger";
|
||||
const tage = daysUntil(loan.due_on);
|
||||
return tage != null && tage <= warnTage ? "row-warn" : "";
|
||||
}
|
||||
|
||||
const LEER = { borrower: "", reason: "", due_on: "" };
|
||||
|
||||
/**
|
||||
* Formular zum Herausgeben. Nur der Name ist Pflicht; Grund und Rückgabedatum
|
||||
* sind bewusst optional – „kurz mal mitgenommen" soll nicht an einem
|
||||
* Datumszwang scheitern.
|
||||
*/
|
||||
export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(LEER);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
if (!form.borrower.trim()) { onError("Bitte einen Namen angeben."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.lendItem(item.id, {
|
||||
borrower: form.borrower.trim(),
|
||||
reason: form.reason.trim() || null,
|
||||
due_on: form.due_on || null,
|
||||
});
|
||||
setForm(LEER);
|
||||
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
|
||||
if (onDone) await onDone();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="card-head">
|
||||
<Icon name="loan" /><h3>Einzelstück {item.uid} verleihen</h3>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
|
||||
title="Schließen" onClick={onCancel}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">Verliehen an
|
||||
<input autoFocus value={form.borrower} placeholder="z.B. Markus"
|
||||
onChange={(e) => setForm({ ...form, borrower: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Grund (optional)
|
||||
<input value={form.reason} placeholder="z.B. Hochzeit"
|
||||
onChange={(e) => setForm({ ...form, reason: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Zurück bis (optional)
|
||||
<input type="date" value={form.due_on}
|
||||
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
|
||||
verliehen zählt es aber nicht mehr zum Bestand.
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
<Icon name="loan" size={16} />{busy ? "Wird gespeichert…" : "Verleihen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Knopf-Paar für eine Tabellenzeile: verleihen bzw. zurücknehmen. Das Formular
|
||||
* öffnet der Aufrufer, damit es in seiner Tabelle an der richtigen Stelle steht.
|
||||
*/
|
||||
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function zurueck() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.returnItem(item.id);
|
||||
toast(`${item.uid} ist zurück.`);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (item.loan) {
|
||||
return (
|
||||
<button type="button" className="btn-icon" disabled={busy}
|
||||
title={`Zurücknehmen (von ${item.loan.borrower})`} onClick={zurueck}>
|
||||
<Icon name="checkin" size={size} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className="btn-icon" title="Verleihen"
|
||||
onClick={() => onLend(item)}>
|
||||
<Icon name="loan" size={size} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verleih-Historie eines Stücks (neueste zuerst), bei Bedarf nachgeladen. */
|
||||
export function VerleihHistorie({ item, onError }) {
|
||||
const [zeilen, setZeilen] = useState(null);
|
||||
const [offen, setOffen] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
setOffen(true);
|
||||
try { setZeilen(await api.itemLoans(item.id)); }
|
||||
catch (err) { onError(err.message); }
|
||||
}
|
||||
|
||||
if (!offen) {
|
||||
return (
|
||||
<button type="button" className="btn sm ghost" onClick={laden}>
|
||||
<Icon name="history" size={14} />Verleih-Verlauf
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (zeilen == null) return <span className="muted small">Wird geladen…</span>;
|
||||
if (zeilen.length === 0) return <span className="muted small">Noch nie verliehen.</span>;
|
||||
|
||||
return (
|
||||
<ul className="clean-list">
|
||||
{zeilen.map((v) => (
|
||||
<li key={v.id} className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
|
||||
<span className="strong">{v.borrower}</span>
|
||||
<span className="muted small">
|
||||
{v.lent_on}
|
||||
{v.returned_on ? ` – ${v.returned_on}` : " – offen"}
|
||||
{v.reason ? ` · ${v.reason}` : ""}
|
||||
</span>
|
||||
{!v.returned_on && <FristBadge loan={v} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Icon from "../components/Icon";
|
||||
import { CardSkeleton } from "../components/Skeleton";
|
||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||
import BewegungIcon from "../components/BewegungIcon";
|
||||
import { FristBadge, verleihRowClass } from "../components/Verleih";
|
||||
import { useSettings } from "../settings";
|
||||
import {
|
||||
articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||
@@ -396,6 +397,60 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Verliehene Einzelstücke – wer hat was, und ist die Rückgabe schon fällig?
|
||||
*
|
||||
* „modus" trennt die beiden nützlichen Blickwinkel: alles Draußene oder nur das
|
||||
* Überfällige. Stücke ohne Rückgabedatum sind nie fällig und tauchen deshalb im
|
||||
* Modus „ueberfaellig" nicht auf.
|
||||
*/
|
||||
function KarteVerleih({ modus = "alle" }) {
|
||||
const { daten, fehler } = useDaten(() => api.loans());
|
||||
const zeilen = (daten || []).filter(
|
||||
(v) => (modus === "ueberfaellig" ? !!v.days_overdue : true),
|
||||
);
|
||||
const leer = modus === "ueberfaellig"
|
||||
? "Nichts überfällig."
|
||||
: "Gerade ist nichts verliehen.";
|
||||
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten} leer={leer}>
|
||||
{zeilen.length === 0 ? (
|
||||
<div className="empty">{leer}</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Stück</th><th>Verliehen an</th><th>Zurück</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((v) => (
|
||||
<tr key={v.id} className={verleihRowClass(v)}>
|
||||
<td data-label="Stück">
|
||||
<Link to={`/i/${encodeURIComponent(v.item_uid || "")}`}>
|
||||
{v.product_name || v.item_uid}
|
||||
</Link>
|
||||
{v.product_name && <div className="muted small">{v.item_uid}</div>}
|
||||
</td>
|
||||
<td data-label="Verliehen an">
|
||||
{v.borrower}
|
||||
{v.reason && <div className="muted small">{v.reason}</div>}
|
||||
</td>
|
||||
<td data-label="Zurück">
|
||||
{v.due_on
|
||||
? <FristBadge loan={v} />
|
||||
: <span className="muted small">offen</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteBewegungen() {
|
||||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||
return (
|
||||
@@ -655,6 +710,18 @@ export const KARTEN = {
|
||||
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
|
||||
komponente: KarteEinkaufsliste,
|
||||
},
|
||||
loans: {
|
||||
titel: "Verliehen", icon: "loan", vorschau: "tabelle",
|
||||
beschreibung: "Welche Einzelstücke gerade draußen sind und bei wem",
|
||||
standard: { w: 6, h: 5 }, min: { w: 4, h: 4 },
|
||||
komponente: () => <KarteVerleih modus="alle" />,
|
||||
},
|
||||
"loans-overdue": {
|
||||
titel: "Überfällige Verleihe", icon: "alert", vorschau: "tabelle",
|
||||
beschreibung: "Nur Stücke, deren Rückgabedatum vorbei ist",
|
||||
standard: { w: 6, h: 4 }, min: { w: 4, h: 3 },
|
||||
komponente: () => <KarteVerleih modus="ueberfaellig" />,
|
||||
},
|
||||
movements: {
|
||||
titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
|
||||
beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
|
||||
|
||||
@@ -8,6 +8,12 @@ import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { categoryInfoMap } from "../categoryPath";
|
||||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||||
import {
|
||||
VerleihAktion,
|
||||
VerleihForm,
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "../components/Verleih";
|
||||
|
||||
/**
|
||||
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
|
||||
@@ -22,6 +28,7 @@ export default function ItemList() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -71,6 +78,16 @@ export default function ItemList() {
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (locationPathById(it.location_id, locations) || "–")) },
|
||||
{ key: "loan", header: "Verliehen an", width: 200,
|
||||
filterText: (it) => it.loan?.borrower || "",
|
||||
filterValues: (it) => [it.loan?.borrower || ""],
|
||||
filterOptionLabel: (v) => (v === "" ? "(im Haus)" : v),
|
||||
sortValue: (it) => it.loan?.borrower || "",
|
||||
render: (it) => <VerleihText loan={it.loan} /> },
|
||||
{ key: "due", header: "Zurück bis", width: 120,
|
||||
filterText: (it) => it.loan?.due_on || "",
|
||||
sortValue: (it) => it.loan?.due_on || "",
|
||||
render: (it) => it.loan?.due_on || "–" },
|
||||
{ key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
|
||||
render: (it) => (isAdmin ? (
|
||||
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
|
||||
@@ -90,6 +107,10 @@ export default function ItemList() {
|
||||
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
||||
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
|
||||
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
|
||||
{ key: "actions", header: "", label: "Verleihen", fixed: true, width: 52, align: "num",
|
||||
render: (it) => (
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={load} onError={setError} />
|
||||
) },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -103,8 +124,13 @@ export default function ItemList() {
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
<div className="card">
|
||||
<DataTable id="items" columns={columns} rows={items} loading={loading}
|
||||
getRowKey={(it) => it.id} empty="Noch keine Einzelstücke." />
|
||||
getRowKey={(it) => it.id} rowClassName={(it) => verleihRowClass(it.loan)}
|
||||
empty="Noch keine Einzelstücke." />
|
||||
</div>
|
||||
{lend && (
|
||||
<VerleihForm item={lend} onError={setError} onCancel={() => setLend(null)}
|
||||
onDone={async () => { setLend(null); await load(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+113
-17
@@ -1,37 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { FristBadge, VerleihForm, VerleihHistorie } from "../components/Verleih";
|
||||
import { useToast } from "../toast";
|
||||
import { locationPathById } from "../locationPath";
|
||||
|
||||
/**
|
||||
* Ziel eines gescannten QR-Codes (/i/<uid>): löst die UID auf und öffnet den
|
||||
* zugehörigen Artikel mit dem Einzelstück im Blick.
|
||||
* Ziel eines gescannten Einzelstück-QR bzw. NFC-Tags (/i/<uid>) – zeigt, was man
|
||||
* da in der Hand hält, und bietet die Handgriffe direkt an: verleihen,
|
||||
* zurücknehmen, Artikel öffnen.
|
||||
*
|
||||
* Vorher leitete die Seite nur zum Artikel weiter; fürs Verleihen direkt nach
|
||||
* dem Scannen braucht die Aktion aber genau hier ihren Platz. Gegenstück zu
|
||||
* `LocationResolve.jsx` für Lagerort-QR.
|
||||
*/
|
||||
export default function ItemResolve() {
|
||||
const { uid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [item, setItem] = useState(null);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lend, setLend] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ok = true;
|
||||
api.itemByUid(uid)
|
||||
.then((item) => {
|
||||
if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`,
|
||||
{ replace: true });
|
||||
})
|
||||
.catch((err) => { if (ok) setError(err.message); });
|
||||
return () => { ok = false; };
|
||||
}, [uid, navigate]);
|
||||
const laden = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const gefunden = await api.itemByUid(uid);
|
||||
setItem(gefunden);
|
||||
setError(null);
|
||||
// Nur für den vollen Lagerort-Pfad („Keller → Regal 2"). Scheitert es,
|
||||
// bleibt der Name aus dem Stück selbst stehen.
|
||||
try { setLocations(await api.listLocations()); } catch { /* nicht kritisch */ }
|
||||
} catch (err) { setError(err.message); } finally { setLoading(false); }
|
||||
}, [uid]);
|
||||
|
||||
useEffect(() => { laden(); }, [laden]);
|
||||
|
||||
async function zurueck() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.returnItem(item.id);
|
||||
toast(`${item.uid} ist zurück.`);
|
||||
await laden();
|
||||
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const ort = item
|
||||
? (locationPathById(item.location_id, locations) || item.location_name)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einzelstück {uid}</h1>
|
||||
<div className="sub">{error ? error : "Wird geöffnet…"}</div>
|
||||
<h1><Icon name="tag" size={20} /> {item?.product_name || `Einzelstück ${uid}`}</h1>
|
||||
<div className="sub">
|
||||
{loading ? "Wird geladen…" : (
|
||||
<>
|
||||
<span className="strong">{uid}</span>
|
||||
{item?.product_brand ? ` · ${item.product_brand}` : ""}
|
||||
{ort ? ` · ${ort}` : ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item && (
|
||||
item.loan ? (
|
||||
<button className="btn primary" disabled={busy} onClick={zurueck}>
|
||||
<Icon name="checkin" size={16} />{busy ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn primary" onClick={() => setLend(true)}>
|
||||
<Icon name="loan" size={16} />Verleihen
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{item && (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<ProduktThumb productId={item.product_id} alt={item.product_name} />
|
||||
<h2>{item.loan ? `Verliehen an ${item.loan.borrower}` : "Im Haus"}</h2>
|
||||
{item.loan && <FristBadge loan={item.loan} />}
|
||||
<Link className="btn sm ghost" style={{ marginLeft: "auto" }}
|
||||
to={`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`}>
|
||||
Artikel öffnen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<dl className="kv">
|
||||
<dt>Lagerort</dt>
|
||||
<dd>
|
||||
{ort || "– ohne –"}
|
||||
{item.loan && (
|
||||
<span className="muted small"> · gerade nicht dort, aber dorthin zurück</span>
|
||||
)}
|
||||
</dd>
|
||||
{item.loan && (
|
||||
<>
|
||||
<dt>Verliehen seit</dt>
|
||||
<dd>{item.loan.lent_on}</dd>
|
||||
{item.loan.reason && (<><dt>Grund</dt><dd>{item.loan.reason}</dd></>)}
|
||||
<dt>Zurück bis</dt>
|
||||
<dd>{item.loan.due_on || "– offen –"}</dd>
|
||||
</>
|
||||
)}
|
||||
{item.note && (<><dt>Notiz</dt><dd>{item.note}</dd></>)}
|
||||
{item.warranty_until && (
|
||||
<><dt>Garantie bis</dt><dd>{item.warranty_until}</dd></>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<VerleihHistorie item={item} onError={setError} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item && lend && (
|
||||
<VerleihForm item={item} onError={setError} onCancel={() => setLend(false)}
|
||||
onDone={async () => { setLend(false); await laden(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user