feat(assets): ASSET-011 externer Verleih mit Rückgabe-Tracking
CI / backend-tests (push) Successful in 2m59s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 16:16:14 +02:00
1 parent 63ac9ecf2c
commit 1ad693073e
12 files changed
+666 -6

No files matched your search

+9 -5
View File
@@ -419,11 +419,15 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
(voraussichtliche_rueckgabe_am in der Vergangenheit, noch nicht
zurückgenommen) ist erkennbar.
- **Tests:** CRUD-Test, Doppel-Verleih-Ablehnung, Überfälligkeits-Test.
- **DoD:** offen.
- **Referenz (HiOrg-Server-Vergleich 2026-09-10):** HiOrg-Server unterstützt
Materialverleih über die eigene Organisation hinaus mit Rückgabe-Tracking —
MABEAs ASSET-010 deckt nur die interne Zuordnung ab, keine echte Lücke bei
Bedarf, aber bisher nicht vorgesehen.
- **DoD:** ✅ umgesetzt (2026-09-10). `externe_stelle`/`verleih`
(Migration 0048, partieller Unique-Index erzwingt max. eine offene Zeile je
Objekt), `GET/POST /externe-stellen`, `GET/POST /verleihe`,
`POST /objekte/{id}/verleih/rueckgabe`, Admin-Tab „Verleih". **Bewusst
nicht verknüpft mit MAINT-003 (Wartungsauftrag an externer Werkstatt):**
konzeptionell ähnlich ("Objekt ist physisch woanders"), aber unterschiedlich
genug (Reparatur vs. Ausleihe an Dritte) und beide können parallel offen
sein, ohne dass das eine das andere sperrt — keine gegenseitige Prüfung.
`backend/tests/test_verleih.py`.
## ASSET-012 — Ressourcen-Buchungskalender (P2, neu, Referenz: HiOrg-Server)
+52
View File
@@ -0,0 +1,52 @@
"""ASSET-011: externer Verleih mit Rückgabe-Tracking.
Revision ID: 0048_verleih
Revises: 0047_material_set
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0048_verleih"
down_revision: Union[str, None] = "0047_material_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE externe_stelle (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
kontakt TEXT,
adresse TEXT
)
"""
)
op.execute(
"""
CREATE TABLE verleih (
id UUID PRIMARY KEY,
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
externe_stelle_id INTEGER NOT NULL REFERENCES externe_stelle(id),
verliehen_am DATE NOT NULL,
voraussichtliche_rueckgabe_am DATE,
tatsaechliche_rueckgabe_am DATE,
bemerkung TEXT,
verliehen_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
erstellt_am TIMESTAMPTZ NOT NULL
)
"""
)
op.execute(
"CREATE UNIQUE INDEX ux_verleih_offen_je_objekt ON verleih (objekt_id) "
"WHERE tatsaechliche_rueckgabe_am IS NULL"
)
def downgrade() -> None:
op.execute("DROP TABLE verleih")
op.execute("DROP TABLE externe_stelle")
+2
View File
@@ -30,6 +30,7 @@ from app.api.v1.endpoints import (
stammdaten,
suche,
tankbuch,
verleih,
vorlagen,
wartung,
zustaendigkeit,
@@ -71,6 +72,7 @@ api_router.include_router(ausgabe.router, tags=["ausgabe"])
api_router.include_router(wartung.router, tags=["wartung"])
api_router.include_router(suche.router, tags=["suche"])
api_router.include_router(tankbuch.router, tags=["tankbuch"])
api_router.include_router(verleih.router, tags=["verleih"])
api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"])
api_router.include_router(readiness_regel.router, tags=["readiness_regel"])
api_router.include_router(einsatz.router, tags=["einsatz"])
+99
View File
@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import Benutzer, RolleTyp
from app.models.objekt import Objekt
from app.models.verleih import ExterneStelle, Verleih
from app.schemas.verleih import ExterneStelleCreate, ExterneStelleRead, VerleihCreate, VerleihRead
from app.services.verleih import (
KeinOffenerVerleihError,
ObjektBereitsVerliehenError,
erstelle_externe_stelle,
ist_ueberfaellig,
liste_externe_stellen,
liste_verleihe,
verleihen,
zurueckgeben,
)
router = APIRouter()
_materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher)
def _verleih_read(v: Verleih) -> VerleihRead:
return VerleihRead(
id=v.id,
objekt_id=v.objekt_id,
externe_stelle_id=v.externe_stelle_id,
verliehen_am=v.verliehen_am,
voraussichtliche_rueckgabe_am=v.voraussichtliche_rueckgabe_am,
tatsaechliche_rueckgabe_am=v.tatsaechliche_rueckgabe_am,
bemerkung=v.bemerkung,
verliehen_von_benutzer_id=v.verliehen_von_benutzer_id,
erstellt_am=v.erstellt_am,
ueberfaellig=ist_ueberfaellig(v),
)
@router.get("/externe-stellen", response_model=list[ExterneStelleRead])
async def hole_externe_stellen(
db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> list[ExterneStelle]:
return await liste_externe_stellen(db)
@router.post("/externe-stellen", response_model=ExterneStelleRead, status_code=status.HTTP_201_CREATED)
async def erstelle_externe_stelle_endpunkt(
payload: ExterneStelleCreate, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> ExterneStelle:
return await erstelle_externe_stelle(db, name=payload.name, kontakt=payload.kontakt, adresse=payload.adresse)
@router.get("/verleihe", response_model=list[VerleihRead])
async def hole_verleihe(
offen: bool = False, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> list[VerleihRead]:
return [_verleih_read(v) for v in await liste_verleihe(db, nur_offene=offen)]
@router.post("/verleihe", response_model=VerleihRead, status_code=status.HTTP_201_CREATED)
async def erstelle_verleih(
payload: VerleihCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_materialverantwortliche),
) -> VerleihRead:
if await db.get(Objekt, payload.objekt_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
if await db.get(ExterneStelle, payload.externe_stelle_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Externe Stelle nicht gefunden")
try:
verleih = await verleihen(
db,
objekt_id=payload.objekt_id,
externe_stelle_id=payload.externe_stelle_id,
verliehen_am=payload.verliehen_am,
voraussichtliche_rueckgabe_am=payload.voraussichtliche_rueckgabe_am,
bemerkung=payload.bemerkung,
benutzer_id=current_user.id,
)
except ObjektBereitsVerliehenError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Objekt ist bereits verliehen"
) from exc
return _verleih_read(verleih)
@router.post("/objekte/{objekt_id}/verleih/rueckgabe", response_model=VerleihRead)
async def verleih_rueckgabe(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> VerleihRead:
try:
verleih = await zurueckgeben(db, objekt_id)
except KeinOffenerVerleihError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Objekt ist aktuell nicht verliehen"
) from exc
return _verleih_read(verleih)
+3
View File
@@ -31,6 +31,7 @@ from app.models.personal import (
)
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
from app.models.tankbuch import TankbuchEintrag
from app.models.verleih import ExterneStelle, Verleih
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.models.wartung import (
@@ -110,6 +111,8 @@ __all__ = [
"Objekttyp",
"Standort",
"TankbuchEintrag",
"ExterneStelle",
"Verleih",
"Beladungsvorlage",
"VorlageStatus",
"Vorlagenposition",
+38
View File
@@ -0,0 +1,38 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ExterneStelle(Base):
"""ASSET-011: externe Organisation/Stelle OHNE eigenen MABEA-Zugang -
bewusst getrennt von `Person` (PERS-001, i.d.R. eigenes Personal mit
optionalem Benutzerkonto)."""
__tablename__ = "externe_stelle"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
kontakt: Mapped[str | None] = mapped_column(String)
adresse: Mapped[str | None] = mapped_column(Text)
class Verleih(Base):
"""ASSET-011: "offene Zeile"-Muster wie geraet_instanz_custody (ASSET-010) -
tatsaechliche_rueckgabe_am IS NULL = aktuell verliehen."""
__tablename__ = "verleih"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
externe_stelle_id: Mapped[int] = mapped_column(ForeignKey("externe_stelle.id"), nullable=False)
verliehen_am: Mapped[date] = mapped_column(Date, nullable=False)
voraussichtliche_rueckgabe_am: Mapped[date | None] = mapped_column(Date)
tatsaechliche_rueckgabe_am: Mapped[date | None] = mapped_column(Date)
bemerkung: Mapped[str | None] = mapped_column(Text)
verliehen_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class ExterneStelleCreate(BaseModel):
name: str
kontakt: str | None = None
adresse: str | None = None
class ExterneStelleRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
kontakt: str | None
adresse: str | None
class VerleihCreate(BaseModel):
objekt_id: int
externe_stelle_id: int
verliehen_am: date
voraussichtliche_rueckgabe_am: date | None = None
bemerkung: str | None = None
class VerleihRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
objekt_id: int
externe_stelle_id: int
verliehen_am: date
voraussichtliche_rueckgabe_am: date | None
tatsaechliche_rueckgabe_am: date | None
bemerkung: str | None
verliehen_von_benutzer_id: int
erstellt_am: datetime
ueberfaellig: bool = False
+94
View File
@@ -0,0 +1,94 @@
from datetime import date, datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.verleih import ExterneStelle, Verleih
class ObjektBereitsVerliehenError(Exception):
pass
class KeinOffenerVerleihError(Exception):
pass
async def erstelle_externe_stelle(
db: AsyncSession, *, name: str, kontakt: str | None, adresse: str | None
) -> ExterneStelle:
stelle = ExterneStelle(name=name, kontakt=kontakt, adresse=adresse)
db.add(stelle)
await db.flush()
return stelle
async def liste_externe_stellen(db: AsyncSession) -> list[ExterneStelle]:
result = await db.execute(select(ExterneStelle))
return list(result.scalars().all())
async def _offener_verleih(db: AsyncSession, objekt_id: int) -> Verleih | None:
result = await db.execute(
select(Verleih).where(Verleih.objekt_id == objekt_id, Verleih.tatsaechliche_rueckgabe_am.is_(None))
)
return result.scalar_one_or_none()
async def verleihen(
db: AsyncSession,
*,
objekt_id: int,
externe_stelle_id: int,
verliehen_am: date,
voraussichtliche_rueckgabe_am: date | None,
bemerkung: str | None,
benutzer_id: int,
) -> Verleih:
if await _offener_verleih(db, objekt_id) is not None:
raise ObjektBereitsVerliehenError()
verleih = Verleih(
objekt_id=objekt_id,
externe_stelle_id=externe_stelle_id,
verliehen_am=verliehen_am,
voraussichtliche_rueckgabe_am=voraussichtliche_rueckgabe_am,
bemerkung=bemerkung,
verliehen_von_benutzer_id=benutzer_id,
erstellt_am=datetime.now(timezone.utc),
)
db.add(verleih)
try:
await db.flush()
except IntegrityError as exc:
raise ObjektBereitsVerliehenError() from exc
await db.commit()
await db.refresh(verleih)
return verleih
async def zurueckgeben(db: AsyncSession, objekt_id: int) -> Verleih:
verleih = await _offener_verleih(db, objekt_id)
if verleih is None:
raise KeinOffenerVerleihError()
verleih.tatsaechliche_rueckgabe_am = date.today()
await db.commit()
await db.refresh(verleih)
return verleih
async def liste_verleihe(db: AsyncSession, *, nur_offene: bool = False) -> list[Verleih]:
query = select(Verleih)
if nur_offene:
query = query.where(Verleih.tatsaechliche_rueckgabe_am.is_(None))
result = await db.execute(query.order_by(Verleih.verliehen_am.desc()))
return list(result.scalars().all())
def ist_ueberfaellig(verleih: Verleih) -> bool:
if verleih.tatsaechliche_rueckgabe_am is not None:
return False
if verleih.voraussichtliche_rueckgabe_am is None:
return False
return verleih.voraussichtliche_rueckgabe_am < date.today()
+82
View File
@@ -0,0 +1,82 @@
from datetime import date, timedelta
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_verleih_und_rueckgabe(client, db_session, admin_user, standort_factory, objekt_factory):
standort = await standort_factory("Wache-Verleih")
objekt = await objekt_factory(name="Notstromaggregat", code="VERLEIH-1", standort=standort)
token = await login(client, "admin1")
stelle = await client.post(
"/api/v1/externe-stellen",
json={"name": "Nachbar-Ortsverein", "kontakt": "kontakt@example.org"},
headers=auth_header(token),
)
assert stelle.status_code == 201
stelle_id = stelle.json()["id"]
verliehen = await client.post(
"/api/v1/verleihe",
json={
"objekt_id": objekt.id,
"externe_stelle_id": stelle_id,
"verliehen_am": date.today().isoformat(),
"voraussichtliche_rueckgabe_am": (date.today() + timedelta(days=7)).isoformat(),
},
headers=auth_header(token),
)
assert verliehen.status_code == 201
assert verliehen.json()["ueberfaellig"] is False
doppelt = await client.post(
"/api/v1/verleihe",
json={"objekt_id": objekt.id, "externe_stelle_id": stelle_id, "verliehen_am": date.today().isoformat()},
headers=auth_header(token),
)
assert doppelt.status_code == 409
rueckgabe = await client.post(f"/api/v1/objekte/{objekt.id}/verleih/rueckgabe", headers=auth_header(token))
assert rueckgabe.status_code == 200
assert rueckgabe.json()["tatsaechliche_rueckgabe_am"] == date.today().isoformat()
erneut = await client.post(f"/api/v1/objekte/{objekt.id}/verleih/rueckgabe", headers=auth_header(token))
assert erneut.status_code == 409
@pytest.mark.asyncio
async def test_verleih_ueberfaellig_erkennbar(client, db_session, admin_user, standort_factory, objekt_factory):
standort = await standort_factory("Wache-Verleih2")
objekt = await objekt_factory(name="Zelt", code="VERLEIH-2", standort=standort)
token = await login(client, "admin1")
stelle = await client.post(
"/api/v1/externe-stellen", json={"name": "Verein X"}, headers=auth_header(token)
)
stelle_id = stelle.json()["id"]
verliehen = await client.post(
"/api/v1/verleihe",
json={
"objekt_id": objekt.id,
"externe_stelle_id": stelle_id,
"verliehen_am": (date.today() - timedelta(days=10)).isoformat(),
"voraussichtliche_rueckgabe_am": (date.today() - timedelta(days=3)).isoformat(),
},
headers=auth_header(token),
)
assert verliehen.json()["ueberfaellig"] is True
liste = await client.get("/api/v1/verleihe?offen=true", headers=auth_header(token))
eintrag = next(v for v in liste.json() if v["objekt_id"] == objekt.id)
assert eintrag["ueberfaellig"] is True
@pytest.mark.asyncio
async def test_verleih_nur_materialverantwortliche(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.get("/api/v1/verleihe", headers=auth_header(token))
assert response.status_code == 403
+20
View File
@@ -479,6 +479,26 @@ export interface CustomRolle {
berechtigung_ids: number[];
}
export interface ExterneStelle {
id: number;
name: string;
kontakt: string | null;
adresse: string | null;
}
export interface VerleihEintrag {
id: string;
objekt_id: number;
externe_stelle_id: number;
verliehen_am: string;
voraussichtliche_rueckgabe_am: string | null;
tatsaechliche_rueckgabe_am: string | null;
bemerkung: string | null;
verliehen_von_benutzer_id: number;
erstellt_am: string;
ueberfaellig: boolean;
}
export interface MaterialSetMitglied {
material_id: number;
menge: string;
+5 -1
View File
@@ -30,6 +30,7 @@ import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
import { ReadinessRegelSection } from "./admin/ReadinessRegelSection";
import { EinsatzSection } from "./admin/EinsatzSection";
import { MaterialSetSection } from "./admin/MaterialSetSection";
import { VerleihSection } from "./admin/VerleihSection";
type Tab =
| "objekte"
@@ -50,7 +51,8 @@ type Tab =
| "rollen"
| "readinessRegeln"
| "einsaetze"
| "materialSets";
| "materialSets"
| "verleih";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
@@ -96,6 +98,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
{ key: "readinessRegeln", label: "Bereitschaftsregeln", rollen: ["administration"] },
{ key: "einsaetze", label: "Einsätze", rollen: ["administration", "leitungsverantwortlicher"] },
{ key: "materialSets", label: "Material-Sets", rollen: ["administration", "materialverantwortlicher"] },
{ key: "verleih", label: "Verleih", rollen: ["administration", "materialverantwortlicher"] },
];
/**
@@ -316,6 +319,7 @@ export function AdminPage() {
{aktiverTab === "readinessRegeln" && <ReadinessRegelSection onFehler={setFehler} />}
{aktiverTab === "einsaetze" && <EinsatzSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "materialSets" && <MaterialSetSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { apiRequest, ApiError } from "../../api/client";
import type { ExterneStelle, Objekt, VerleihEintrag } from "../../api/types";
import { TableWrapper } from "../../components/ui/table-wrapper";
interface Props {
objekte: Objekt[];
onFehler: (text: string) => void;
}
// ASSET-011: Materialverleih an externe Stellen (nicht Person/PERS-001 -
// externe Organisationen haben keinen MABEA-Zugang), mit Rückgabe-Tracking.
export function VerleihSection({ objekte, onFehler }: Props) {
const [stellen, setStellen] = useState<ExterneStelle[]>([]);
const [verleihe, setVerleihe] = useState<VerleihEintrag[]>([]);
const [laedt, setLaedt] = useState(true);
const [neueStelleName, setNeueStelleName] = useState("");
const [neueStelleKontakt, setNeueStelleKontakt] = useState("");
const [objektId, setObjektId] = useState<number | "">("");
const [stelleId, setStelleId] = useState<number | "">("");
const [verliehenAm, setVerliehenAm] = useState("");
const [rueckgabeAm, setRueckgabeAm] = useState("");
async function laden() {
setLaedt(true);
try {
const [s, v] = await Promise.all([
apiRequest<ExterneStelle[]>("/externe-stellen"),
apiRequest<VerleihEintrag[]>("/verleihe"),
]);
setStellen(s);
setVerleihe(v);
} catch {
onFehler("Verleihdaten konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
async function stelleAnlegen() {
if (!neueStelleName) return;
try {
await apiRequest("/externe-stellen", {
method: "POST",
body: { name: neueStelleName, kontakt: neueStelleKontakt || null },
});
setNeueStelleName("");
setNeueStelleKontakt("");
await laden();
} catch {
onFehler("Externe Stelle konnte nicht angelegt werden.");
}
}
async function verleihen() {
if (objektId === "" || stelleId === "" || !verliehenAm) return;
try {
await apiRequest("/verleihe", {
method: "POST",
body: {
objekt_id: objektId,
externe_stelle_id: stelleId,
verliehen_am: verliehenAm,
voraussichtliche_rueckgabe_am: rueckgabeAm || null,
},
});
setObjektId("");
setStelleId("");
setVerliehenAm("");
setRueckgabeAm("");
await laden();
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 409) {
onFehler("Objekt ist bereits verliehen.");
} else {
onFehler("Verleih konnte nicht angelegt werden.");
}
}
}
async function rueckgabe(objId: number) {
try {
await apiRequest(`/objekte/${objId}/verleih/rueckgabe`, { method: "POST" });
await laden();
} catch {
onFehler("Rückgabe konnte nicht erfasst werden.");
}
}
function objektName(id: number): string {
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `#${id}`;
}
function stelleName(id: number): string {
const s = stellen.find((x) => x.id === id);
return s ? s.name : `#${id}`;
}
return (
<section className="section">
<h2>Verleih</h2>
<div className="card">
<h3 style={{ marginTop: 0 }}>Externe Stelle anlegen</h3>
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
placeholder="Name"
value={neueStelleName}
onChange={(e) => setNeueStelleName(e.target.value)}
/>
<input
className="input"
placeholder="Kontakt (optional)"
value={neueStelleKontakt}
onChange={(e) => setNeueStelleKontakt(e.target.value)}
/>
<button className="btn btn-secondary" onClick={stelleAnlegen} disabled={!neueStelleName}>
Anlegen
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Verleihen</h3>
<div className="row" style={{ flexWrap: "wrap" }}>
<select className="input" value={objektId} onChange={(e) => setObjektId(Number(e.target.value) || "")}>
<option value="">Objekt wählen…</option>
{objekte.map((o) => (
<option key={o.id} value={o.id}>
{o.name} ({o.code})
</option>
))}
</select>
<select className="input" value={stelleId} onChange={(e) => setStelleId(Number(e.target.value) || "")}>
<option value="">Externe Stelle wählen…</option>
{stellen.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<input type="date" className="input" value={verliehenAm} onChange={(e) => setVerliehenAm(e.target.value)} />
<input
type="date"
className="input"
value={rueckgabeAm}
onChange={(e) => setRueckgabeAm(e.target.value)}
title="Voraussichtliche Rückgabe (optional)"
/>
<button
className="btn btn-primary"
onClick={verleihen}
disabled={objektId === "" || stelleId === "" || !verliehenAm}
>
Verleihen
</button>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Aktuelle/vergangene Verleihe</h3>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && verleihe.length === 0 && <p className="text-muted">Keine Verleihe erfasst.</p>}
{!laedt && verleihe.length > 0 && (
<TableWrapper>
<table className="table">
<thead>
<tr>
<th>Objekt</th>
<th>Externe Stelle</th>
<th>Verliehen am</th>
<th>Vorauss. Rückgabe</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{verleihe.map((v) => (
<tr key={v.id}>
<td>{objektName(v.objekt_id)}</td>
<td>{stelleName(v.externe_stelle_id)}</td>
<td>{new Date(v.verliehen_am).toLocaleDateString("de-DE")}</td>
<td>
{v.voraussichtliche_rueckgabe_am
? new Date(v.voraussichtliche_rueckgabe_am).toLocaleDateString("de-DE")
: "–"}
</td>
<td>
{v.tatsaechliche_rueckgabe_am ? (
<span className="badge badge-neutral">zurück</span>
) : v.ueberfaellig ? (
<span className="badge badge-danger">überfällig</span>
) : (
<span className="badge badge-success">verliehen</span>
)}
</td>
<td>
{!v.tatsaechliche_rueckgabe_am && (
<button className="btn btn-secondary" onClick={() => rueckgabe(v.objekt_id)}>
Rückgabe
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</TableWrapper>
)}
</div>
</section>
);
}