diff --git a/arbeitskacheln/04_assets.md b/arbeitskacheln/04_assets.md index cff46bb..4aa4f2d 100644 --- a/arbeitskacheln/04_assets.md +++ b/arbeitskacheln/04_assets.md @@ -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) diff --git a/backend/alembic/versions/0048_verleih.py b/backend/alembic/versions/0048_verleih.py new file mode 100644 index 0000000..3346a80 --- /dev/null +++ b/backend/alembic/versions/0048_verleih.py @@ -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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index a9ad9da..1567504 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/verleih.py b/backend/app/api/v1/endpoints/verleih.py new file mode 100644 index 0000000..09db5fc --- /dev/null +++ b/backend/app/api/v1/endpoints/verleih.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9608c1f..5219ceb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/verleih.py b/backend/app/models/verleih.py new file mode 100644 index 0000000..42edb66 --- /dev/null +++ b/backend/app/models/verleih.py @@ -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) diff --git a/backend/app/schemas/verleih.py b/backend/app/schemas/verleih.py new file mode 100644 index 0000000..5205273 --- /dev/null +++ b/backend/app/schemas/verleih.py @@ -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 diff --git a/backend/app/services/verleih.py b/backend/app/services/verleih.py new file mode 100644 index 0000000..b1c6ce8 --- /dev/null +++ b/backend/app/services/verleih.py @@ -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() diff --git a/backend/tests/test_verleih.py b/backend/tests/test_verleih.py new file mode 100644 index 0000000..cbafe83 --- /dev/null +++ b/backend/tests/test_verleih.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5d99783..c7049d3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 28f2399..a073822 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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" && } {aktiverTab === "einsaetze" && } {aktiverTab === "materialSets" && } + {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( <> diff --git a/frontend/src/pages/admin/VerleihSection.tsx b/frontend/src/pages/admin/VerleihSection.tsx new file mode 100644 index 0000000..1fe7a11 --- /dev/null +++ b/frontend/src/pages/admin/VerleihSection.tsx @@ -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([]); + const [verleihe, setVerleihe] = useState([]); + const [laedt, setLaedt] = useState(true); + + const [neueStelleName, setNeueStelleName] = useState(""); + const [neueStelleKontakt, setNeueStelleKontakt] = useState(""); + + const [objektId, setObjektId] = useState(""); + const [stelleId, setStelleId] = useState(""); + const [verliehenAm, setVerliehenAm] = useState(""); + const [rueckgabeAm, setRueckgabeAm] = useState(""); + + async function laden() { + setLaedt(true); + try { + const [s, v] = await Promise.all([ + apiRequest("/externe-stellen"), + apiRequest("/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 ( +
+

Verleih

+ +
+

Externe Stelle anlegen

+
+ setNeueStelleName(e.target.value)} + /> + setNeueStelleKontakt(e.target.value)} + /> + +
+
+ +
+

Verleihen

+
+ + + setVerliehenAm(e.target.value)} /> + setRueckgabeAm(e.target.value)} + title="Voraussichtliche Rückgabe (optional)" + /> + +
+
+ +
+

Aktuelle/vergangene Verleihe

+ {laedt &&

Lade…

} + {!laedt && verleihe.length === 0 &&

Keine Verleihe erfasst.

} + {!laedt && verleihe.length > 0 && ( + + + + + + + + + + + + + + {verleihe.map((v) => ( + + + + + + + + + ))} + +
ObjektExterne StelleVerliehen amVorauss. RückgabeStatus
{objektName(v.objekt_id)}{stelleName(v.externe_stelle_id)}{new Date(v.verliehen_am).toLocaleDateString("de-DE")} + {v.voraussichtliche_rueckgabe_am + ? new Date(v.voraussichtliche_rueckgabe_am).toLocaleDateString("de-DE") + : "–"} + + {v.tatsaechliche_rueckgabe_am ? ( + zurück + ) : v.ueberfaellig ? ( + überfällig + ) : ( + verliehen + )} + + {!v.tatsaechliche_rueckgabe_am && ( + + )} +
+
+ )} +
+
+ ); +}