feat(assets): ASSET-011 externer Verleih mit Rückgabe-Tracking
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
63ac9ecf2c
commit
1ad693073e
12 files changed
+666
-6
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user