Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.
Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.
In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.
Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.
ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.
Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.
Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.
Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.
In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.
12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca4e9a2264
commit
a3fd9819c4
@@ -222,6 +222,10 @@ def _ensure_schema() -> None:
|
||||
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
||||
"WHERE email IS NOT NULL",
|
||||
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop).
|
||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS parent_item_id INTEGER "
|
||||
"REFERENCES items(id) ON DELETE SET NULL",
|
||||
"CREATE INDEX IF NOT EXISTS ix_items_parent_item_id ON items (parent_item_id)",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||
|
||||
@@ -742,9 +742,30 @@ class Item(Base):
|
||||
# entsteht; Währung getrennt (z.B. CHF, EUR).
|
||||
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
# Zubehoer: Dieses Stueck gehoert zu einem anderen (Netzteil -> Laptop).
|
||||
# Genau ein Geraet je Teil, beliebig viele Teile je Geraet - dafuer genuegt
|
||||
# eine Spalte, es braucht keine Verknuepfungstabelle wie bei den Gruppen.
|
||||
# SET NULL statt CASCADE: Verschwindet das Geraet, wird das Netzteil wieder
|
||||
# eigenstaendig, statt ungefragt mitgeloescht zu werden.
|
||||
parent_item_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
product: Mapped[Product] = relationship()
|
||||
# Beide Richtungen sind gefragt: am Geraet "was gehoert dazu", am Teil
|
||||
# "wozu gehoert das". selectin, weil sonst jede Einzelstueckliste je Zeile
|
||||
# eine Extraabfrage ausloest (wie bei Group.parents).
|
||||
gehoert_zu: Mapped["Item | None"] = relationship(
|
||||
"Item", remote_side="Item.id", back_populates="zubehoer", lazy="joined",
|
||||
# joined statt selectin: Es ist hoechstens EIN Geraet je Teil, ein
|
||||
# many-to-one-Join kann die Zeilen also nicht vervielfachen - und ohne
|
||||
# das holte jede Einzelstueckliste je Zeile eine Extraabfrage.
|
||||
join_depth=1,
|
||||
)
|
||||
zubehoer: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="gehoert_zu", lazy="selectin", order_by="Item.uid"
|
||||
)
|
||||
location: Mapped[Location | None] = relationship()
|
||||
shop: Mapped[Shop | None] = relationship()
|
||||
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||
|
||||
@@ -31,6 +31,7 @@ from ..schemas import (
|
||||
ItemOut,
|
||||
ItemRemove,
|
||||
ItemUpdate,
|
||||
ZubehoerZuordnen,
|
||||
)
|
||||
from ..services.items import generate_uid, item_to_out
|
||||
from ..services.warranty import (
|
||||
@@ -56,6 +57,18 @@ def _product_or_404(db: Session, product_id: int) -> Product:
|
||||
return product
|
||||
|
||||
|
||||
def _zubehoer_freigeben(item: Item) -> None:
|
||||
"""Zubehoer vom Geraet loesen, bevor das Geraet verschwindet.
|
||||
|
||||
ON DELETE SET NULL steht zwar am Fremdschluessel, aber SQLite erzwingt
|
||||
Fremdschluessel nicht - in den Tests bliebe sonst eine tote Zuordnung
|
||||
stehen. Dieselbe Vorsicht wie beim Loeschen einer Gruppe.
|
||||
"""
|
||||
for teil in list(item.zubehoer):
|
||||
teil.parent_item_id = None
|
||||
item.parent_item_id = None
|
||||
|
||||
|
||||
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||
item = db.get(Item, item_id)
|
||||
if item is None:
|
||||
@@ -208,6 +221,7 @@ def remove_item(
|
||||
) -> None:
|
||||
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
||||
item = _item_or_404(db, item_id)
|
||||
_zubehoer_freigeben(item)
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=item.product_id,
|
||||
@@ -232,7 +246,83 @@ def delete_item(
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
||||
db.delete(_item_or_404(db, item_id))
|
||||
item = _item_or_404(db, item_id)
|
||||
_zubehoer_freigeben(item)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop)
|
||||
# --------------------------------------------------------------------------
|
||||
def _ringpruefung(db: Session, geraet: Item, teil: Item) -> None:
|
||||
"""Verhindert, dass ein Stueck ueber Umwege sein eigenes Zubehoer wird.
|
||||
|
||||
Mit nur einem Elternteil je Teil ist die Kette kurz, aber A->B->A waere
|
||||
ohne Pruefung moeglich - und danach liefe jedes Hochlaufen der Kette
|
||||
endlos. Deshalb vom Geraet aus nach oben gehen, wie beim Kategoriebaum.
|
||||
"""
|
||||
if geraet.id == teil.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Einzelstück kann nicht sein eigenes Zubehör sein"
|
||||
)
|
||||
gesehen: set[int] = set()
|
||||
lauf = geraet.gehoert_zu
|
||||
while lauf is not None and lauf.id not in gesehen:
|
||||
if lauf.id == teil.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"„{teil.uid}“ ist bereits weiter oben in dieser Zubehör-Kette",
|
||||
)
|
||||
gesehen.add(lauf.id)
|
||||
lauf = lauf.gehoert_zu
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/zubehoer", response_model=ItemOut)
|
||||
def zubehoer_zuordnen(
|
||||
item_id: int,
|
||||
payload: ZubehoerZuordnen,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ItemOut:
|
||||
"""Ein Einzelstück als Zubehör an dieses Gerät hängen.
|
||||
|
||||
Eigener Endpunkt und nicht über ``ItemUpdate``: Das Schema sendet bewusst
|
||||
immer alle Felder mit, auch leere, damit sich Felder loeschen lassen. Eine
|
||||
Beziehung darueber zu pflegen hiesse, dass ein Aufrufer sie still kappt,
|
||||
sobald er das Feld vergisst - genau so gingen schon einmal Kaufpreis und
|
||||
Waehrung verloren.
|
||||
"""
|
||||
geraet = _item_or_404(db, item_id)
|
||||
teil = _item_or_404(db, payload.child_item_id)
|
||||
_ringpruefung(db, geraet, teil)
|
||||
# Ein Teil haengt an genau einem Geraet - ein Wechsel ist deshalb einfach
|
||||
# ein Umhaengen und braucht kein vorheriges Loesen.
|
||||
teil.parent_item_id = geraet.id
|
||||
db.commit()
|
||||
db.refresh(geraet)
|
||||
return item_to_out(geraet)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/items/{item_id}/zubehoer/{child_item_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def zubehoer_loesen(
|
||||
item_id: int,
|
||||
child_item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Zubehör vom Gerät lösen. Das Stück selbst bleibt bestehen."""
|
||||
geraet = _item_or_404(db, item_id)
|
||||
teil = _item_or_404(db, child_item_id)
|
||||
if teil.parent_item_id != geraet.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
f"„{teil.uid}“ gehört nicht zu „{geraet.uid}“",
|
||||
)
|
||||
teil.parent_item_id = None
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,28 @@ def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | No
|
||||
)
|
||||
|
||||
|
||||
def _zubehoer_pruefen(db: Session, geraet: Item, ids: list[int]) -> list[Item]:
|
||||
"""Die benannten Teile holen und pruefen, dass sie zu diesem Geraet gehoeren.
|
||||
|
||||
Ohne die Pruefung koennte ein Aufrufer ueber diesen Weg ein beliebiges
|
||||
fremdes Stueck mitverleihen.
|
||||
"""
|
||||
teile: list[Item] = []
|
||||
for teil_id in dict.fromkeys(ids):
|
||||
teil = db.get(Item, teil_id)
|
||||
if teil is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Zubehörteil nicht gefunden"
|
||||
)
|
||||
if teil.parent_item_id != geraet.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"„{teil.uid}“ ist kein Zubehör von „{geraet.uid}“",
|
||||
)
|
||||
teile.append(teil)
|
||||
return teile
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -81,6 +103,27 @@ def lend_item(
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(loan)
|
||||
|
||||
# Zubehoer geht nur mit, wenn es ausdruecklich benannt wurde - die
|
||||
# Oberflaeche fragt danach. Ein Netzteil, das im Schrank bleibt, darf nicht
|
||||
# als verliehen gefuehrt werden, nur weil es am Geraet haengt.
|
||||
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
|
||||
if offener_verleih(db, teil.id) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"„{teil.uid}“ ist bereits verliehen.",
|
||||
)
|
||||
db.add(
|
||||
ItemLoan(
|
||||
item_id=teil.id,
|
||||
borrower=name,
|
||||
reason=(payload.reason or "").strip() or None,
|
||||
lent_on=lent_on,
|
||||
due_on=payload.due_on,
|
||||
user_id=user.id,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
@@ -103,6 +146,16 @@ def return_item(
|
||||
returned_on = payload.returned_on or date.today()
|
||||
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
||||
loan.returned_on = returned_on
|
||||
|
||||
# Spiegelbild zum Verleihen: Ohne das kaeme das Geraet zurueck und sein
|
||||
# Netzteil bliebe ewig „verliehen“, weil niemand mehr danach sucht.
|
||||
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
|
||||
offen = offener_verleih(db, teil.id)
|
||||
if offen is None:
|
||||
continue # war gar nicht mit draussen – kein Fehler, nur nichts zu tun
|
||||
_pruefe_zeitfolge(offen.lent_on, offen.due_on, returned_on)
|
||||
offen.returned_on = returned_on
|
||||
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
|
||||
@@ -778,6 +778,9 @@ class LoanCreate(BaseModel):
|
||||
lent_on: date | None = None
|
||||
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||||
due_on: date | None = None
|
||||
# Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche
|
||||
# fragt danach; der Server nimmt nie ungefragt etwas mit.
|
||||
mit_zubehoer: list[int] = []
|
||||
|
||||
|
||||
class LoanUpdate(BaseModel):
|
||||
@@ -792,6 +795,9 @@ class LoanUpdate(BaseModel):
|
||||
class LoanReturn(BaseModel):
|
||||
"""Rückgabe buchen. Leeres Datum = heute."""
|
||||
returned_on: date | None = None
|
||||
# Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das
|
||||
# käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen".
|
||||
mit_zubehoer: list[int] = []
|
||||
|
||||
|
||||
class LoanOut(BaseModel):
|
||||
@@ -818,6 +824,30 @@ class LoanOut(BaseModel):
|
||||
location_name: str | None = None
|
||||
|
||||
|
||||
class ItemKurz(BaseModel):
|
||||
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
|
||||
|
||||
Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes
|
||||
Teil wieder sein Geraet nach, endlos.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
uid: str
|
||||
product_id: int
|
||||
product_name: str | None = None
|
||||
product_brand: str | None = None
|
||||
location_name: str | None = None
|
||||
# Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann.
|
||||
verliehen: bool = False
|
||||
|
||||
|
||||
class ZubehoerZuordnen(BaseModel):
|
||||
"""Welches Stueck als Zubehoer an dieses Geraet gehaengt wird."""
|
||||
|
||||
child_item_id: int
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -842,6 +872,10 @@ class ItemOut(BaseModel):
|
||||
# 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
|
||||
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
|
||||
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
|
||||
zubehoer: list[ItemKurz] = []
|
||||
gehoert_zu: ItemKurz | None = None
|
||||
|
||||
|
||||
# ---- Views ----
|
||||
|
||||
@@ -7,7 +7,7 @@ import secrets
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Item, ItemLoan
|
||||
from ..schemas import ItemOut
|
||||
from ..schemas import ItemKurz, ItemOut
|
||||
from .loans import loan_to_out
|
||||
|
||||
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||
@@ -34,8 +34,21 @@ def _offener(item: Item) -> ItemLoan | None:
|
||||
return None
|
||||
|
||||
|
||||
def item_kurz(item: Item) -> ItemKurz:
|
||||
"""Ein verknuepftes Stueck in Kurzform (Zubehoer, Geraet)."""
|
||||
return ItemKurz(
|
||||
id=item.id,
|
||||
uid=item.uid,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product.name if item.product else None,
|
||||
product_brand=item.product.brand if item.product else None,
|
||||
location_name=item.location.name if item.location else None,
|
||||
verliehen=_offener(item) is not None,
|
||||
)
|
||||
|
||||
|
||||
def item_to_out(item: Item) -> ItemOut:
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen, Verleih und Zubehoer."""
|
||||
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
|
||||
@@ -46,4 +59,9 @@ def item_to_out(item: Item) -> ItemOut:
|
||||
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
|
||||
# Beide Richtungen ausdruecklich setzen: model_validate wuerde die Beziehung
|
||||
# zwar finden, aber nur die Spalten fuellen - Artikelname und Lagerort des
|
||||
# verknuepften Stuecks blieben leer.
|
||||
out.zubehoer = [item_kurz(t) for t in item.zubehoer]
|
||||
out.gehoert_zu = item_kurz(item.gehoert_zu) if item.gehoert_zu else None
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Zubehör: ein Einzelstück gehört zu einem anderen (Netzteil → Laptop).
|
||||
|
||||
Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
|
||||
Geprüft werden die Zuordnung selbst, ihre Grenzen (Selbstbezug, Ring) und das
|
||||
Mitverleihen, das die Oberfläche erfragt und der Server nie von sich aus tut.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import Category, CategoryTracking, Item, Product, Role, User
|
||||
from app.routers.items import (
|
||||
create_items,
|
||||
delete_item,
|
||||
get_item,
|
||||
zubehoer_loesen,
|
||||
zubehoer_zuordnen,
|
||||
)
|
||||
from app.routers.loans import lend_item, return_item
|
||||
from app.schemas import ItemCreate, LoanCreate, LoanReturn, ZubehoerZuordnen
|
||||
from app.services.loans import offener_verleih
|
||||
|
||||
|
||||
@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 laptop(db):
|
||||
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name="Laptop", category_id=cat.id, individual=True, package_size=1)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _stueck(db, admin, product) -> Item:
|
||||
out = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0]
|
||||
return db.get(Item, out.id)
|
||||
|
||||
|
||||
def _haenge(db, admin, geraet: Item, teil: Item):
|
||||
return zubehoer_zuordnen(
|
||||
geraet.id, ZubehoerZuordnen(child_item_id=teil.id), db=db, _=admin
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zuordnen und lösen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_geraet_traegt_mehrere_teile(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
tasche = _stueck(db, admin, laptop)
|
||||
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
out = _haenge(db, admin, geraet, tasche)
|
||||
|
||||
assert {t.uid for t in out.zubehoer} == {netzteil.uid, tasche.uid}
|
||||
# Und die Gegenrichtung steht am Teil.
|
||||
assert get_item(netzteil.id, db=db, _=admin).gehoert_zu.uid == geraet.uid
|
||||
|
||||
|
||||
def test_teil_wechselt_das_geraet_ohne_dublette(db, admin, laptop):
|
||||
"""Ein Teil haengt an genau einem Geraet – Umhaengen ist ein Wechsel."""
|
||||
a = _stueck(db, admin, laptop)
|
||||
b = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
|
||||
_haenge(db, admin, a, netzteil)
|
||||
_haenge(db, admin, b, netzteil)
|
||||
|
||||
assert get_item(a.id, db=db, _=admin).zubehoer == []
|
||||
assert [t.uid for t in get_item(b.id, db=db, _=admin).zubehoer] == [netzteil.uid]
|
||||
|
||||
|
||||
def test_loesen_laesst_das_stueck_bestehen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
zubehoer_loesen(geraet.id, netzteil.id, db=db, _=admin)
|
||||
|
||||
assert get_item(geraet.id, db=db, _=admin).zubehoer == []
|
||||
assert db.get(Item, netzteil.id) is not None
|
||||
|
||||
|
||||
def test_loesen_eines_fremden_teils_ist_ein_fehler(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
fremdes = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
zubehoer_loesen(geraet.id, fremdes.id, db=db, _=admin)
|
||||
assert fehler.value.status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Grenzen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_selbstbezug_wird_abgelehnt(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, geraet, geraet)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_ring_wird_abgelehnt(db, admin, laptop):
|
||||
"""A trägt B – dann darf B nicht A tragen, sonst läuft jede Kette endlos."""
|
||||
a = _stueck(db, admin, laptop)
|
||||
b = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, a, b)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, b, a)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_laengerer_ring_wird_abgelehnt(db, admin, laptop):
|
||||
a, b, c = (_stueck(db, admin, laptop) for _ in range(3))
|
||||
_haenge(db, admin, a, b)
|
||||
_haenge(db, admin, b, c)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, c, a)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_geraet_loeschen_gibt_das_teil_frei(db, admin, laptop):
|
||||
"""Das Netzteil ueberlebt den Laptop – es wird wieder eigenstaendig."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
delete_item(geraet.id, db=db, _=admin)
|
||||
|
||||
uebrig = db.get(Item, netzteil.id)
|
||||
assert uebrig is not None
|
||||
assert uebrig.parent_item_id is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verleihen und Zurücknehmen mit Zubehör
|
||||
# --------------------------------------------------------------------------
|
||||
def test_ohne_angabe_geht_nur_das_geraet(db, admin, laptop):
|
||||
"""Der Server nimmt nie ungefragt etwas mit."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
assert offener_verleih(db, geraet.id) is not None
|
||||
assert offener_verleih(db, netzteil.id) is None
|
||||
|
||||
|
||||
def test_mit_zubehoer_verleihen_und_zuruecknehmen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
tasche = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
_haenge(db, admin, geraet, tasche)
|
||||
teile = [netzteil.id, tasche.id]
|
||||
|
||||
lend_item(
|
||||
geraet.id,
|
||||
LoanCreate(borrower="Markus", mit_zubehoer=teile),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert all(offener_verleih(db, i) is not None for i in [geraet.id, *teile])
|
||||
|
||||
return_item(geraet.id, LoanReturn(mit_zubehoer=teile), db=db, _=admin)
|
||||
# Keines darf offen haengenbleiben – sonst sucht es niemand mehr.
|
||||
assert all(offener_verleih(db, i) is None for i in [geraet.id, *teile])
|
||||
|
||||
|
||||
def test_fremdes_stueck_laesst_sich_nicht_mitverleihen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
fremdes = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(
|
||||
geraet.id,
|
||||
LoanCreate(borrower="Markus", mit_zubehoer=[fremdes.id]),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert fehler.value.status_code == 400
|
||||
assert offener_verleih(db, fremdes.id) is None
|
||||
|
||||
|
||||
def test_zuruecknehmen_uebergeht_ein_teil_das_dablieb(db, admin, laptop):
|
||||
"""Wer das Netzteil behalten hat, verleiht es nicht – die Rückgabe darf
|
||||
daran trotzdem nicht scheitern."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
return_item(geraet.id, LoanReturn(mit_zubehoer=[netzteil.id]), db=db, _=admin)
|
||||
|
||||
assert offener_verleih(db, geraet.id) is None
|
||||
@@ -264,6 +264,12 @@ actor APIClient {
|
||||
return try await send(request, as: [Item].self)
|
||||
}
|
||||
|
||||
/// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das
|
||||
/// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen.
|
||||
func item(id: Int) async throws -> Item {
|
||||
try await send(try makeRequest("/items/\(id)"), as: Item.self)
|
||||
}
|
||||
|
||||
/// QR-Auflösung: UID → Einzelstück.
|
||||
func itemByUid(uid: String) async throws -> Item {
|
||||
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
||||
@@ -278,6 +284,21 @@ actor APIClient {
|
||||
|
||||
// MARK: - Verleih von Einzelstuecken
|
||||
|
||||
/// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen
|
||||
/// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung
|
||||
/// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen.
|
||||
func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item {
|
||||
var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST")
|
||||
try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId))
|
||||
return try await send(request, as: Item.self)
|
||||
}
|
||||
|
||||
func zubehoerLoesen(itemId: Int, childItemId: Int) async throws {
|
||||
try await sendNoContent(
|
||||
try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE")
|
||||
)
|
||||
}
|
||||
|
||||
/// 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")
|
||||
|
||||
@@ -28,6 +28,8 @@ struct ItemListView: View {
|
||||
}
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
|
||||
@State private var zurueckFuer: Item?
|
||||
|
||||
private struct TreeEntry: Identifiable {
|
||||
let item: CategoryItem
|
||||
@@ -118,6 +120,23 @@ struct ItemListView: View {
|
||||
NavigationLink { ItemEditView(item: it) } label: {
|
||||
ItemRow(item: it, locations: locations)
|
||||
}
|
||||
// Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber
|
||||
// das Bearbeiten-Formular des Stuecks.
|
||||
.swipeActions(edge: .leading) {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||
@@ -128,11 +147,39 @@ struct ItemListView: View {
|
||||
.refreshable { await load() }
|
||||
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
||||
.onAppear { Task { await load() } }
|
||||
.confirmationDialog(
|
||||
zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "",
|
||||
isPresented: Binding(get: { zurueckFuer != nil },
|
||||
set: { if !$0 { zurueckFuer = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { zurueckFuer = nil }
|
||||
} message: {
|
||||
if let it = zurueckFuer, let l = it.loan {
|
||||
let mit = it.teile.filter(\.verliehen)
|
||||
Text("Von \(l.borrower)." + (mit.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mit.map(\.uid).joined(separator: ", ") + "."))
|
||||
}
|
||||
}
|
||||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { Text(error ?? "") }
|
||||
}
|
||||
|
||||
private func nimmZurueck() async {
|
||||
guard let it = zurueckFuer else { return }
|
||||
zurueckFuer = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: it.id,
|
||||
LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id))
|
||||
)
|
||||
await load()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private var filterMenu: some View {
|
||||
Menu {
|
||||
Menu("Lagerort") {
|
||||
@@ -163,9 +210,11 @@ struct ItemListView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: hatFilter
|
||||
Label(hatFilter ? verleihFilter.label : "Filter",
|
||||
systemImage: hatFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+127
-3
@@ -223,6 +223,13 @@ struct ItemEditView: View {
|
||||
@State private var previewURL: URL?
|
||||
@State private var locScanShown = false
|
||||
@State private var nfcShown = false
|
||||
@State private var zurueckFrage = false
|
||||
// Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen
|
||||
// ist das uebergebene Stueck veraltet, die Ansicht aber noch offen.
|
||||
@State private var teile: [ItemKurz] = []
|
||||
@State private var gehoertZu: ItemKurz?
|
||||
@State private var zubehoerWaehlen = false
|
||||
@State private var mitVerleihen: Set<Int> = []
|
||||
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||||
@State private var loan: Loan?
|
||||
@State private var loanHistory: [Loan] = []
|
||||
@@ -279,7 +286,7 @@ struct ItemEditView: View {
|
||||
LabeledContent("Zurück bis",
|
||||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
||||
}
|
||||
Button { Task { await nimmZurueck() } } label: {
|
||||
Button { zurueckFrage = true } label: {
|
||||
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.disabled(busyLoan)
|
||||
@@ -296,6 +303,40 @@ struct ItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an
|
||||
// genau einem Geraet, ein Geraet traegt beliebig viele Teile.
|
||||
Section("Zubehör") {
|
||||
if let g = gehoertZu {
|
||||
LabeledContent("Gehört zu") {
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text(g.productName ?? "Einzelstück")
|
||||
Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(teile) { t in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück")
|
||||
Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if t.verliehen {
|
||||
Text("verliehen").font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button("Lösen", role: .destructive) { Task { await loese(t) } }
|
||||
}
|
||||
}
|
||||
if teile.isEmpty && gehoertZu == nil {
|
||||
Text("Kein Zubehör.").foregroundStyle(.secondary)
|
||||
}
|
||||
Button { zubehoerWaehlen = true } label: {
|
||||
Label("Zubehör hinzufügen", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
@@ -400,6 +441,25 @@ struct ItemEditView: View {
|
||||
.sheet(isPresented: $nfcShown) {
|
||||
NavigationStack { NFCSchreibenView(item: item) }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"„\(item.uid)“ zurücknehmen?",
|
||||
isPresented: $zurueckFrage, titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
let wer = loan.map { "Von \($0.borrower)." } ?? ""
|
||||
let mit = mitZurueck.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mitZurueck.map(\.uid).joined(separator: ", ") + "."
|
||||
Text(wer + mit)
|
||||
}
|
||||
.sheet(isPresented: $zubehoerWaehlen) {
|
||||
// Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts.
|
||||
ItemPickerSheet { gewaehlt in
|
||||
Task { await haengeAn(gewaehlt) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showRemove) {
|
||||
NavigationStack { removeSheet }
|
||||
}
|
||||
@@ -445,6 +505,29 @@ struct ItemEditView: View {
|
||||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
||||
}
|
||||
// Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig
|
||||
// verliehene Teile stehen nicht zur Wahl - die sind gar nicht da.
|
||||
if !teile.filter({ !$0.verliehen }).isEmpty {
|
||||
Section("Zubehör mitgeben?") {
|
||||
ForEach(teile.filter { !$0.verliehen }) { t in
|
||||
Button {
|
||||
if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) }
|
||||
else { mitVerleihen.insert(t.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: mitVerleihen.contains(t.id)
|
||||
? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(Color.marke)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück").foregroundStyle(.primary)
|
||||
Text(t.uid).font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||
Section {
|
||||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||||
@@ -481,6 +564,9 @@ struct ItemEditView: View {
|
||||
hasDue = false
|
||||
due = Date()
|
||||
error = nil
|
||||
// Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt
|
||||
// das Netzteil meist mit. Abwaehlen geht mit einem Tipp.
|
||||
mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id))
|
||||
showLend = true
|
||||
}
|
||||
|
||||
@@ -490,19 +576,28 @@ struct ItemEditView: View {
|
||||
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))
|
||||
dueOn: hasDue ? dateToString(due) : nil,
|
||||
mitZubehoer: Array(mitVerleihen)))
|
||||
showLend = false
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
/// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt.
|
||||
private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } }
|
||||
|
||||
private func nimmZurueck() async {
|
||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(id: item.id)
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: item.id,
|
||||
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
|
||||
)
|
||||
loan = nil
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
@@ -527,9 +622,38 @@ struct ItemEditView: View {
|
||||
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
|
||||
teile = item.teile
|
||||
gehoertZu = item.gehoertZu
|
||||
await ladeVerleihe()
|
||||
}
|
||||
|
||||
private func haengeAn(_ gewaehlt: Item) async {
|
||||
error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.zubehoerZuordnen(
|
||||
itemId: item.id, childItemId: gewaehlt.id
|
||||
)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func loese(_ teil: ItemKurz) async {
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func ladeZubehoer() async {
|
||||
guard let frisch = try? await APIClient.shared.item(id: item.id) else { return }
|
||||
teile = frisch.teile
|
||||
gehoertZu = frisch.gehoertZu
|
||||
loan = frisch.loan
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
busy = true; defer { busy = false }; error = nil
|
||||
let cents = ItemEditView.parseCents(priceText)
|
||||
|
||||
@@ -1094,18 +1094,55 @@ struct LoanCreateRequest: Encodable {
|
||||
var reason: String? = nil
|
||||
var lentOn: String? = nil
|
||||
var dueOn: String? = nil
|
||||
/// Zubehoerteile, die mitgehen. Leer = nur das Geraet selbst; der Server
|
||||
/// nimmt nie ungefragt etwas mit.
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case borrower, reason
|
||||
case lentOn = "lent_on"
|
||||
case dueOn = "due_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoanReturnRequest: Encodable {
|
||||
var returnedOn: String? = nil
|
||||
/// Spiegelbild zu LoanCreateRequest: Ohne das kaeme das Geraet zurueck und
|
||||
/// sein Netzteil bliebe ewig „verliehen".
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case returnedOn = "returned_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct ZubehoerZuordnenRequest: Encodable {
|
||||
let childItemId: Int
|
||||
enum CodingKeys: String, CodingKey { case childItemId = "child_item_id" }
|
||||
}
|
||||
|
||||
/// Ein verknuepftes Einzelstueck in Kurzform (Zubehoer bzw. das Geraet dazu).
|
||||
///
|
||||
/// Bewusst nicht `Item`: sonst zoege das Geraet seine Teile und jedes Teil
|
||||
/// wieder sein Geraet nach, endlos.
|
||||
struct ItemKurz: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let uid: String
|
||||
let productId: Int
|
||||
let productName: String?
|
||||
let productBrand: String?
|
||||
let locationName: String?
|
||||
let verliehen: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, verliehen
|
||||
case productId = "product_id"
|
||||
case productName = "product_name"
|
||||
case productBrand = "product_brand"
|
||||
case locationName = "location_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct Item: Codable, Identifiable, Hashable {
|
||||
@@ -1130,11 +1167,24 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
|
||||
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
||||
let loan: Loan?
|
||||
/// Zubehoer dieses Geraets und, umgekehrt, das Geraet, zu dem dieses Stueck
|
||||
/// gehoert.
|
||||
///
|
||||
/// Beide optional, und das ist keine Bequemlichkeit: Die App spricht mit
|
||||
/// mehreren Serverprofilen, und ein aelterer Server schickt die Felder gar
|
||||
/// nicht. Ein nicht-optionales Feld mit Vorgabewert reicht dafuer NICHT -
|
||||
/// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen.
|
||||
var zubehoer: [ItemKurz]?
|
||||
var gehoertZu: ItemKurz?
|
||||
|
||||
/// Zubehoer ohne Optional-Gehampel an der Aufrufstelle.
|
||||
var teile: [ItemKurz] { zubehoer ?? [] }
|
||||
|
||||
var istVerliehen: Bool { loan != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, note, currency, documents, loan
|
||||
case id, uid, note, currency, documents, loan, zubehoer
|
||||
case gehoertZu = "gehoert_zu"
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
|
||||
@@ -27,6 +27,7 @@ import Units from "./pages/Units";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
import History from "./pages/History";
|
||||
import Verliehen from "./pages/Verliehen";
|
||||
import Transfer from "./pages/Transfer";
|
||||
import Settings from "./pages/Settings";
|
||||
import Konto from "./pages/Konto";
|
||||
@@ -87,6 +88,7 @@ function Sidebar() {
|
||||
<NavItem to="/chargen" icon="box" label="Chargen" />
|
||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||||
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -190,6 +192,9 @@ export default function App() {
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||||
und Auslagern, keine Stammdatenänderung. */}
|
||||
<Route path="/verliehen" element={<Protected><Verliehen /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
||||
|
||||
@@ -232,6 +232,12 @@ export const api = {
|
||||
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`),
|
||||
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
|
||||
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
|
||||
zubehoerZuordnen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }),
|
||||
zubehoerLoesen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }),
|
||||
// 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 } = {}) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api, authorizedObjectUrl } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "./Icon";
|
||||
import Zubehoer from "./Zubehoer";
|
||||
import { REASONS } from "./ObjektBestand";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { itemDeepLink } from "../qr";
|
||||
@@ -362,6 +363,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
<VerleihHistorie item={it} onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
<Zubehoer item={it} isAdmin={isAdmin}
|
||||
onChanged={nachAktion} onError={onError} />
|
||||
<label style={{ margin: 0 }}>Kaufpreis
|
||||
<div className="field-inline">
|
||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useToast } from "../toast";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { daysUntil, relativeExpiry } from "../units";
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,14 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(LEER);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Zubehör geht nur mit, wenn es angehakt ist. Vorausgewählt sind die Teile,
|
||||
// die gerade im Haus sind – ein schon anderweitig verliehenes kann nicht mit.
|
||||
const verfuegbaresZubehoer = (item.zubehoer || []).filter((t) => !t.verliehen);
|
||||
const [mit, setMit] = useState(() => verfuegbaresZubehoer.map((t) => t.id));
|
||||
|
||||
function umschalten(id) {
|
||||
setMit((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
@@ -68,6 +77,7 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
borrower: form.borrower.trim(),
|
||||
reason: form.reason.trim() || null,
|
||||
due_on: form.due_on || null,
|
||||
mit_zubehoer: mit,
|
||||
});
|
||||
setForm(LEER);
|
||||
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
|
||||
@@ -100,6 +110,19 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
{verfuegbaresZubehoer.length > 0 && (
|
||||
<fieldset className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<legend className="small strong">Zubehör mitgeben?</legend>
|
||||
{verfuegbaresZubehoer.map((t) => (
|
||||
<label key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<input type="checkbox" checked={mit.includes(t.id)}
|
||||
onChange={() => umschalten(t.id)} />
|
||||
<span>{t.product_name || "Einzelstück"}</span>
|
||||
<span className="muted small">{t.uid}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<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.
|
||||
@@ -117,29 +140,45 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
*/
|
||||
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function zurueck() {
|
||||
// Mitverliehenes Zubehör kommt zusammen mit dem Gerät zurück – sonst bliebe
|
||||
// das Netzteil ewig „verliehen“, weil niemand mehr danach sucht.
|
||||
const teile = (item.zubehoer || []).filter((t) => t.verliehen);
|
||||
const ok = await confirm({
|
||||
title: `„${item.uid}“ zurücknehmen?`,
|
||||
message: teile.length
|
||||
? `Von ${item.loan.borrower}. Das mitverliehene Zubehör kommt mit zurück: `
|
||||
+ teile.map((t) => t.uid).join(", ") + "."
|
||||
: `Von ${item.loan.borrower}.`,
|
||||
confirmLabel: "Zurücknehmen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.returnItem(item.id);
|
||||
await api.returnItem(item.id, { mit_zubehoer: teile.map((t) => t.id) });
|
||||
toast(`${item.uid} ist zurück.`);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Bewusst mit Text statt als nacktes Symbol: In einer breiten Tabelle war der
|
||||
// Knopf sonst ein unbeschriftetes Bildchen ganz rechts, und das title-Attribut
|
||||
// erreicht auf einem Touchgerät niemanden.
|
||||
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 type="button" className="btn sm" disabled={busy} onClick={zurueck}
|
||||
aria-label={`„${item.uid}“ von ${item.loan.borrower} zurücknehmen`}>
|
||||
<Icon name="checkin" size={size} />{busy ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className="btn-icon" title="Verleihen"
|
||||
onClick={() => onLend(item)}>
|
||||
<Icon name="loan" size={size} />
|
||||
<button type="button" className="btn sm" onClick={() => onLend(item)}
|
||||
aria-label={`„${item.uid}“ verleihen`}>
|
||||
<Icon name="loan" size={size} />Verleihen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useConfirm } from "../confirm";
|
||||
|
||||
/**
|
||||
* Zubehör eines Einzelstücks: das Netzteil, das zum Laptop gehört.
|
||||
*
|
||||
* Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
|
||||
* Die Zuordnung läuft über eigene Endpunkte und nicht über `updateItem` – dessen
|
||||
* Schema sendet immer alle Felder mit, ein vergessenes Feld würde die Beziehung
|
||||
* still kappen.
|
||||
*/
|
||||
export default function Zubehoer({ item, isAdmin, onChanged, onError }) {
|
||||
const confirm = useConfirm();
|
||||
const [alle, setAlle] = useState(null);
|
||||
const [waehlen, setWaehlen] = useState(false);
|
||||
const [suche, setSuche] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waehlen || alle != null) return;
|
||||
api.listAllItems().then(setAlle).catch((err) => onError(err.message));
|
||||
}, [waehlen, alle, onError]);
|
||||
|
||||
async function zuordnen(teil) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.zubehoerZuordnen(item.id, teil.id);
|
||||
setWaehlen(false);
|
||||
setSuche("");
|
||||
setAlle(null); // nach dem Umhängen stimmen die Zuordnungen nicht mehr
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loesen(teil) {
|
||||
const ok = await confirm({
|
||||
title: `„${teil.uid}“ vom Gerät lösen?`,
|
||||
message: "Das Einzelstück bleibt bestehen, es gehört danach nur zu nichts mehr.",
|
||||
confirmLabel: "Lösen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.zubehoerLoesen(item.id, teil.id);
|
||||
setAlle(null);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const teile = item.zubehoer || [];
|
||||
// Weder sich selbst noch die eigenen Teile erneut anbieten. Ringe über mehrere
|
||||
// Stufen weist der Server ab – hier geht es nur darum, Unsinn gar nicht erst
|
||||
// zur Auswahl zu stellen.
|
||||
const kandidaten = (alle || []).filter((k) => {
|
||||
if (k.id === item.id) return false;
|
||||
if (k.gehoert_zu && k.gehoert_zu.id === item.id) return false;
|
||||
const q = suche.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [k.uid, k.product_name, k.product_brand, k.note]
|
||||
.filter(Boolean).join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="muted small">Zubehör</span>
|
||||
|
||||
{item.gehoert_zu && (
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-1)" }}>
|
||||
Gehört zu <a href={`/i/${item.gehoert_zu.uid}`}>
|
||||
{item.gehoert_zu.product_name || "Einzelstück"} ({item.gehoert_zu.uid})
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{teile.length === 0 && !item.gehoert_zu && (
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-1)" }}>Kein Zubehör.</p>
|
||||
)}
|
||||
|
||||
{teile.length > 0 && (
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{teile.map((t) => (
|
||||
<li key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<span>{t.product_name || "Einzelstück"}</span>
|
||||
<span className="muted small">{t.uid}</span>
|
||||
{t.verliehen && <span className="badge warn">verliehen</span>}
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn-icon danger" disabled={busy}
|
||||
onClick={() => loesen(t)} aria-label={`„${t.uid}“ vom Gerät lösen`}
|
||||
title="Vom Gerät lösen">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isAdmin && !waehlen && (
|
||||
<button type="button" className="btn sm ghost" style={{ marginTop: "var(--sp-2)" }}
|
||||
onClick={() => setWaehlen(true)}>
|
||||
<Icon name="plus" size={14} />Zubehör hinzufügen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && waehlen && (
|
||||
<div className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<div className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<input autoFocus value={suche} placeholder="UID oder Artikelname"
|
||||
onChange={(e) => setSuche(e.target.value)} />
|
||||
<button type="button" className="btn-icon" title="Abbrechen"
|
||||
onClick={() => { setWaehlen(false); setSuche(""); }}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{alle == null && <span className="muted small">Wird geladen…</span>}
|
||||
{alle != null && kandidaten.length === 0 && (
|
||||
<span className="muted small">Nichts gefunden.</span>
|
||||
)}
|
||||
<ul className="clean-list">
|
||||
{kandidaten.slice(0, 25).map((k) => (
|
||||
<li key={k.id}>
|
||||
<button type="button" className="btn sm ghost" disabled={busy}
|
||||
onClick={() => zuordnen(k)}>
|
||||
{k.product_name || "Einzelstück"} <span className="muted">{k.uid}</span>
|
||||
{k.gehoert_zu && (
|
||||
<span className="muted small"> · hängt an {k.gehoert_zu.uid}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{alle != null && kandidaten.length > 25 && (
|
||||
<span className="muted small">
|
||||
{kandidaten.length - 25} weitere – bitte genauer suchen.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +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",
|
||||
// Überschrift und Breite sind Absicht: Vorher stand hier ein unbeschriftetes
|
||||
// 16-px-Symbol in einer 52 px schmalen Spalte ohne Titel, ganz rechts in
|
||||
// einer breiten Tabelle – es war schlicht nicht zu finden.
|
||||
{ key: "actions", header: "Verleih", label: "Verleihen", fixed: true, width: 150, align: "num",
|
||||
render: (it) => (
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={load} onError={setError} />
|
||||
) },
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { FristBadge, verleihRowClass } from "../components/Verleih";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/**
|
||||
* „Was ist gerade draußen?" – alle offenen Verleihe an einem Ort, mit Rückgabe
|
||||
* direkt aus der Liste.
|
||||
*
|
||||
* Bis hierher gab es diese Frage nur über zwei Dashboard-Karten, und die sind
|
||||
* nicht im Standard-Layout (routers/dashboard.py BUILTIN_LAYOUT) – wer sie nicht
|
||||
* selbst hinzufügte, hatte keine Übersicht. Die Einzelstück-Liste beantwortet
|
||||
* sie auch, aber erst nach Filtern über eine Spalte.
|
||||
*/
|
||||
export default function Verliehen() {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [zeilen, setZeilen] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
try {
|
||||
setZeilen(await api.loans({ openOnly: true }));
|
||||
setError(null);
|
||||
} catch (err) { setError(err.message); setZeilen([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { laden(); }, [laden]);
|
||||
|
||||
async function zurueck(loan) {
|
||||
const ok = await confirm({
|
||||
title: `„${loan.item_uid}“ zurücknehmen?`,
|
||||
message: `Von ${loan.borrower}.`,
|
||||
confirmLabel: "Zurücknehmen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusyId(loan.id);
|
||||
try {
|
||||
// Das Zubehör hängt am Einzelstück, nicht am Verleih – diese Liste kennt
|
||||
// es nicht. Wer ein Gerät samt Teilen zurücknimmt, tut das über das
|
||||
// Stück selbst (/i/<uid>), wo beides zusammen angeboten wird.
|
||||
await api.returnItem(loan.item_id);
|
||||
toast(`${loan.item_uid} ist zurück.`);
|
||||
await laden();
|
||||
} catch (err) { setError(err.message); } finally { setBusyId(null); }
|
||||
}
|
||||
|
||||
const ueberfaellig = (zeilen || []).filter((l) => l.days_overdue).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Verliehen</h1>
|
||||
{zeilen && zeilen.length > 0 && (
|
||||
<span className="muted">
|
||||
{zeilen.length} {zeilen.length === 1 ? "Stück" : "Stücke"} draußen
|
||||
{ueberfaellig > 0 && `, ${ueberfaellig} überfällig`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{zeilen == null && <div className="muted">Lädt…</div>}
|
||||
|
||||
{zeilen && zeilen.length === 0 && !error && (
|
||||
<div className="card center" style={{ padding: "var(--sp-5)" }}>
|
||||
<Icon name="loan" size={28} />
|
||||
<p className="muted">Nichts verliehen – alles im Haus.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zeilen && zeilen.length > 0 && (
|
||||
<div className="card table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Einzelstück</th>
|
||||
<th>Artikel</th>
|
||||
<th>Verliehen an</th>
|
||||
<th>Seit</th>
|
||||
<th>Zurück bis</th>
|
||||
<th className="num">Verleih</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((l) => (
|
||||
<tr key={l.id} className={verleihRowClass(l)}>
|
||||
<td data-label="Einzelstück">
|
||||
<Link to={`/i/${l.item_uid}`}>{l.item_uid}</Link>
|
||||
</td>
|
||||
<td data-label="Artikel">
|
||||
{l.product_id
|
||||
? <Link to={`/products/${l.product_id}`}>{l.product_name || "Artikel"}</Link>
|
||||
: <span className="muted">–</span>}
|
||||
{l.product_brand && <span className="muted small"> · {l.product_brand}</span>}
|
||||
</td>
|
||||
<td data-label="Verliehen an"><span className="strong">{l.borrower}</span></td>
|
||||
<td data-label="Seit" className="muted">{l.lent_on}</td>
|
||||
<td data-label="Zurück bis">
|
||||
{l.due_on ? <FristBadge loan={l} /> : <span className="muted">ohne Frist</span>}
|
||||
</td>
|
||||
<td data-label="Verleih" className="num">
|
||||
<button type="button" className="btn sm" disabled={busyId === l.id}
|
||||
onClick={() => zurueck(l)}
|
||||
aria-label={`„${l.item_uid}“ von ${l.borrower} zurücknehmen`}>
|
||||
<Icon name="checkin" size={16} />
|
||||
{busyId === l.id ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user