Compare commits

..
4 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 b2dda78a7b Rueckgabe-Erinnerung: ein Meldetag ist eine Meldung
Erinnert an faellige Rueckgaben verliehener Einzelstuecke, auf dem Weg der
Ablauf-Meldungen: lokale Benachrichtigung, geplant aus GET /loans?due_in=.
Je Server einstellbar - "X vorher", am Rueckgabetag, und taeglich weiter,
solange etwas ueberfaellig ist. Ein Verleih ohne Rueckgabedatum ist nie
faellig und kommt nicht vor.

Zwei Aenderungen am bestehenden Plan waren dafuer noetig, und beide haben
denselben Grund: Der Plan muss allein stehen. Der BGAppRefreshTask ist
keine Garantie - iOS fuehrt ihn nur aus, wenn es will -, also darf die
Erinnerung nicht davon abhaengen, dass die App geoeffnet wird.

1. Alles, was an einem Tag auf einem Server anliegt, wird EINE Meldung.
   Vorher waren es zwei Arten (bald ablaufend, abgelaufen) mit je eigener
   Kennung; mit dem Verleih waeren es vier geworden. iOS erlaubt 64
   geplante Mitteilungen je App - ein Platz je Tag statt je Art ist die
   Voraussetzung dafuer, dass der Horizont ueberhaupt hineinpasst. Ein
   vergessener Verleih hat sonst mit taeglicher Meldung den ganzen Monat
   belegt und alle MHD-Meldungen verdraengt, weil die weiter in der
   Zukunft liegen und bei der Kappung zuerst wegfallen.

2. Horizont 30 statt 60 Tage. Er ist damit die Zusage, wie lange die App
   ungeoeffnet bleiben darf, ohne dass Erinnerungen ausfallen. Ein
   laengerer Horizont haette nur spaetere Tage eingeplant und dafuer die
   naechsten verdraengt. Preis: Eine MHD-Regel mit mehr als 30 Tagen
   Vorlauf greift erst, wenn das Datum in dieses Fenster rueckt.

Der LoanPlanner ist wie der ExpiryPlanner netzfrei und ohne iOS-Typen
gebaut, damit sich die Entscheidungen ohne Geraet nachrechnen lassen.
Gespeicherte Konfigurationen werden mit decodeIfPresent gelesen - eine
bestehende Einstellung bleibt nach dem Update gueltig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:47:26 +02:00
ScarriffleandClaude Opus 5 aa72d8342a App: Einzelstuecke verleihen - alle Scan-Wege enden schon dort
Verleih-Abschnitt im Einzelstueck: an wen, seit wann, bis wann, dazu
Herausgeben bzw. Zuruecknehmen und der Verleih-Verlauf. Pflicht ist nur der
Name; Grund und Rueckgabedatum sind abschaltbar, weil "kurz mal
mitgenommen" nicht an einem Datum scheitern soll.

Der Abschnitt sitzt bewusst oben in ItemEditView, und zwar nicht nur der
Wichtigkeit wegen: QR ueber Nachschlagen, eingenaehtes NFC-Tag und ein
angetipptes Tag (vorrania://i/<UID>) landen alle in genau dieser Ansicht.
Damit ist "scannen und verleihen" ueberall moeglich, ohne einen eigenen
Weg dafuer zu bauen.

In der Einzelstueck-Liste steht der Verleih unter dem Stueck - ueberfaellig
in Rot - und das Filter-Menue kann auf Verliehen, Ueberfaellig oder Im Haus
eingrenzen. Wo etwas ist, war bisher immer der Lagerort; ist es verliehen,
ist der Entleiher die Antwort, und der Lagerort steht daneben.

days_overdue rechnet der Server (GET /loans, ItemOut.loan), damit App und
Web nicht getrennt rechnen und nicht auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:36:05 +02:00
ScarriffleandClaude Opus 5 1332dd86fa Web: Einzelstuecke verleihen, auch direkt nach dem Scannen
Verleihen und Zuruecknehmen in der Einzelstueck-Tabelle eines Artikels und
in der Liste aller Einzelstuecke, dort als Spalten "Verliehen an" und
"Zurueck bis" (filter- und sortierbar). Ueberfaellig ist rot, demnaechst
faellig gelb - dieselben Zustandsfarben wie beim MHD.

Die Scan-Zielseite /i/<uid> leitete vorher nur zum Artikel weiter. Fuer
"scannen und verleihen" braucht die Aktion aber genau dort ihren Platz:
Die Seite zeigt jetzt, was man in der Hand haelt - Stueck, Artikel,
Lagerort, Verleihzustand - und bietet Verleihen bzw. Zuruecknehmen an.
Gegenstueck zur Lagerort-Seite /l/<code>, die es fuers Einlagern genauso
macht.

Anzeige und Aktionen liegen einmal in components/Verleih.jsx, weil alle
drei Stellen dasselbe brauchen. Der Knopf haengt nicht an isAdmin:
Verleihen ist eine Buchung wie Ein- und Auslagern.

Dazu zwei Dashboard-Karten: alles Verliehene und, getrennt, nur das
Ueberfaellige. Stuecke ohne Rueckgabedatum sind nie faellig und tauchen in
der zweiten Karte nicht auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:30:14 +02:00
ScarriffleandClaude Opus 5 467ef3c804 Einzelstuecke verleihen: eigener Vorgang statt Lagerort-Wert
Der Lagerort bleibt beim Verleihen stehen - er sagt weiter, wohin das
Stueck nach der Rueckgabe zurueckgehoert. Ein Lagerort-Wert "verliehen"
haette diese Information vernichtet. Stattdessen eine eigene Tabelle
item_loans mit Entleiher und Grund als Freitext, Verleihdatum und einem
optionalen Rueckgabedatum: "kurz mal mitgenommen" soll nicht an einem
Datumszwang scheitern.

Zurueckgegebene Vorgaenge bleiben stehen, damit die Tabelle gleichzeitig
die Verleih-Historie je Stueck ist. Ein Teilindex auf die offenen
Vorgaenge haelt fest, dass ein Stueck nicht zweimal gleichzeitig
verliehen sein kann - wie bei den "Ueberall"-Mindestbestaenden, weil
Postgres NULLs in UNIQUE als verschieden zaehlt.

Verliehen heisst nicht im Bestand: Ein Stueck, das nicht im Haus ist,
zaehlt nirgends mehr mit - weder am Lagerort noch im Gesamtbestand.
Entschieden wird das an einer Stelle (services/loans.py::nicht_verliehen),
die alle fuenf Zaehlungen benutzen. Sonst behauptete "Was liegt hier?"
weiterhin, die Kamera liege im Regal. Folge: Faellt ein verliehenes
Stueck unter den Mindestbestand, erscheint der Artikel auf der
Einkaufsliste.

Verleihen und Zuruecknehmen darf jeder angemeldete Benutzer - es ist eine
Buchung wie Ein- und Auslagern, keine Stammdatenaenderung - und wird mit
dem Konto protokolliert. Nur das Loeschen eines Vorgangs bleibt Admins
vorbehalten, weil es Historie vernichtet.

Bewusst keine Movement-Bewegung je Verleih: Der Verlauf rechnet den
Bestand rueckwaerts aus vorzeichenbehafteten Bewegungen, ein Abgang ohne
Gegenbuchung wuerde diese Kurve verfaelschen.

GET /loans?due_in= liefert die faelligen Verleihe und ist damit schon die
Datenquelle fuer die spaetere Rueckgabe-Erinnerung der App. Oberflaeche in
Web und App fehlt noch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:16:47 +02:00
29 changed files with 1987 additions and 91 deletions
+8
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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)
+56
View File
@@ -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).
+5 -1
View File
@@ -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)
+4
View File
@@ -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()
+204
View File
@@ -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()
+51
View File
@@ -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 ----
+17 -2
View File
@@ -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
+62
View File
@@ -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
+14 -7
View File
@@ -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)
+384
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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)
+41 -2
View File
@@ -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
View File
@@ -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))"
}
}
+68 -1
View File
@@ -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"
+200 -34
View File
@@ -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))
}
}
+45 -2
View File
@@ -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)
}
}
+34 -1
View File
@@ -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")
+10 -2
View File
@@ -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) ?? []
)
}
+17
View File
@@ -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 }),
+33 -7
View File
@@ -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>
+9
View File
@@ -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" />
+181
View File
@@ -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>
);
}
+67
View File
@@ -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",
+27 -1
View File
@@ -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
View File
@@ -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>
);
}