feat(lager): Lagerbewegung-Protokoll (Roadmap Phase 4, Modul Lager)
Objekt bleibt der Materialträger (Rucksack/Gerät/Fahrzeug, Karte 11) -
"Material verschieben" heißt: das Objekt bekommt einen neuen Standort, jede
Bewegung wird append-only protokolliert (Wer/Wann/Von/Nach/Grund, Nutzer-
Vorgabe Modul 5).
POST /objekte/{id}/lagerbewegungen setzt Standort + protokolliert in einem
Zug, GET /lagerbewegungen (Filter objekt_id/standort_id) für die Historie.
Rollen wie beim Mangel-Modul: Materialverantwortliche+Leitung+Admin dürfen
verschieben, alle Eingeloggten dürfen lesen.
Frontend: "Verschieben"-Aktion in ObjektSection, neuer Admin-Tab
"Lagerbewegungen" (reine Anzeige, analog HistorieSection).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
@@ -4051,3 +4051,17 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/DashboardPage.tsx | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
|
- frontend/src/pages/DashboardPage.tsx | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-09-05 13:41 – 13:42 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** asb-material
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 8fbb356 feat(mangel): Leitungsverantwortliche dürfen Mangel-Status ändern/erledigen
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 32 ++++++++++++++++++++++++++++++++
|
||||||
|
- backend/app/api/v1/endpoints/mangel.py | 4 +++-
|
||||||
|
- backend/tests/test_mangel.py | 30 ++++++++++++++++++++++++++++++
|
||||||
|
- frontend/src/pages/MangelListePage.tsx | 4 ++--
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Lagerbewegung (Roadmap Phase 4, Modul Lager): protokolliert Standortwechsel
|
||||||
|
eines Objekts. Objekt bleibt der Materialträger (Rucksack/Gerät/Fahrzeug,
|
||||||
|
Karte 11) - "Material verschieben" heißt hier: das Objekt, das es enthält,
|
||||||
|
bekommt einen neuen Standort, die Bewegung wird mit Wer/Wann/Von/Nach/Grund
|
||||||
|
protokolliert (Nutzer-Vorgabe Modul 5 "Lagerverwaltung").
|
||||||
|
|
||||||
|
Revision ID: 0015_lagerbewegung
|
||||||
|
Revises: 0014_fahrzeugdetails
|
||||||
|
Create Date: 2026-09-05
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0015_lagerbewegung"
|
||||||
|
down_revision: Union[str, None] = "0014_fahrzeugdetails"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE lagerbewegung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||||
|
von_standort_id INTEGER REFERENCES standort(id),
|
||||||
|
nach_standort_id INTEGER NOT NULL REFERENCES standort(id),
|
||||||
|
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||||
|
zeitpunkt TIMESTAMPTZ NOT NULL,
|
||||||
|
grund TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX idx_lagerbewegung_objekt ON lagerbewegung (objekt_id)")
|
||||||
|
op.execute("CREATE INDEX idx_lagerbewegung_standorte ON lagerbewegung (von_standort_id, nach_standort_id)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE lagerbewegung")
|
||||||
@@ -10,6 +10,7 @@ from app.api.v1.endpoints import (
|
|||||||
health,
|
health,
|
||||||
historie,
|
historie,
|
||||||
kontrollen,
|
kontrollen,
|
||||||
|
lagerbewegung,
|
||||||
mangel,
|
mangel,
|
||||||
objekte,
|
objekte,
|
||||||
personal,
|
personal,
|
||||||
@@ -34,3 +35,4 @@ api_router.include_router(geraet_instanz.router, tags=["geraet_instanz"])
|
|||||||
api_router.include_router(dashboard.router, tags=["dashboard"])
|
api_router.include_router(dashboard.router, tags=["dashboard"])
|
||||||
api_router.include_router(personal.router, tags=["personal"])
|
api_router.include_router(personal.router, tags=["personal"])
|
||||||
api_router.include_router(mangel.router, tags=["mangel"])
|
api_router.include_router(mangel.router, tags=["mangel"])
|
||||||
|
api_router.include_router(lagerbewegung.router, tags=["lagerbewegung"])
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.auth import RolleTyp
|
||||||
|
from app.models.lagerbewegung import Lagerbewegung
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
from app.models.stammdaten import Standort
|
||||||
|
from app.schemas.lagerbewegung import LagerbewegungCreate, LagerbewegungRead
|
||||||
|
from app.services.lagerbewegung import verschiebe_objekt
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Deckt sich mit der Rollen-Vorgabe aus dem Mangel-Modul (Materialwart-
|
||||||
|
# Berechtigung "Lagerbewegungen durchführen" aus der Ursprungs-Anforderung).
|
||||||
|
_materialverantwortliche = require_roles(
|
||||||
|
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lagerbewegungen", response_model=list[LagerbewegungRead])
|
||||||
|
async def liste_lagerbewegungen(
|
||||||
|
objekt_id: int | None = None,
|
||||||
|
standort_id: int | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
) -> list[Lagerbewegung]:
|
||||||
|
stmt = select(Lagerbewegung)
|
||||||
|
if objekt_id is not None:
|
||||||
|
stmt = stmt.where(Lagerbewegung.objekt_id == objekt_id)
|
||||||
|
if standort_id is not None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
(Lagerbewegung.von_standort_id == standort_id) | (Lagerbewegung.nach_standort_id == standort_id)
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Lagerbewegung.zeitpunkt.desc())
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/objekte/{objekt_id}/lagerbewegungen",
|
||||||
|
response_model=LagerbewegungRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def verschiebe(
|
||||||
|
objekt_id: int,
|
||||||
|
payload: LagerbewegungCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user=Depends(_materialverantwortliche),
|
||||||
|
) -> Lagerbewegung:
|
||||||
|
objekt = await db.get(Objekt, objekt_id)
|
||||||
|
if objekt is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
|
||||||
|
ziel = await db.get(Standort, payload.nach_standort_id)
|
||||||
|
if ziel is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Standort nicht gefunden")
|
||||||
|
if payload.nach_standort_id == objekt.standort_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Objekt ist bereits an diesem Standort")
|
||||||
|
|
||||||
|
return await verschiebe_objekt(
|
||||||
|
db,
|
||||||
|
objekt=objekt,
|
||||||
|
nach_standort_id=payload.nach_standort_id,
|
||||||
|
benutzer_id=current_user.id,
|
||||||
|
grund=payload.grund,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Lagerbewegung(Base):
|
||||||
|
"""Roadmap Phase 4 (Modul Lager): protokolliert Standortwechsel eines
|
||||||
|
Objekts (Rucksack/Gerät/Fahrzeug - der Materialträger, Karte 11). Append-
|
||||||
|
only wie Historie, keine Update/Delete-Operation vorgesehen."""
|
||||||
|
|
||||||
|
__tablename__ = "lagerbewegung"
|
||||||
|
|
||||||
|
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)
|
||||||
|
von_standort_id: Mapped[int | None] = mapped_column(ForeignKey("standort.id"))
|
||||||
|
nach_standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False)
|
||||||
|
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||||
|
zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
grund: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class LagerbewegungCreate(BaseModel):
|
||||||
|
nach_standort_id: int
|
||||||
|
grund: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LagerbewegungRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
objekt_id: int
|
||||||
|
von_standort_id: int | None
|
||||||
|
nach_standort_id: int
|
||||||
|
benutzer_id: int
|
||||||
|
zeitpunkt: datetime
|
||||||
|
grund: str | None
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.lagerbewegung import Lagerbewegung
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
|
||||||
|
|
||||||
|
async def verschiebe_objekt(
|
||||||
|
db: AsyncSession, *, objekt: Objekt, nach_standort_id: int, benutzer_id: int, grund: str | None
|
||||||
|
) -> Lagerbewegung:
|
||||||
|
"""Roadmap Phase 4 (Modul Lager): setzt objekt.standort_id und protokolliert
|
||||||
|
die Bewegung in einem Zug - von_standort_id ist der ALTE Stand vor dem
|
||||||
|
Setzen (muss vor der Zuweisung gelesen werden)."""
|
||||||
|
bewegung = Lagerbewegung(
|
||||||
|
objekt_id=objekt.id,
|
||||||
|
von_standort_id=objekt.standort_id,
|
||||||
|
nach_standort_id=nach_standort_id,
|
||||||
|
benutzer_id=benutzer_id,
|
||||||
|
zeitpunkt=datetime.now(timezone.utc),
|
||||||
|
grund=grund,
|
||||||
|
)
|
||||||
|
objekt.standort_id = nach_standort_id
|
||||||
|
db.add(bewegung)
|
||||||
|
await db.flush()
|
||||||
|
return bewegung
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import auth_header, login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_objekt_verschieben_protokolliert_bewegung(
|
||||||
|
client, objekt_mit_position, standort_factory, materialverantwortlicher_user
|
||||||
|
):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
ziel = await standort_factory("Aussenlager")
|
||||||
|
token = await login(client, "materialverantwortlicher1")
|
||||||
|
|
||||||
|
bewegung = await client.post(
|
||||||
|
f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
|
||||||
|
json={"nach_standort_id": ziel.id, "grund": "Umzug"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert bewegung.status_code == 201
|
||||||
|
body = bewegung.json()
|
||||||
|
assert body["objekt_id"] == objekt.id
|
||||||
|
assert body["von_standort_id"] == objekt.standort_id
|
||||||
|
assert body["nach_standort_id"] == ziel.id
|
||||||
|
assert body["grund"] == "Umzug"
|
||||||
|
|
||||||
|
objekt_neu = await client.get(f"/api/v1/objekte/{objekt.id}", headers=auth_header(token))
|
||||||
|
assert objekt_neu.json()["standort_id"] == ziel.id
|
||||||
|
|
||||||
|
liste = await client.get(f"/api/v1/lagerbewegungen?objekt_id={objekt.id}", headers=auth_header(token))
|
||||||
|
assert len(liste.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mitarbeiter_darf_nicht_verschieben(
|
||||||
|
client, objekt_mit_position, standort_factory, mitarbeiter_user
|
||||||
|
):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
ziel = await standort_factory("Aussenlager2")
|
||||||
|
token = await login(client, "mitarbeiter1")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
|
||||||
|
json={"nach_standort_id": ziel.id},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verschieben_an_gleichen_standort_ist_409(client, objekt_mit_position, materialverantwortlicher_user):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
token = await login(client, "materialverantwortlicher1")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
|
||||||
|
json={"nach_standort_id": objekt.standort_id},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verschieben_zu_unbekanntem_standort_ist_404(client, objekt_mit_position, materialverantwortlicher_user):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
token = await login(client, "materialverantwortlicher1")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
|
||||||
|
json={"nach_standort_id": 999999},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lagerbewegungen_nach_standort_filterbar(
|
||||||
|
client, objekt_mit_position, standort_factory, materialverantwortlicher_user
|
||||||
|
):
|
||||||
|
objekt, _material = objekt_mit_position
|
||||||
|
ziel = await standort_factory("Aussenlager3")
|
||||||
|
token = await login(client, "materialverantwortlicher1")
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
|
||||||
|
json={"nach_standort_id": ziel.id},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
gefiltert = await client.get(f"/api/v1/lagerbewegungen?standort_id={ziel.id}", headers=auth_header(token))
|
||||||
|
assert len(gefiltert.json()) == 1
|
||||||
@@ -73,6 +73,16 @@ export interface Mangel {
|
|||||||
erledigt_am: string | null;
|
erledigt_am: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Lagerbewegung {
|
||||||
|
id: string;
|
||||||
|
objekt_id: number;
|
||||||
|
von_standort_id: number | null;
|
||||||
|
nach_standort_id: number;
|
||||||
|
benutzer_id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
grund: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Fehlbestand {
|
export interface Fehlbestand {
|
||||||
id: string;
|
id: string;
|
||||||
objekt_id: number;
|
objekt_id: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { EskalationSection } from "./admin/EskalationSection";
|
|||||||
import { FachSection } from "./admin/FachSection";
|
import { FachSection } from "./admin/FachSection";
|
||||||
import { HistorieSection } from "./admin/HistorieSection";
|
import { HistorieSection } from "./admin/HistorieSection";
|
||||||
import { KategorieSection } from "./admin/KategorieSection";
|
import { KategorieSection } from "./admin/KategorieSection";
|
||||||
|
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
|
||||||
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
|
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
|
||||||
import { MaterialSection } from "./admin/MaterialSection";
|
import { MaterialSection } from "./admin/MaterialSection";
|
||||||
import { ObjektSection } from "./admin/ObjektSection";
|
import { ObjektSection } from "./admin/ObjektSection";
|
||||||
@@ -30,7 +31,8 @@ type Tab =
|
|||||||
| "eskalation"
|
| "eskalation"
|
||||||
| "kontrollverantwortung"
|
| "kontrollverantwortung"
|
||||||
| "zustaendigkeit"
|
| "zustaendigkeit"
|
||||||
| "personal";
|
| "personal"
|
||||||
|
| "lagerbewegungen";
|
||||||
|
|
||||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||||
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
|
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
|
||||||
@@ -56,6 +58,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
|||||||
},
|
},
|
||||||
{ key: "eskalation", label: "Eskalation", rollen: ["administration"] },
|
{ key: "eskalation", label: "Eskalation", rollen: ["administration"] },
|
||||||
{ key: "personal", label: "Personal", rollen: ["administration"] },
|
{ key: "personal", label: "Personal", rollen: ["administration"] },
|
||||||
|
{
|
||||||
|
key: "lagerbewegungen",
|
||||||
|
label: "Lagerbewegungen",
|
||||||
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,6 +219,9 @@ export function AdminPage() {
|
|||||||
<KontrollverantwortungSection objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
|
<KontrollverantwortungSection objekte={objekte} benutzer={benutzer} onFehler={setFehler} />
|
||||||
)}
|
)}
|
||||||
{aktiverTab === "historie" && <HistorieSection onFehler={setFehler} />}
|
{aktiverTab === "historie" && <HistorieSection onFehler={setFehler} />}
|
||||||
|
{aktiverTab === "lagerbewegungen" && (
|
||||||
|
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
||||||
|
)}
|
||||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||||
{aktiverTab === "personal" && (
|
{aktiverTab === "personal" && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../../api/client";
|
||||||
|
import type { Lagerbewegung, Objekt, Standort } from "../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objekte: Objekt[];
|
||||||
|
standorte: Standort[];
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roadmap Phase 4 (Modul Lager): reine Anzeige des append-only Bewegungs-
|
||||||
|
// protokolls, analog HistorieSection. Verschieben selbst passiert direkt in
|
||||||
|
// ObjektSection (Aktion am Objekt, nicht hier).
|
||||||
|
export function LagerbewegungSection({ objekte, standorte, onFehler }: Props) {
|
||||||
|
const [bewegungen, setBewegungen] = useState<Lagerbewegung[]>([]);
|
||||||
|
const [standortFilter, setStandortFilter] = useState<number | "">("");
|
||||||
|
const [laedt, setLaedt] = useState(true);
|
||||||
|
|
||||||
|
function objektName(id: number): string {
|
||||||
|
const o = objekte.find((x) => x.id === id);
|
||||||
|
return o ? `${o.name} (${o.code})` : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function standortName(id: number | null): string {
|
||||||
|
if (id === null) return "–";
|
||||||
|
const s = standorte.find((x) => x.id === id);
|
||||||
|
return s ? s.name : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
setLaedt(true);
|
||||||
|
try {
|
||||||
|
const query = standortFilter !== "" ? `?standort_id=${standortFilter}` : "";
|
||||||
|
const daten = await apiRequest<Lagerbewegung[]>(`/lagerbewegungen${query}`);
|
||||||
|
setBewegungen(daten);
|
||||||
|
} catch {
|
||||||
|
onFehler("Lagerbewegungen konnten nicht geladen werden.");
|
||||||
|
} finally {
|
||||||
|
setLaedt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [standortFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<h2>Lagerbewegungen</h2>
|
||||||
|
<div className="card">
|
||||||
|
<div className="field" style={{ maxWidth: "20rem" }}>
|
||||||
|
<label>Standort</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={standortFilter}
|
||||||
|
onChange={(e) => setStandortFilter(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{standorte.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{laedt && <p className="text-muted">Lade…</p>}
|
||||||
|
{!laedt && bewegungen.length === 0 && <p className="text-muted">Keine Bewegungen.</p>}
|
||||||
|
|
||||||
|
{!laedt && bewegungen.length > 0 && (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Objekt</th>
|
||||||
|
<th>Von</th>
|
||||||
|
<th>Nach</th>
|
||||||
|
<th>Grund</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bewegungen.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>{new Date(b.zeitpunkt).toLocaleString("de-DE")}</td>
|
||||||
|
<td>{objektName(b.objekt_id)}</td>
|
||||||
|
<td>{standortName(b.von_standort_id)}</td>
|
||||||
|
<td>{standortName(b.nach_standort_id)}</td>
|
||||||
|
<td>{b.grund ?? "–"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,36 @@ export function ObjektSection({
|
|||||||
const [suche, setSuche] = useState("");
|
const [suche, setSuche] = useState("");
|
||||||
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
|
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
|
||||||
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
|
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
|
||||||
|
const [verschiebenId, setVerschiebenId] = useState<number | null>(null);
|
||||||
|
const [zielStandortId, setZielStandortId] = useState<number | "">("");
|
||||||
|
const [verschiebenGrund, setVerschiebenGrund] = useState("");
|
||||||
|
|
||||||
|
function verschiebenOeffnen(o: Objekt) {
|
||||||
|
if (verschiebenId === o.id) {
|
||||||
|
setVerschiebenId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerschiebenId(o.id);
|
||||||
|
setZielStandortId("");
|
||||||
|
setVerschiebenGrund("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verschieben(objektId: number) {
|
||||||
|
if (zielStandortId === "") {
|
||||||
|
onFehler("Zielstandort auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiRequest(`/objekte/${objektId}/lagerbewegungen`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { nach_standort_id: zielStandortId, grund: verschiebenGrund || null },
|
||||||
|
});
|
||||||
|
setVerschiebenId(null);
|
||||||
|
await onGeaendert();
|
||||||
|
} catch {
|
||||||
|
onFehler("Objekt konnte nicht verschoben werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function statusAendern(o: Objekt, status: string) {
|
async function statusAendern(o: Objekt, status: string) {
|
||||||
try {
|
try {
|
||||||
@@ -356,6 +386,11 @@ export function ObjektSection({
|
|||||||
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
|
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!nurPflegen && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => verschiebenOeffnen(o)}>
|
||||||
|
{verschiebenId === o.id ? "Schließen" : "Verschieben"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
|
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
|
||||||
@@ -431,6 +466,33 @@ export function ObjektSection({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{verschiebenId === o.id && (
|
||||||
|
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={zielStandortId}
|
||||||
|
onChange={(e) => setZielStandortId(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Zielstandort wählen…</option>
|
||||||
|
{standorte
|
||||||
|
.filter((s) => s.id !== o.standort_id)
|
||||||
|
.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={verschiebenGrund}
|
||||||
|
onChange={(e) => setVerschiebenGrund(e.target.value)}
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={() => verschieben(o.id)}>
|
||||||
|
Verschieben
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{fahrzeugBearbeitenId === o.id && (
|
{fahrzeugBearbeitenId === o.id && (
|
||||||
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
|
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
|
||||||
<select
|
<select
|
||||||
|
|||||||
Reference in New Issue
Block a user