diff --git a/DEVLOG.md b/DEVLOG.md index 1277c30..3e9aeba 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++++++++++++++++++++ + +--- diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md index 6aace41..1df655e 100644 --- a/backend/DEVLOG.md +++ b/backend/DEVLOG.md @@ -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 +++++++++++++++++++++++++++ + +--- diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index b1edb6a..9b71fef 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/geraet_instanz.py b/backend/app/api/v1/endpoints/geraet_instanz.py new file mode 100644 index 0000000..ae13d74 --- /dev/null +++ b/backend/app/api/v1/endpoints/geraet_instanz.py @@ -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) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 3ef59aa..b2576bb 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -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, ) diff --git a/backend/app/schemas/geraet_instanz.py b/backend/app/schemas/geraet_instanz.py new file mode 100644 index 0000000..a018042 --- /dev/null +++ b/backend/app/schemas/geraet_instanz.py @@ -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 diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index 1b96db3..843adf6 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -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): diff --git a/backend/app/services/geraet_instanz.py b/backend/app/services/geraet_instanz.py new file mode 100644 index 0000000..7e8fa1a --- /dev/null +++ b/backend/app/services/geraet_instanz.py @@ -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 diff --git a/backend/tests/test_geraet_instanz.py b/backend/tests/test_geraet_instanz.py index 7387e10..9e84029 100644 --- a/backend/tests/test_geraet_instanz.py +++ b/backend/tests/test_geraet_instanz.py @@ -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 diff --git a/frontend/e2e/mockApi.ts b/frontend/e2e/mockApi.ts index 532665f..e4a9708 100644 --- a/frontend/e2e/mockApi.ts +++ b/frontend/e2e/mockApi.ts @@ -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 = { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 90e8103..7077a46 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 { diff --git a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx new file mode 100644 index 0000000..e675b81 --- /dev/null +++ b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx @@ -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 = { + 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([]); + const [neueSeriennummer, setNeueSeriennummer] = useState(""); + const [bearbeiteId, setBearbeiteId] = useState(null); + const [statusAuswahl, setStatusAuswahl] = useState("einsatzbereit"); + const [pruefdatum, setPruefdatum] = useState(""); + const [bemerkung, setBemerkung] = useState(""); + const [erzeugeFehlbestand, setErzeugeFehlbestand] = useState(false); + + async function laden() { + try { + const daten = await apiRequest(`/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 ( +
+ {pruefintervallMonate === null && ( +

+ Kein Prüfintervall gesetzt – Prüfdatum wird gespeichert, nächste Fälligkeit aber nicht berechnet. +

+ )} +
    + {instanzen.map((instanz) => ( +
  • +
    + + {instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "} + {STATUS_LABEL[instanz.status]}{" "} + {instanz.naechste_pruefung && ( + nächste Prüfung: {instanz.naechste_pruefung} + )} + +
    + + +
    +
    + {bearbeiteId === instanz.id && ( +
    +
    + + setPruefdatum(e.target.value)} + placeholder="Prüfdatum" + /> + setBemerkung(e.target.value)} + placeholder="Bemerkung" + /> +
    + {statusAuswahl !== "einsatzbereit" && ( + + )} + +
    + )} +
  • + ))} +
+
+ setNeueSeriennummer(e.target.value)} + /> + +
+
+ ); +} diff --git a/frontend/src/pages/admin/ObjektPositionenPanel.tsx b/frontend/src/pages/admin/ObjektPositionenPanel.tsx index 0468659..d53683c 100644 --- a/frontend/src/pages/admin/ObjektPositionenPanel.tsx +++ b/frontend/src/pages/admin/ObjektPositionenPanel.tsx @@ -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" && ( - setFormulare((f) => ({ ...f, [p.id]: { ...formular, seriennummer: e.target.value } })) + setFormulare((f) => ({ + ...f, + [p.id]: { ...formular, pruefintervallMonate: e.target.value }, + })) } /> )} + {typ === "geraet_sn" && ( + + )} ); })} diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index c360544..173f885 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -26,6 +26,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea const [neuerCode, setNeuerCode] = useState(""); const [neuerName, setNeuerName] = useState(""); const [neuerStandortId, setNeuerStandortId] = useState(""); + const [gezogenVonId, setGezogenVonId] = useState(""); + const [kopplungBearbeitenId, setKopplungBearbeitenId] = useState(null); + const [kopplungAuswahl, setKopplungAuswahl] = useState(""); 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 setName(e.target.value)} /> +
+ + +
@@ -180,6 +224,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
{o.name} ({o.code}) + {o.gezogen_von_objekt_id !== null && ( + – gezogen von {objektName(o.gezogen_von_objekt_id)} + )}
+
+ {kopplungBearbeitenId === o.id && ( +
+ + +
+ )} {ausgeklapptId === o.id && (