Karte 14 Schritt 2 (Admin-Teil) + Frontend für Anhänger-Kopplung
Backend: geraet_instanz-CRUD (Liste/Anlegen/Status ändern inkl. optionalem Fehlbestand pro Gerät/Löschen), pruefintervall_monate in Objektposition- Schemas verdrahtet (Prüfdatum -> naechste_pruefung wird automatisch berechnet, Monatsrechnung ohne neue Dependency). Frontend: GeraeteInstanzenListe ersetzt das Einzel-SN-Feld im Admin-Portal (ObjektPositionenPanel) durch eine Instanz-Liste mit Status/Prüfdatum/ Bemerkung je Exemplar - löst das Karte-14-Kernproblem (mehrere Geräte pro Position). ObjektSection: Anhänger-Kopplung bei Anlage und nachträglich änderbar. Kontroll-Erfassung (PositionCard/KontrollPage) bleibt unverändert - die komplett neue Erfassungs-UI für geraet_sn (Umsetzungsschritt 4, Karte 14) ist bewusst nicht Teil dieses Commits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -3191,3 +3191,37 @@ Keine Commits in dieser Session.
|
||||
- .../pages/admin/KontrollverantwortungSection.tsx | 185 +++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 20:47 – 20:48 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
- eff9fe4 Fahrzeug/Anhänger-Kopplung (objekt.gezogen_von_objekt_id)
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/DEVLOG.md | 36 ++++++++
|
||||
- backend/alembic/versions/0009_gezogen_von.py | 42 +++++++++
|
||||
- backend/app/api/v1/endpoints/objekte.py | 38 ++++++++
|
||||
- backend/app/models/objekt.py | 6 ++
|
||||
- backend/app/schemas/objekt.py | 6 ++
|
||||
- backend/app/services/objekte.py | 6 +-
|
||||
- backend/tests/test_objekte.py | 131 +++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 20:48 – 20:49 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/DEVLOG.md | 36 ++++++++
|
||||
- backend/alembic/versions/0009_gezogen_von.py | 42 +++++++++
|
||||
- backend/app/api/v1/endpoints/objekte.py | 38 ++++++++
|
||||
- backend/app/models/objekt.py | 6 ++
|
||||
- backend/app/schemas/objekt.py | 6 ++
|
||||
- backend/app/services/objekte.py | 6 +-
|
||||
- backend/tests/test_objekte.py | 131 +++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -225,3 +225,20 @@ Keine Commits in dieser Session.
|
||||
- backend/tests/test_geraet_instanz.py | 99 +++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 20:49 – 20:54 (5m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- backend/DEVLOG.md | 36 ++++++++
|
||||
- backend/alembic/versions/0009_gezogen_von.py | 42 +++++++++
|
||||
- backend/app/api/v1/endpoints/objekte.py | 38 ++++++++
|
||||
- backend/app/models/objekt.py | 6 ++
|
||||
- backend/app/schemas/objekt.py | 6 ++
|
||||
- backend/app/services/objekte.py | 6 +-
|
||||
- backend/tests/test_objekte.py | 131 +++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.api.v1.endpoints import (
|
||||
dashboard,
|
||||
eskalation,
|
||||
fehlbestaende,
|
||||
geraet_instanz,
|
||||
health,
|
||||
historie,
|
||||
kontrollen,
|
||||
@@ -27,4 +28,5 @@ api_router.include_router(kontrollen.router, tags=["kontrollen"])
|
||||
api_router.include_router(fehlbestaende.router, tags=["fehlbestaende"])
|
||||
api_router.include_router(eskalation.router, tags=["eskalation"])
|
||||
api_router.include_router(historie.router, tags=["historie"])
|
||||
api_router.include_router(geraet_instanz.router, tags=["geraet_instanz"])
|
||||
api_router.include_router(dashboard.router, tags=["dashboard"])
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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 KnotenTyp, RolleTyp, Systemknoten
|
||||
from app.models.geraet_instanz import GeraetInstanz
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.schemas.geraet_instanz import GeraetInstanzCreate, GeraetInstanzRead, GeraetInstanzStatusUpdate
|
||||
from app.services.geraet_instanz import aendere_status, erstelle, liste_fuer_position, loesche
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_materialverantwortliche = require_roles(
|
||||
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
|
||||
)
|
||||
|
||||
|
||||
async def _hauptserver_id(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.get("/objektpositionen/{position_id}/geraete", response_model=list[GeraetInstanzRead])
|
||||
async def liste_geraete(
|
||||
position_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
|
||||
) -> list[GeraetInstanz]:
|
||||
return await liste_fuer_position(db, position_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/objektpositionen/{position_id}/geraete",
|
||||
response_model=GeraetInstanzRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def erstelle_geraet(
|
||||
position_id: uuid.UUID,
|
||||
payload: GeraetInstanzCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_materialverantwortliche),
|
||||
) -> GeraetInstanz:
|
||||
"""Karte 14: löst objektposition.seriennummer als Einzelfeld ab - beliebig
|
||||
viele Geräte-Exemplare pro Position."""
|
||||
position = await db.get(Objektposition, position_id)
|
||||
if position is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
|
||||
try:
|
||||
return await erstelle(db, objektposition_id=position_id, seriennummer=payload.seriennummer)
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben"
|
||||
) from exc
|
||||
|
||||
|
||||
@router.patch("/geraete/{geraet_id}", response_model=GeraetInstanzRead)
|
||||
async def aendere_geraet_status(
|
||||
geraet_id: uuid.UUID,
|
||||
payload: GeraetInstanzStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(_materialverantwortliche),
|
||||
) -> GeraetInstanz:
|
||||
instanz = await db.get(GeraetInstanz, geraet_id)
|
||||
if instanz is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
|
||||
zustaendiger_server_id = await _hauptserver_id(db)
|
||||
aktualisiert, _fehlbestand_id = await aendere_status(
|
||||
db,
|
||||
instanz=instanz,
|
||||
status=payload.status,
|
||||
pruefdatum=payload.pruefdatum,
|
||||
bemerkung=payload.bemerkung,
|
||||
erzeuge_fehlbestand=payload.erzeuge_fehlbestand,
|
||||
benutzer_id=current_user.id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
return aktualisiert
|
||||
|
||||
|
||||
@router.delete("/geraete/{geraet_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def loesche_geraet(
|
||||
geraet_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
|
||||
) -> None:
|
||||
instanz = await db.get(GeraetInstanz, geraet_id)
|
||||
if instanz is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
await loesche(db, instanz)
|
||||
@@ -106,6 +106,7 @@ async def hole_objektposition_per_code(
|
||||
ablaufdatum=position.ablaufdatum,
|
||||
chargennummer=position.chargennummer,
|
||||
code=position.code,
|
||||
pruefintervall_monate=position.pruefintervall_monate,
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +180,7 @@ async def liste_objektpositionen(
|
||||
ablaufdatum=p.ablaufdatum,
|
||||
chargennummer=p.chargennummer,
|
||||
code=p.code,
|
||||
pruefintervall_monate=p.pruefintervall_monate,
|
||||
)
|
||||
for p in positionen
|
||||
]
|
||||
@@ -307,6 +309,7 @@ async def aendere_objektposition(
|
||||
ablaufdatum=position.ablaufdatum,
|
||||
chargennummer=position.chargennummer,
|
||||
code=position.code,
|
||||
pruefintervall_monate=position.pruefintervall_monate,
|
||||
)
|
||||
|
||||
|
||||
@@ -351,4 +354,5 @@ async def fuege_zusatzposition_hinzu(
|
||||
ablaufdatum=position.ablaufdatum,
|
||||
chargennummer=position.chargennummer,
|
||||
code=position.code,
|
||||
pruefintervall_monate=position.pruefintervall_monate,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.geraet_instanz import GeraetStatus
|
||||
|
||||
|
||||
class GeraetInstanzCreate(BaseModel):
|
||||
seriennummer: str
|
||||
|
||||
|
||||
class GeraetInstanzRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
objektposition_id: uuid.UUID
|
||||
seriennummer: str
|
||||
pruefdatum: date | None
|
||||
naechste_pruefung: date | None
|
||||
status: GeraetStatus
|
||||
bemerkung: str | None
|
||||
|
||||
|
||||
class GeraetInstanzStatusUpdate(BaseModel):
|
||||
status: GeraetStatus
|
||||
pruefdatum: date | None = None
|
||||
bemerkung: str | None = None
|
||||
# Karte 14: "pro Gerät entscheidbar" - defekt/in_reparatur kann optional
|
||||
# einen Fehlbestand erzeugen (nutzt bestehende Fehlbestand-Infrastruktur),
|
||||
# muss aber nicht (z.B. Ersatzgerät bereits vorhanden).
|
||||
erzeuge_fehlbestand: bool = False
|
||||
@@ -52,6 +52,7 @@ class ObjektpositionRead(BaseModel):
|
||||
ablaufdatum: date | None
|
||||
chargennummer: str | None
|
||||
code: str | None
|
||||
pruefintervall_monate: int | None
|
||||
|
||||
|
||||
class ObjektpositionUpdate(BaseModel):
|
||||
@@ -62,6 +63,7 @@ class ObjektpositionUpdate(BaseModel):
|
||||
ablaufdatum: date | None = None
|
||||
chargennummer: str | None = None
|
||||
code: str | None = None
|
||||
pruefintervall_monate: int | None = None
|
||||
|
||||
|
||||
class ObjektpositionCreate(BaseModel):
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import calendar
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.services import benachrichtigung as benachrichtigung_service
|
||||
from app.services import historie as historie_service
|
||||
|
||||
|
||||
def _plus_monate(stichtag: date, monate: int) -> date:
|
||||
"""Kein python-dateutil als Dependency für eine einzelne Monatsaddition -
|
||||
Tag wird auf das Monatsende gekappt, falls der Zielmonat kürzer ist
|
||||
(z.B. 31. Januar + 1 Monat -> 28./29. Februar)."""
|
||||
monats_index = stichtag.month - 1 + monate
|
||||
jahr = stichtag.year + monats_index // 12
|
||||
monat = monats_index % 12 + 1
|
||||
letzter_tag = calendar.monthrange(jahr, monat)[1]
|
||||
return date(jahr, monat, min(stichtag.day, letzter_tag))
|
||||
|
||||
|
||||
async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[GeraetInstanz]:
|
||||
result = await db.execute(
|
||||
select(GeraetInstanz).where(GeraetInstanz.objektposition_id == objektposition_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def erstelle(db: AsyncSession, *, objektposition_id, seriennummer: str) -> GeraetInstanz:
|
||||
instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer)
|
||||
db.add(instanz)
|
||||
await db.flush()
|
||||
return instanz
|
||||
|
||||
|
||||
async def loesche(db: AsyncSession, instanz: GeraetInstanz) -> None:
|
||||
await db.delete(instanz)
|
||||
|
||||
|
||||
async def aendere_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
instanz: GeraetInstanz,
|
||||
status: GeraetStatus,
|
||||
pruefdatum: date | None,
|
||||
bemerkung: str | None,
|
||||
erzeuge_fehlbestand: bool,
|
||||
benutzer_id: int,
|
||||
zustaendiger_server_id: int,
|
||||
) -> tuple[GeraetInstanz, uuid.UUID | None]:
|
||||
"""Karte 14: Status ändern (einsatzbereit/defekt/in_reparatur), optional
|
||||
Prüfung erfassen (naechste_pruefung wird neu berechnet, sofern die
|
||||
Objektposition ein pruefintervall_monate hat), optional einen Fehlbestand
|
||||
erzeugen (pro Gerät entscheidbar, nutzt bestehende Fehlbestand-
|
||||
Infrastruktur statt eigenem Parallel-Konzept)."""
|
||||
alter_status = instanz.status
|
||||
instanz.status = status
|
||||
instanz.bemerkung = bemerkung
|
||||
|
||||
if pruefdatum is not None:
|
||||
instanz.pruefdatum = pruefdatum
|
||||
objektposition = await db.get(Objektposition, instanz.objektposition_id)
|
||||
if objektposition is not None and objektposition.pruefintervall_monate:
|
||||
instanz.naechste_pruefung = _plus_monate(pruefdatum, objektposition.pruefintervall_monate)
|
||||
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="geraet_status_geaendert",
|
||||
entitaet_typ="geraet_instanz",
|
||||
entitaet_id=instanz.id,
|
||||
alter_wert={"status": alter_status.value},
|
||||
neuer_wert={"status": status.value, "seriennummer": instanz.seriennummer},
|
||||
begruendung=bemerkung,
|
||||
)
|
||||
|
||||
fehlbestand_id = None
|
||||
if erzeuge_fehlbestand and status != GeraetStatus.einsatzbereit:
|
||||
fehlbestand_id = await _erzeuge_fehlbestand(
|
||||
db,
|
||||
instanz=instanz,
|
||||
benutzer_id=benutzer_id,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
)
|
||||
|
||||
return instanz, fehlbestand_id
|
||||
|
||||
|
||||
async def _erzeuge_fehlbestand(
|
||||
db: AsyncSession, *, instanz: GeraetInstanz, benutzer_id: int, zustaendiger_server_id: int
|
||||
):
|
||||
objektposition = await db.get(Objektposition, instanz.objektposition_id)
|
||||
objekt = await db.get(Objekt, objektposition.objekt_id)
|
||||
|
||||
fehlbestand = Fehlbestand(
|
||||
erzeugt_von_server_id=zustaendiger_server_id,
|
||||
objekt_id=objektposition.objekt_id,
|
||||
material_id=objektposition.material_id,
|
||||
standort_id=objekt.standort_id,
|
||||
sollmenge=Decimal(1),
|
||||
istmenge=Decimal(0),
|
||||
fehlmenge=Decimal(1),
|
||||
entstanden_am=datetime.now(timezone.utc),
|
||||
festgestellt_von=benutzer_id,
|
||||
ursache=f"Gerät {instanz.seriennummer} ({instanz.status.value})",
|
||||
status=FehlbestandStatus.offen,
|
||||
)
|
||||
db.add(fehlbestand)
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="fehlbestand_entstanden",
|
||||
entitaet_typ="fehlbestand",
|
||||
entitaet_id=fehlbestand.id,
|
||||
neuer_wert={"ursache": fehlbestand.ursache, "geraet_instanz_id": str(instanz.id)},
|
||||
)
|
||||
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=fehlbestand)
|
||||
return fehlbestand.id
|
||||
@@ -97,3 +97,93 @@ async def test_pruefintervall_monate_default_und_setzen(db_session, objekt_mit_p
|
||||
await db_session.flush()
|
||||
await db_session.refresh(position)
|
||||
assert position.pruefintervall_monate == 24
|
||||
|
||||
|
||||
from tests.conftest import auth_header, login # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpunkte_crud_und_pruefdatum(client, db_session, objekt_mit_position, materialverantwortlicher_user):
|
||||
objekt, material = objekt_mit_position
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
|
||||
position_id = positionen.json()[0]["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/objekte/{objekt.id}/positionen/{position_id}",
|
||||
json={"pruefintervall_monate": 12},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
erstellt = await client.post(
|
||||
f"/api/v1/objektpositionen/{position_id}/geraete",
|
||||
json={"seriennummer": "SN-100"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert erstellt.status_code == 201
|
||||
geraet_id = erstellt.json()["id"]
|
||||
assert erstellt.json()["status"] == "einsatzbereit"
|
||||
|
||||
liste = await client.get(f"/api/v1/objektpositionen/{position_id}/geraete", headers=auth_header(token))
|
||||
assert len(liste.json()) == 1
|
||||
|
||||
doppelt = await client.post(
|
||||
f"/api/v1/objektpositionen/{position_id}/geraete",
|
||||
json={"seriennummer": "SN-100"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert doppelt.status_code == 409
|
||||
|
||||
geprueft = await client.patch(
|
||||
f"/api/v1/geraete/{geraet_id}",
|
||||
json={"status": "einsatzbereit", "pruefdatum": "2026-01-15"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert geprueft.status_code == 200
|
||||
assert geprueft.json()["naechste_pruefung"] == "2027-01-15"
|
||||
|
||||
geloescht = await client.delete(f"/api/v1/geraete/{geraet_id}", headers=auth_header(token))
|
||||
assert geloescht.status_code == 204
|
||||
leer = await client.get(f"/api/v1/objektpositionen/{position_id}/geraete", headers=auth_header(token))
|
||||
assert leer.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defekt_kann_fehlbestand_erzeugen(client, objekt_mit_position, materialverantwortlicher_user):
|
||||
objekt, material = objekt_mit_position
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
|
||||
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
|
||||
position_id = positionen.json()[0]["id"]
|
||||
|
||||
erstellt = await client.post(
|
||||
f"/api/v1/objektpositionen/{position_id}/geraete",
|
||||
json={"seriennummer": "SN-200"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
geraet_id = erstellt.json()["id"]
|
||||
|
||||
ohne_fehlbestand = await client.patch(
|
||||
f"/api/v1/geraete/{geraet_id}",
|
||||
json={"status": "defekt", "bemerkung": "Display kaputt"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert ohne_fehlbestand.status_code == 200
|
||||
|
||||
vorher = await client.get(
|
||||
"/api/v1/fehlbestaende", params={"objekt_id": objekt.id}, headers=auth_header(token)
|
||||
)
|
||||
anzahl_vorher = len(vorher.json())
|
||||
|
||||
mit_fehlbestand = await client.patch(
|
||||
f"/api/v1/geraete/{geraet_id}",
|
||||
json={"status": "defekt", "bemerkung": "Display kaputt", "erzeuge_fehlbestand": True},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert mit_fehlbestand.status_code == 200
|
||||
|
||||
nachher = await client.get(
|
||||
"/api/v1/fehlbestaende", params={"objekt_id": objekt.id}, headers=auth_header(token)
|
||||
)
|
||||
assert len(nachher.json()) == anzahl_vorher + 1
|
||||
|
||||
@@ -14,6 +14,7 @@ export const TEST_OBJEKT = {
|
||||
vorlage_id: 1,
|
||||
standort_id: 1,
|
||||
status: "aktiv",
|
||||
gezogen_von_objekt_id: null,
|
||||
};
|
||||
|
||||
export const TEST_MATERIAL = {
|
||||
@@ -41,6 +42,8 @@ export const TEST_POSITION = {
|
||||
seriennummer: null,
|
||||
ablaufdatum: null,
|
||||
chargennummer: null,
|
||||
code: null,
|
||||
pruefintervall_monate: null,
|
||||
};
|
||||
|
||||
export const TEST_VORLAGE = {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Objekt {
|
||||
vorlage_id: number | null;
|
||||
standort_id: number;
|
||||
status: string;
|
||||
gezogen_von_objekt_id: number | null;
|
||||
}
|
||||
|
||||
export interface Objektposition {
|
||||
@@ -19,6 +20,18 @@ export interface Objektposition {
|
||||
seriennummer: string | null;
|
||||
ablaufdatum: string | null;
|
||||
chargennummer: string | null;
|
||||
code: string | null;
|
||||
pruefintervall_monate: number | null;
|
||||
}
|
||||
|
||||
export interface GeraetInstanz {
|
||||
id: string;
|
||||
objektposition_id: string;
|
||||
seriennummer: string;
|
||||
pruefdatum: string | null;
|
||||
naechste_pruefung: string | null;
|
||||
status: "einsatzbereit" | "defekt" | "in_reparatur";
|
||||
bemerkung: string | null;
|
||||
}
|
||||
|
||||
export interface Kontrolle {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { GeraetInstanz } from "../../api/types";
|
||||
|
||||
interface Props {
|
||||
objektpositionId: string;
|
||||
pruefintervallMonate: number | null;
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<GeraetInstanz["status"], string> = {
|
||||
einsatzbereit: "OK",
|
||||
defekt: "Defekt",
|
||||
in_reparatur: "In Reparatur",
|
||||
};
|
||||
|
||||
/** Karte 14: löst das Einzelfeld objektposition.seriennummer ab - beliebig
|
||||
* viele Geräte-Exemplare pro Position (z.B. 2 Pulsoxymeter im selben
|
||||
* Rucksack), je mit eigenem Prüfdatum/Status. */
|
||||
export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, onFehler }: Props) {
|
||||
const [instanzen, setInstanzen] = useState<GeraetInstanz[]>([]);
|
||||
const [neueSeriennummer, setNeueSeriennummer] = useState("");
|
||||
const [bearbeiteId, setBearbeiteId] = useState<string | null>(null);
|
||||
const [statusAuswahl, setStatusAuswahl] = useState<GeraetInstanz["status"]>("einsatzbereit");
|
||||
const [pruefdatum, setPruefdatum] = useState("");
|
||||
const [bemerkung, setBemerkung] = useState("");
|
||||
const [erzeugeFehlbestand, setErzeugeFehlbestand] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const daten = await apiRequest<GeraetInstanz[]>(`/objektpositionen/${objektpositionId}/geraete`);
|
||||
setInstanzen(daten);
|
||||
} catch {
|
||||
onFehler("Geräte-Liste konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [objektpositionId]);
|
||||
|
||||
async function hinzufuegen() {
|
||||
if (!neueSeriennummer) return;
|
||||
try {
|
||||
await apiRequest(`/objektpositionen/${objektpositionId}/geraete`, {
|
||||
method: "POST",
|
||||
body: { seriennummer: neueSeriennummer },
|
||||
});
|
||||
setNeueSeriennummer("");
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Gerät konnte nicht hinzugefügt werden (Seriennummer evtl. schon vergeben).");
|
||||
}
|
||||
}
|
||||
|
||||
function bearbeiten(instanz: GeraetInstanz) {
|
||||
setBearbeiteId(bearbeiteId === instanz.id ? null : instanz.id);
|
||||
setStatusAuswahl(instanz.status);
|
||||
setPruefdatum("");
|
||||
setBemerkung(instanz.bemerkung ?? "");
|
||||
setErzeugeFehlbestand(false);
|
||||
}
|
||||
|
||||
async function speichernStatus(instanz: GeraetInstanz) {
|
||||
try {
|
||||
await apiRequest(`/geraete/${instanz.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
status: statusAuswahl,
|
||||
pruefdatum: pruefdatum || null,
|
||||
bemerkung: bemerkung || null,
|
||||
erzeuge_fehlbestand: statusAuswahl !== "einsatzbereit" && erzeugeFehlbestand,
|
||||
},
|
||||
});
|
||||
setBearbeiteId(null);
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Status konnte nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function entfernen(id: string) {
|
||||
try {
|
||||
await apiRequest(`/geraete/${id}`, { method: "DELETE" });
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Gerät konnte nicht entfernt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
{pruefintervallMonate === null && (
|
||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
Kein Prüfintervall gesetzt – Prüfdatum wird gespeichert, nächste Fälligkeit aber nicht berechnet.
|
||||
</p>
|
||||
)}
|
||||
<ul className="card-list">
|
||||
{instanzen.map((instanz) => (
|
||||
<li key={instanz.id} style={{ padding: "0.35rem 0" }}>
|
||||
<div className="row-between">
|
||||
<span>
|
||||
{instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "}
|
||||
<span className="badge badge-neutral">{STATUS_LABEL[instanz.status]}</span>{" "}
|
||||
{instanz.naechste_pruefung && (
|
||||
<span className="text-muted">nächste Prüfung: {instanz.naechste_pruefung}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="row">
|
||||
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
|
||||
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => entfernen(instanz.id)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{bearbeiteId === instanz.id && (
|
||||
<div className="stack" style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={statusAuswahl}
|
||||
onChange={(e) => setStatusAuswahl(e.target.value as GeraetInstanz["status"])}
|
||||
>
|
||||
<option value="einsatzbereit">Einsatzbereit</option>
|
||||
<option value="defekt">Defekt</option>
|
||||
<option value="in_reparatur">In Reparatur</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={pruefdatum}
|
||||
onChange={(e) => setPruefdatum(e.target.value)}
|
||||
placeholder="Prüfdatum"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
value={bemerkung}
|
||||
onChange={(e) => setBemerkung(e.target.value)}
|
||||
placeholder="Bemerkung"
|
||||
/>
|
||||
</div>
|
||||
{statusAuswahl !== "einsatzbereit" && (
|
||||
<label className="row" style={{ gap: "0.4rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={erzeugeFehlbestand}
|
||||
onChange={(e) => setErzeugeFehlbestand(e.target.checked)}
|
||||
/>
|
||||
Fehlbestand erzeugen
|
||||
</label>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => speichernStatus(instanz)}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row" style={{ marginTop: "0.5rem" }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Neue Seriennummer"
|
||||
value={neueSeriennummer}
|
||||
onChange={(e) => setNeueSeriennummer(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={hinzufuegen} disabled={!neueSeriennummer}>
|
||||
Gerät hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { Material, Objektposition } from "../../api/types";
|
||||
import { GeraeteInstanzenListe } from "./GeraeteInstanzenListe";
|
||||
|
||||
interface Props {
|
||||
objektId: number;
|
||||
@@ -12,14 +13,14 @@ interface Props {
|
||||
interface ZeilenFormular {
|
||||
ablaufdatum: string;
|
||||
chargennummer: string;
|
||||
seriennummer: string;
|
||||
pruefintervallMonate: string;
|
||||
}
|
||||
|
||||
function leeresFormular(p: Objektposition): ZeilenFormular {
|
||||
return {
|
||||
ablaufdatum: p.ablaufdatum ?? "",
|
||||
chargennummer: p.chargennummer ?? "",
|
||||
seriennummer: p.seriennummer ?? "",
|
||||
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
||||
body: {
|
||||
ablaufdatum: formular.ablaufdatum || null,
|
||||
chargennummer: formular.chargennummer || null,
|
||||
seriennummer: formular.seriennummer || null,
|
||||
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
|
||||
},
|
||||
});
|
||||
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
|
||||
@@ -113,18 +114,30 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
|
||||
)}
|
||||
{typ === "geraet_sn" && (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
placeholder="Seriennummer"
|
||||
value={formular.seriennummer}
|
||||
placeholder="Prüfintervall (Monate)"
|
||||
value={formular.pruefintervallMonate}
|
||||
onChange={(e) =>
|
||||
setFormulare((f) => ({ ...f, [p.id]: { ...formular, seriennummer: e.target.value } }))
|
||||
setFormulare((f) => ({
|
||||
...f,
|
||||
[p.id]: { ...formular, pruefintervallMonate: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
|
||||
Speichern
|
||||
{typ === "geraet_sn" ? "Prüfintervall speichern" : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
{typ === "geraet_sn" && (
|
||||
<GeraeteInstanzenListe
|
||||
objektpositionId={p.id}
|
||||
pruefintervallMonate={p.pruefintervall_monate}
|
||||
onFehler={onFehler}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,6 +26,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
const [neuerCode, setNeuerCode] = useState("");
|
||||
const [neuerName, setNeuerName] = useState("");
|
||||
const [neuerStandortId, setNeuerStandortId] = useState<number | "">("");
|
||||
const [gezogenVonId, setGezogenVonId] = useState<number | "">("");
|
||||
const [kopplungBearbeitenId, setKopplungBearbeitenId] = useState<number | null>(null);
|
||||
const [kopplungAuswahl, setKopplungAuswahl] = useState<number | "">("");
|
||||
|
||||
async function codeVorschlagen() {
|
||||
if (!praefix) {
|
||||
@@ -73,11 +76,13 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
objekttyp_id: vorlage?.objekttyp_id,
|
||||
vorlage_id: vorlageId,
|
||||
standort_id: standortId,
|
||||
gezogen_von_objekt_id: gezogenVonId || null,
|
||||
},
|
||||
});
|
||||
setPraefix("");
|
||||
setCode("");
|
||||
setName("");
|
||||
setGezogenVonId("");
|
||||
await onGeaendert();
|
||||
} catch {
|
||||
onFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben).");
|
||||
@@ -86,6 +91,30 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
}
|
||||
}
|
||||
|
||||
function objektName(id: number | null): string {
|
||||
if (id === null) return "";
|
||||
const o = objekte.find((x) => x.id === id);
|
||||
return o ? `${o.name} (${o.code})` : `Objekt ${id}`;
|
||||
}
|
||||
|
||||
function kopplungBearbeiten(o: Objekt) {
|
||||
setKopplungBearbeitenId(kopplungBearbeitenId === o.id ? null : o.id);
|
||||
setKopplungAuswahl(o.gezogen_von_objekt_id ?? "");
|
||||
}
|
||||
|
||||
async function kopplungSpeichern(objektId: number) {
|
||||
try {
|
||||
await apiRequest(`/objekte/${objektId}`, {
|
||||
method: "PATCH",
|
||||
body: { gezogen_von_objekt_id: kopplungAuswahl || null },
|
||||
});
|
||||
setKopplungBearbeitenId(null);
|
||||
await onGeaendert();
|
||||
} catch {
|
||||
onFehler("Kopplung konnte nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function duplizierenStarten(o: Objekt) {
|
||||
setDuplizierId(duplizierId === o.id ? null : o.id);
|
||||
setNeuerPraefix("");
|
||||
@@ -167,6 +196,21 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
<label>Name</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Gezogen von (optional, Anhänger-Kopplung)</label>
|
||||
<select
|
||||
className="input"
|
||||
value={gezogenVonId}
|
||||
onChange={(e) => setGezogenVonId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Kein Zugfahrzeug</option>
|
||||
{objekte.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name} ({o.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={anlegen} disabled={!code || !name || wirdAngelegt}>
|
||||
Anlegen
|
||||
</button>
|
||||
@@ -180,6 +224,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
<div className="row-between" style={{ padding: "0.4rem 0" }}>
|
||||
<span>
|
||||
{o.name} <span className="text-muted">({o.code})</span>
|
||||
{o.gezogen_von_objekt_id !== null && (
|
||||
<span className="text-muted"> – gezogen von {objektName(o.gezogen_von_objekt_id)}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="row">
|
||||
<button
|
||||
@@ -188,11 +235,35 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
|
||||
>
|
||||
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => kopplungBearbeiten(o)}>
|
||||
{kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
|
||||
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{kopplungBearbeitenId === o.id && (
|
||||
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={kopplungAuswahl}
|
||||
onChange={(e) => setKopplungAuswahl(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Kein Zugfahrzeug</option>
|
||||
{objekte
|
||||
.filter((x) => x.id !== o.id)
|
||||
.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name} ({x.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={() => kopplungSpeichern(o.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ausgeklapptId === o.id && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />
|
||||
|
||||
Reference in New Issue
Block a user