Karte 14 Schritt 2 (Admin-Teil) + Frontend für Anhänger-Kopplung
CI / backend-tests (push) Failing after 1m21s
CI / frontend-build (push) Successful in 22s

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:
2026-09-04 20:55:09 +02:00
co-authored by Claude Sonnet 5
parent eff9fe4956
commit 6934276e8a
14 changed files with 685 additions and 7 deletions
+34
View File
@@ -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 +++++++++++++++++++++++++++
---
+17
View File
@@ -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 +++++++++++++++++++++++++++
---
+2
View File
@@ -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)
+4
View File
@@ -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,
)
+31
View File
@@ -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
+2
View File
@@ -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):
+130
View File
@@ -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
+90
View File
@@ -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
+3
View File
@@ -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 = {
+13
View File
@@ -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} />