feat(inventory): INV-007 Ausgabe/Rückgabe von Material und Leihgeräten
Personenbezogene Ausgabe ergänzt die reine Standort-/Lagerplatz-Sicht
um "wer hat das Ding gerade": neues Modell ausgabe (Material +
optional geraet_instanz_id), POST /ausgaben, GET /ausgaben (Filter
status/empfaenger), POST /ausgaben/{id}/rueckgabe mit Schutz vor
Doppel-Rueckgabe. Frontend als Admin-Tab "Ausgabe/Rückgabe" +
Command-Palette-Eintrag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -175,13 +175,19 @@ Details zu INV-001 … INV-007 (vollständig).
|
|||||||
- **Akzeptanzkriterien:** Ausgabe erzeugt offenen Vorgang, Rückgabe schließt ihn,
|
- **Akzeptanzkriterien:** Ausgabe erzeugt offenen Vorgang, Rückgabe schließt ihn,
|
||||||
überfällige Rückgaben erkennbar.
|
überfällige Rückgaben erkennbar.
|
||||||
- **Tests:** Ausgabe-/Rückgabe-Test, Überfällig-Test.
|
- **Tests:** Ausgabe-/Rückgabe-Test, Überfällig-Test.
|
||||||
- **DoD:** mind. ein vollständiger Ausgabe-Rückgabe-Zyklus getestet.
|
- **DoD:** umgesetzt (2026-09-06). Modell `ausgabe` (Material + optional
|
||||||
|
`geraet_instanz_id`), `POST /ausgaben`, `GET /ausgaben?status_filter=&empfaenger_id=`,
|
||||||
|
`POST /ausgaben/{id}/rueckgabe`. Frontend als Admin-Tab "Ausgabe/Rückgabe"
|
||||||
|
(Empfänger-Auswahl braucht die Benutzerliste, die nur Verantwortliche laden
|
||||||
|
dürfen - Backend selbst erlaubt jedem eingeloggten Nutzer Ausgabe/Rückgabe,
|
||||||
|
Feld-Erfassung durch Mitarbeiter wäre ein späterer Ausbauschritt mit eigener
|
||||||
|
Namenssuche statt vollem `/benutzer`-Endpunkt). Vollständiger
|
||||||
|
Ausgabe-Rückgabe-Zyklus + Doppel-Rückgabe-Schutz getestet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**MABEA-Ist-Stand-Abgleich:** INV-001…003 sowie INV-005/006 vollständig vorhanden — INV-006
|
**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** INV-001…003 sowie INV-005/006/007
|
||||||
(Fehlbestand+Mindermenge) ist sogar eines der am weitesten ausgereiften Module im gesamten
|
vollständig vorhanden — INV-006 (Fehlbestand+Mindermenge) ist eines der am weitesten
|
||||||
Projekt. **INV-004 (echter struktureller Umbau, siehe oben) und INV-007 (Ausgabe/Rückgabe)
|
ausgereiften Module im gesamten Projekt, INV-007 (Ausgabe/Rückgabe) beantwortet jetzt
|
||||||
fehlen komplett:** MABEA kennt nur Standortzuordnung von Objekten (Lagerbewegung), aber kein
|
"wer hat gerade das Funkgerät". **Nur INV-004 (echter struktureller Umbau, siehe oben)
|
||||||
personenbezogenes Ausleih-Konzept für Verbrauchsmaterial/Einzelteile — „wer hat gerade das
|
fehlt noch.**
|
||||||
Funkgerät" ist aktuell nicht beantwortbar.
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""INV-007: personenbezogene Ausgabe/Rueckgabe von Material/Leihgeraeten.
|
||||||
|
|
||||||
|
Revision ID: 0021_ausgabe
|
||||||
|
Revises: 0020_sollmenge_vorlage
|
||||||
|
Create Date: 2026-09-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0021_ausgabe"
|
||||||
|
down_revision: Union[str, None] = "0020_sollmenge_vorlage"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE TYPE ausgabe_status AS ENUM ('offen', 'zurueck')")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE ausgabe (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||||
|
geraet_instanz_id UUID REFERENCES geraet_instanz(id),
|
||||||
|
menge NUMERIC NOT NULL,
|
||||||
|
empfaenger_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||||
|
ausgegeben_von INTEGER NOT NULL REFERENCES benutzer(id),
|
||||||
|
ausgegeben_am TIMESTAMPTZ NOT NULL,
|
||||||
|
rueckgabe_am TIMESTAMPTZ,
|
||||||
|
status ausgabe_status NOT NULL DEFAULT 'offen',
|
||||||
|
zweck TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX idx_ausgabe_empfaenger ON ausgabe (empfaenger_id)")
|
||||||
|
op.execute("CREATE INDEX idx_ausgabe_status ON ausgabe (status)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE ausgabe")
|
||||||
|
op.execute("DROP TYPE ausgabe_status")
|
||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.v1.endpoints import (
|
from app.api.v1.endpoints import (
|
||||||
akte,
|
akte,
|
||||||
|
ausgabe,
|
||||||
auth,
|
auth,
|
||||||
benutzer,
|
benutzer,
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -44,3 +45,4 @@ api_router.include_router(dokument.router, tags=["dokument"])
|
|||||||
api_router.include_router(permission.router, tags=["permission"])
|
api_router.include_router(permission.router, tags=["permission"])
|
||||||
api_router.include_router(akte.router, tags=["akte"])
|
api_router.include_router(akte.router, tags=["akte"])
|
||||||
api_router.include_router(lager.router, tags=["lager"])
|
api_router.include_router(lager.router, tags=["lager"])
|
||||||
|
api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.ausgabe import Ausgabe, AusgabeStatus
|
||||||
|
from app.models.auth import Benutzer
|
||||||
|
from app.schemas.ausgabe import AusgabeCreate, AusgabeRead
|
||||||
|
from app.services.ausgabe import AusgabeBereitsZurueckError, ausgeben, zurueckgeben
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ausgaben", response_model=list[AusgabeRead])
|
||||||
|
async def liste_ausgaben(
|
||||||
|
status_filter: AusgabeStatus | None = None,
|
||||||
|
empfaenger_id: int | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
) -> list[Ausgabe]:
|
||||||
|
stmt = select(Ausgabe)
|
||||||
|
if status_filter is not None:
|
||||||
|
stmt = stmt.where(Ausgabe.status == status_filter)
|
||||||
|
if empfaenger_id is not None:
|
||||||
|
stmt = stmt.where(Ausgabe.empfaenger_id == empfaenger_id)
|
||||||
|
stmt = stmt.order_by(Ausgabe.ausgegeben_am.desc())
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ausgaben", response_model=AusgabeRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def erstelle_ausgabe(
|
||||||
|
payload: AusgabeCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Benutzer = Depends(get_current_user),
|
||||||
|
) -> Ausgabe:
|
||||||
|
return await ausgeben(
|
||||||
|
db,
|
||||||
|
material_id=payload.material_id,
|
||||||
|
geraet_instanz_id=payload.geraet_instanz_id,
|
||||||
|
menge=payload.menge,
|
||||||
|
empfaenger_id=payload.empfaenger_id,
|
||||||
|
ausgegeben_von=current_user.id,
|
||||||
|
zweck=payload.zweck,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ausgaben/{ausgabe_id}/rueckgabe", response_model=AusgabeRead)
|
||||||
|
async def route_rueckgabe(
|
||||||
|
ausgabe_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||||
|
) -> Ausgabe:
|
||||||
|
ausgabe = await db.get(Ausgabe, ausgabe_id)
|
||||||
|
if ausgabe is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ausgabe nicht gefunden")
|
||||||
|
try:
|
||||||
|
return await zurueckgeben(db, ausgabe=ausgabe)
|
||||||
|
except AusgabeBereitsZurueckError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Ausgabe wurde bereits zurückgegeben"
|
||||||
|
) from exc
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.models.ausgabe import Ausgabe, AusgabeStatus
|
||||||
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
|
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
|
||||||
from app.models.dokument import Dokument
|
from app.models.dokument import Dokument
|
||||||
from app.models.eskalation import EskalationKonfiguration
|
from app.models.eskalation import EskalationKonfiguration
|
||||||
@@ -26,6 +27,8 @@ from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
|
|||||||
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Ausgabe",
|
||||||
|
"AusgabeStatus",
|
||||||
"Benutzer",
|
"Benutzer",
|
||||||
"BenutzerRolle",
|
"BenutzerRolle",
|
||||||
"RolleTyp",
|
"RolleTyp",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AusgabeStatus(str, enum.Enum):
|
||||||
|
offen = "offen"
|
||||||
|
zurueck = "zurueck"
|
||||||
|
|
||||||
|
|
||||||
|
ausgabe_status_pg = PgEnum(AusgabeStatus, name="ausgabe_status", create_type=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Ausgabe(Base):
|
||||||
|
"""INV-007: personenbezogene Ausgabe/Rückgabe von Material/Leihgeräten -
|
||||||
|
ergänzt die reine Standort-/Lagerplatz-Sicht (Lagerbewegung/Materialbewegung)
|
||||||
|
um "wer hat das Ding gerade", solange die Ausgabe offen ist."""
|
||||||
|
|
||||||
|
__tablename__ = "ausgabe"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
|
||||||
|
geraet_instanz_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("geraet_instanz.id")
|
||||||
|
)
|
||||||
|
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
|
||||||
|
empfaenger_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||||
|
ausgegeben_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||||
|
ausgegeben_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
rueckgabe_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||||
|
status: Mapped[AusgabeStatus] = mapped_column(
|
||||||
|
ausgabe_status_pg, nullable=False, default=AusgabeStatus.offen
|
||||||
|
)
|
||||||
|
zweck: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.ausgabe import AusgabeStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AusgabeCreate(BaseModel):
|
||||||
|
material_id: int
|
||||||
|
geraet_instanz_id: uuid.UUID | None = None
|
||||||
|
menge: Decimal
|
||||||
|
empfaenger_id: int
|
||||||
|
zweck: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AusgabeRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
material_id: int
|
||||||
|
geraet_instanz_id: uuid.UUID | None
|
||||||
|
menge: Decimal
|
||||||
|
empfaenger_id: int
|
||||||
|
ausgegeben_von: int
|
||||||
|
ausgegeben_am: datetime
|
||||||
|
rueckgabe_am: datetime | None
|
||||||
|
status: AusgabeStatus
|
||||||
|
zweck: str | None
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.ausgabe import Ausgabe, AusgabeStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AusgabeBereitsZurueckError(Exception):
|
||||||
|
"""Eine bereits zurückgegebene Ausgabe kann nicht erneut zurückgegeben werden."""
|
||||||
|
|
||||||
|
|
||||||
|
async def ausgeben(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
material_id: int,
|
||||||
|
geraet_instanz_id: UUID | None,
|
||||||
|
menge: Decimal,
|
||||||
|
empfaenger_id: int,
|
||||||
|
ausgegeben_von: int,
|
||||||
|
zweck: str | None,
|
||||||
|
) -> Ausgabe:
|
||||||
|
ausgabe = Ausgabe(
|
||||||
|
material_id=material_id,
|
||||||
|
geraet_instanz_id=geraet_instanz_id,
|
||||||
|
menge=menge,
|
||||||
|
empfaenger_id=empfaenger_id,
|
||||||
|
ausgegeben_von=ausgegeben_von,
|
||||||
|
ausgegeben_am=datetime.now(timezone.utc),
|
||||||
|
status=AusgabeStatus.offen,
|
||||||
|
zweck=zweck,
|
||||||
|
)
|
||||||
|
db.add(ausgabe)
|
||||||
|
await db.flush()
|
||||||
|
return ausgabe
|
||||||
|
|
||||||
|
|
||||||
|
async def zurueckgeben(db: AsyncSession, *, ausgabe: Ausgabe) -> Ausgabe:
|
||||||
|
if ausgabe.status == AusgabeStatus.zurueck:
|
||||||
|
raise AusgabeBereitsZurueckError
|
||||||
|
ausgabe.status = AusgabeStatus.zurueck
|
||||||
|
ausgabe.rueckgabe_am = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
return ausgabe
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import auth_header, login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ausgabe_rueckgabe_zyklus(client, admin_user, materialverantwortlicher_user, material_factory):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
material = await material_factory("Funkgerät")
|
||||||
|
|
||||||
|
ausgabe = await client.post(
|
||||||
|
"/api/v1/ausgaben",
|
||||||
|
json={
|
||||||
|
"material_id": material.id,
|
||||||
|
"menge": "1",
|
||||||
|
"empfaenger_id": materialverantwortlicher_user.id,
|
||||||
|
"zweck": "Übung",
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert ausgabe.status_code == 201
|
||||||
|
daten = ausgabe.json()
|
||||||
|
assert daten["status"] == "offen"
|
||||||
|
assert daten["rueckgabe_am"] is None
|
||||||
|
assert daten["ausgegeben_von"] == admin_user.id
|
||||||
|
|
||||||
|
offene = await client.get("/api/v1/ausgaben?status_filter=offen", headers=auth_header(token))
|
||||||
|
assert len(offene.json()) == 1
|
||||||
|
|
||||||
|
rueckgabe = await client.post(
|
||||||
|
f"/api/v1/ausgaben/{daten['id']}/rueckgabe", headers=auth_header(token)
|
||||||
|
)
|
||||||
|
assert rueckgabe.status_code == 200
|
||||||
|
assert rueckgabe.json()["status"] == "zurueck"
|
||||||
|
assert rueckgabe.json()["rueckgabe_am"] is not None
|
||||||
|
|
||||||
|
doppelt = await client.post(
|
||||||
|
f"/api/v1/ausgaben/{daten['id']}/rueckgabe", headers=auth_header(token)
|
||||||
|
)
|
||||||
|
assert doppelt.status_code == 409
|
||||||
|
|
||||||
|
offene2 = await client.get("/api/v1/ausgaben?status_filter=offen", headers=auth_header(token))
|
||||||
|
assert len(offene2.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rueckgabe_unbekannte_ausgabe_404(client, admin_user):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/ausgaben/00000000-0000-0000-0000-000000000000/rueckgabe",
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -130,6 +130,19 @@ export interface Materialbewegung {
|
|||||||
grund: string | null;
|
grund: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Ausgabe {
|
||||||
|
id: string;
|
||||||
|
material_id: number;
|
||||||
|
geraet_instanz_id: string | null;
|
||||||
|
menge: string;
|
||||||
|
empfaenger_id: number;
|
||||||
|
ausgegeben_von: number;
|
||||||
|
ausgegeben_am: string;
|
||||||
|
rueckgabe_am: string | null;
|
||||||
|
status: "offen" | "zurueck";
|
||||||
|
zweck: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Fehlbestand {
|
export interface Fehlbestand {
|
||||||
id: string;
|
id: string;
|
||||||
objekt_id: number;
|
objekt_id: number;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function CommandPalette() {
|
|||||||
gehe: () => navigate("/admin?tab=lager"),
|
gehe: () => navigate("/admin?tab=lager"),
|
||||||
},
|
},
|
||||||
{ id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") },
|
{ id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") },
|
||||||
|
{ id: "nav-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") },
|
||||||
{ id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") },
|
{ id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") },
|
||||||
{
|
{
|
||||||
id: "nav-material",
|
id: "nav-material",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
|
import { AusgabeSection } from "./admin/AusgabeSection";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
|
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
|
||||||
import { BenutzerSection } from "./admin/BenutzerSection";
|
import { BenutzerSection } from "./admin/BenutzerSection";
|
||||||
@@ -37,6 +38,7 @@ type Tab =
|
|||||||
| "personal"
|
| "personal"
|
||||||
| "lagerbewegungen"
|
| "lagerbewegungen"
|
||||||
| "lager"
|
| "lager"
|
||||||
|
| "ausgabe"
|
||||||
| "rollen";
|
| "rollen";
|
||||||
|
|
||||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||||
@@ -69,6 +71,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
|||||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
},
|
},
|
||||||
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
|
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
|
||||||
|
{
|
||||||
|
key: "ausgabe",
|
||||||
|
label: "Ausgabe/Rückgabe",
|
||||||
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
|
},
|
||||||
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -237,6 +244,9 @@ export function AdminPage() {
|
|||||||
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
||||||
)}
|
)}
|
||||||
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
|
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
|
||||||
|
{aktiverTab === "ausgabe" && (
|
||||||
|
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
|
||||||
|
)}
|
||||||
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
||||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||||
{aktiverTab === "personal" && (
|
{aktiverTab === "personal" && (
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../../api/client";
|
||||||
|
import type { Ausgabe, Benutzer, Material } from "../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
materialien: Material[];
|
||||||
|
benutzer: Benutzer[];
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INV-007: personenbezogene Ausgabe/Rückgabe - ergänzt die reine Standort-
|
||||||
|
// Sicht (Lagerbewegung/Materialbewegung) um "wer hat das Ding gerade".
|
||||||
|
export function AusgabeSection({ materialien, benutzer, onFehler }: Props) {
|
||||||
|
const [ausgaben, setAusgaben] = useState<Ausgabe[]>([]);
|
||||||
|
const [nurOffene, setNurOffene] = useState(true);
|
||||||
|
const [laedt, setLaedt] = useState(true);
|
||||||
|
|
||||||
|
const [neuMaterial, setNeuMaterial] = useState<number | "">("");
|
||||||
|
const [neuMenge, setNeuMenge] = useState("1");
|
||||||
|
const [neuEmpfaenger, setNeuEmpfaenger] = useState<number | "">("");
|
||||||
|
const [neuZweck, setNeuZweck] = useState("");
|
||||||
|
|
||||||
|
function materialName(id: number): string {
|
||||||
|
const m = materialien.find((x) => x.id === id);
|
||||||
|
return m ? m.name : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function benutzerName(id: number): string {
|
||||||
|
const b = benutzer.find((x) => x.id === id);
|
||||||
|
return b ? b.name : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true);
|
||||||
|
try {
|
||||||
|
const query = nurOffene ? "?status_filter=offen" : "";
|
||||||
|
setAusgaben(await apiRequest<Ausgabe[]>(`/ausgaben${query}`));
|
||||||
|
} catch {
|
||||||
|
onFehler("Ausgaben konnten nicht geladen werden.");
|
||||||
|
} finally {
|
||||||
|
setLaedt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nurOffene]);
|
||||||
|
|
||||||
|
async function ausgeben() {
|
||||||
|
if (neuMaterial === "" || neuEmpfaenger === "" || !neuMenge) {
|
||||||
|
onFehler("Material, Menge und Empfänger angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiRequest("/ausgaben", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
material_id: neuMaterial,
|
||||||
|
menge: neuMenge,
|
||||||
|
empfaenger_id: neuEmpfaenger,
|
||||||
|
zweck: neuZweck || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNeuMenge("1");
|
||||||
|
setNeuZweck("");
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Ausgabe fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rueckgabe(id: string) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/ausgaben/${id}/rueckgabe`, { method: "POST" });
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Rückgabe fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<h2>Ausgabe / Rückgabe</h2>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Neue Ausgabe</h3>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
|
<select className="input" value={neuMaterial} onChange={(e) => setNeuMaterial(Number(e.target.value) || "")}>
|
||||||
|
<option value="">Material wählen…</option>
|
||||||
|
{materialien.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
placeholder="Menge"
|
||||||
|
style={{ width: "6rem" }}
|
||||||
|
value={neuMenge}
|
||||||
|
onChange={(e) => setNeuMenge(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select className="input" value={neuEmpfaenger} onChange={(e) => setNeuEmpfaenger(Number(e.target.value) || "")}>
|
||||||
|
<option value="">Empfänger wählen…</option>
|
||||||
|
{benutzer.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Zweck (optional)"
|
||||||
|
value={neuZweck}
|
||||||
|
onChange={(e) => setNeuZweck(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={ausgeben}>
|
||||||
|
Ausgeben
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: "1rem" }}>
|
||||||
|
<div className="row-between" style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Ausgaben</h3>
|
||||||
|
<label className="row" style={{ gap: "0.4rem", fontSize: "0.9rem" }}>
|
||||||
|
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
|
||||||
|
nur offene
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{laedt && <p className="text-muted">Lade…</p>}
|
||||||
|
{!laedt && ausgaben.length === 0 && <p className="text-muted">Keine Ausgaben.</p>}
|
||||||
|
|
||||||
|
{!laedt && ausgaben.length > 0 && (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ausgegeben am</th>
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Menge</th>
|
||||||
|
<th>Empfänger</th>
|
||||||
|
<th>Zweck</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ausgaben.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{new Date(a.ausgegeben_am).toLocaleString("de-DE")}</td>
|
||||||
|
<td>{materialName(a.material_id)}</td>
|
||||||
|
<td>{a.menge}</td>
|
||||||
|
<td>{benutzerName(a.empfaenger_id)}</td>
|
||||||
|
<td>{a.zweck ?? "–"}</td>
|
||||||
|
<td>
|
||||||
|
<span className={a.status === "offen" ? "badge badge-warning" : "badge badge-neutral"}>
|
||||||
|
{a.status === "offen" ? "offen" : "zurück"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{a.status === "offen" && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => rueckgabe(a.id)}>
|
||||||
|
Rückgabe
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user