Refactor: große Dateien in kleinere Module/Komponenten aufgeteilt
CI / backend-tests (push) Successful in 53s
CI / frontend-build (push) Successful in 34s

Explizite Vorgabe: keine wachsenden Monolith-Dateien. AdminPage.tsx (349
Zeilen) in pages/admin/{Standort,Material,Objekt}Section.tsx aufgeteilt,
KontrollPage.tsx (277 Zeilen) in pages/kontrolle/{useKontrolle,
PositionCard,StatusBadge,types} aufgeteilt, services/kontrolle.py (332
Zeilen) in ein Package (lifecycle/erfassung/exceptions) aufgeteilt -
öffentliche API bleibt über __init__.py unverändert re-exportiert, kein
bestehender Import musste angepasst werden. Reines Refactoring, keine
Verhaltensänderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-04 00:45:18 +02:00
co-authored by Claude Sonnet 5
parent 08b10c7c37
commit f6084e2d5a
14 changed files with 1079 additions and 661 deletions
+263
View File
@@ -1444,3 +1444,266 @@ Keine Commits in dieser Session.
- frontend/vite.config.ts | 36 ++++++++++
---
## 2026-09-04 00:13 00:16 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- f2ca0ed Sprint 8: Excel-Import-Skript + CI Frontend-Build-Job
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:16 00:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:17 00:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:17 00:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:17 00:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:18 00:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:19 00:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:19 00:19 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:21 00:22 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitea/workflows/ci.yml | 16 ++++++
- DEVLOG.md | 38 +++++++++++++++
- backend/scripts/__init__.py | 0
- backend/scripts/import_excel.py | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- ergebnisse/testphasen.md | 1 +
---
## 2026-09-04 00:22 00:25 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 3ecca4c deploy: nginx liefert React-PWA-Build statt Backend-Proxy auf /
### Geänderte Dateien
- deploy/nginx_mabea.conf.template | 11 ++++++++++-
---
## 2026-09-04 00:25 00:28 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 8943e36 fix(import): nicht-numerische Menge überspringen statt Import abzubrechen
- 6503beb fix(import): Mengen zusammenführen bei Mehrfach-Fach desselben Materials
- f4c1124 Sprint 8: Admin-Seed-Skript für Erstinbetriebnahme
### Geänderte Dateien
- backend/scripts/import_excel.py | 11 ++++++++++-
---
## 2026-09-04 00:30 00:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/scripts/import_excel.py | 11 ++++++++++-
---
## 2026-09-04 00:30 00:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/App.tsx | 20 ++++++++++++
- frontend/src/api/types.ts | 21 +++++++++++++
- frontend/src/auth/AuthContext.tsx | 20 ++++++++++--
- frontend/src/pages/AdminPage.tsx | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/ObjektListPage.tsx | 7 +++++
---
## 2026-09-04 00:33 00:33 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/App.tsx | 20 ++++++++++++
- frontend/src/api/types.ts | 21 +++++++++++++
- frontend/src/auth/AuthContext.tsx | 20 ++++++++++--
- frontend/src/pages/AdminPage.tsx | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/ObjektListPage.tsx | 7 +++++
---
## 2026-09-04 00:34 00:34 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/App.tsx | 20 ++++++++++++
- frontend/src/api/types.ts | 21 +++++++++++++
- frontend/src/auth/AuthContext.tsx | 20 ++++++++++--
- frontend/src/pages/AdminPage.tsx | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/ObjektListPage.tsx | 7 +++++
---
## 2026-09-04 00:35 00:38 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- e3ed5e0 Sprint 8: modernes Design-System für die React-PWA
### Geänderte Dateien
- frontend/src/App.tsx | 5 +-
- frontend/src/components/AppShell.tsx | 39 ++++++++++
- frontend/src/main.tsx | 1 +
- frontend/src/pages/AdminPage.tsx | 166 +++++++++++++++++++++++++++++------------
- frontend/src/pages/KontrollPage.tsx | 107 ++++++++++++++-------------
- frontend/src/pages/LoginPage.tsx | 66 +++++++++--------
- frontend/src/pages/ObjektListPage.tsx | 47 ++++++------
- frontend/src/styles/global.css | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 00:38 00:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/App.tsx | 5 +-
- frontend/src/components/AppShell.tsx | 39 ++++++++++
- frontend/src/main.tsx | 1 +
- frontend/src/pages/AdminPage.tsx | 166 +++++++++++++++++++++++++++++------------
- frontend/src/pages/KontrollPage.tsx | 107 ++++++++++++++-------------
- frontend/src/pages/LoginPage.tsx | 66 +++++++++--------
- frontend/src/pages/ObjektListPage.tsx | 47 ++++++------
- frontend/src/styles/global.css | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-04 00:39 00:40 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 08b10c7 Sprint 8: Material anlegen/bearbeiten im Admin-Portal
### Geänderte Dateien
- frontend/src/api/types.ts | 7 ++++
- frontend/src/pages/AdminPage.tsx | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
---
@@ -0,0 +1,30 @@
"""Kontroll-Domänenlogik (Prompt 02.3/09/16), aufgeteilt in Module:
- exceptions: ObjektGesperrtError/UnvollstaendigError
- lifecycle: Start/Übernahme/Abschluss/Abbruch, Mindermenge-Ablauf (U6)
- erfassung: Position erfassen + Fehlbestand-Synchronisierung
Öffentliche API bleibt hier re-exportiert, damit bestehender Code
(`from app.services.kontrolle import starte_kontrolle`) unverändert funktioniert.
"""
from .erfassung import erfasse_position
from .exceptions import ObjektGesperrtError, UnvollstaendigError
from .lifecycle import (
abbreche_kontrolle,
abschliesse_kontrolle,
aktive_kontrolle,
fehlende_material_ids,
starte_kontrolle,
)
__all__ = [
"ObjektGesperrtError",
"UnvollstaendigError",
"abbreche_kontrolle",
"abschliesse_kontrolle",
"aktive_kontrolle",
"erfasse_position",
"fehlende_material_ids",
"starte_kontrolle",
]
+153
View File
@@ -0,0 +1,153 @@
import uuid
from datetime import 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.kontrolle import Kontrolle, Kontrollposition
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
from app.services.objekte import sollmenge_effektiv, sollmenge_map
async def erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> tuple[Kontrollposition, uuid.UUID | None]:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die
Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine
Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
)
)
objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0)
abweichung = istmenge != sollmenge
result = await db.execute(
select(Kontrollposition).where(
Kontrollposition.kontrolle_id == kontrolle.id, Kontrollposition.material_id == material_id
)
)
kontrollposition = result.scalar_one_or_none()
if kontrollposition is None:
kontrollposition = Kontrollposition(
kontrolle_id=kontrolle.id,
material_id=material_id,
sollmenge_snapshot=sollmenge,
istmenge_erfasst=istmenge,
abweichung=abweichung,
)
db.add(kontrollposition)
else:
kontrollposition.sollmenge_snapshot = sollmenge
kontrollposition.istmenge_erfasst = istmenge
kontrollposition.abweichung = abweichung
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="istmenge_erfasst",
entitaet_typ="kontrollposition",
entitaet_id=kontrollposition.id,
neuer_wert={"material_id": material_id, "sollmenge": str(sollmenge), "istmenge": str(istmenge)},
)
fehlbestand_id = await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
sollmenge=sollmenge,
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> uuid.UUID | None:
result = await db.execute(
select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id,
Fehlbestand.material_id == material_id,
Fehlbestand.status == FehlbestandStatus.offen,
)
)
bestehender = result.scalar_one_or_none()
unterdeckung = sollmenge - istmenge
if unterdeckung > 0:
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand.
if bestehender is None:
bestehender = Fehlbestand(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt.id,
material_id=material_id,
standort_id=objekt.standort_id,
sollmenge=sollmenge,
istmenge=istmenge,
fehlmenge=unterdeckung,
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=kontrolle.benutzer_id,
kontrolle_id=kontrolle.id,
status=FehlbestandStatus.offen,
)
db.add(bestehender)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="fehlbestand_entstanden",
entitaet_typ="fehlbestand",
entitaet_id=bestehender.id,
neuer_wert={
"objekt_id": objekt.id,
"material_id": material_id,
"sollmenge": str(sollmenge),
"istmenge": str(istmenge),
"fehlmenge": str(unterdeckung),
},
)
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht
# final und darf berichtigt werden.
bestehender.sollmenge = sollmenge
bestehender.istmenge = istmenge
bestehender.fehlmenge = unterdeckung
elif bestehender is not None:
# Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle
# erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde.
await db.delete(bestehender)
bestehender = None
await db.flush()
return bestehender.id if bestehender is not None else None
@@ -0,0 +1,17 @@
from app.models.kontrolle import Kontrolle
class ObjektGesperrtError(Exception):
"""Prompt 02.8: Objekt wird bereits von einer anderen, laufenden Kontrolle belegt."""
def __init__(self, aktive_kontrolle: Kontrolle):
self.aktive_kontrolle = aktive_kontrolle
super().__init__("Objekt wird bereits kontrolliert")
class UnvollstaendigError(Exception):
"""Prompt 16.1: Abschluss mit unbestätigten Positionen."""
def __init__(self, fehlende_material_ids: list[int]):
self.fehlende_material_ids = fehlende_material_ids
super().__init__("Nicht alle Positionen wurden erfasst")
@@ -1,34 +1,15 @@
import uuid
from datetime import 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.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services import benachrichtigung as benachrichtigung_service
from app.services import historie as historie_service
from app.services.objekte import sollmenge_effektiv, sollmenge_map
class ObjektGesperrtError(Exception):
"""Prompt 02.8: Objekt wird bereits von einer anderen, laufenden Kontrolle belegt."""
def __init__(self, aktive_kontrolle: Kontrolle):
self.aktive_kontrolle = aktive_kontrolle
super().__init__("Objekt wird bereits kontrolliert")
class UnvollstaendigError(Exception):
"""Prompt 16.1: Abschluss mit unbestätigten Positionen."""
def __init__(self, fehlende_material_ids: list[int]):
self.fehlende_material_ids = fehlende_material_ids
super().__init__("Nicht alle Positionen wurden erfasst")
from .exceptions import ObjektGesperrtError, UnvollstaendigError
async def aktive_kontrolle(db: AsyncSession, objekt_id: int) -> Kontrolle | None:
@@ -136,145 +117,6 @@ async def _verwirf_kontrolldaten(db: AsyncSession, kontrolle_id) -> None:
await db.flush()
async def erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> tuple[Kontrollposition, uuid.UUID | None]:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die
Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine
Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
)
)
objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0)
abweichung = istmenge != sollmenge
result = await db.execute(
select(Kontrollposition).where(
Kontrollposition.kontrolle_id == kontrolle.id, Kontrollposition.material_id == material_id
)
)
kontrollposition = result.scalar_one_or_none()
if kontrollposition is None:
kontrollposition = Kontrollposition(
kontrolle_id=kontrolle.id,
material_id=material_id,
sollmenge_snapshot=sollmenge,
istmenge_erfasst=istmenge,
abweichung=abweichung,
)
db.add(kontrollposition)
else:
kontrollposition.sollmenge_snapshot = sollmenge
kontrollposition.istmenge_erfasst = istmenge
kontrollposition.abweichung = abweichung
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="istmenge_erfasst",
entitaet_typ="kontrollposition",
entitaet_id=kontrollposition.id,
neuer_wert={"material_id": material_id, "sollmenge": str(sollmenge), "istmenge": str(istmenge)},
)
fehlbestand_id = await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
sollmenge=sollmenge,
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> uuid.UUID | None:
result = await db.execute(
select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id,
Fehlbestand.material_id == material_id,
Fehlbestand.status == FehlbestandStatus.offen,
)
)
bestehender = result.scalar_one_or_none()
unterdeckung = sollmenge - istmenge
if unterdeckung > 0:
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand.
if bestehender is None:
bestehender = Fehlbestand(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt.id,
material_id=material_id,
standort_id=objekt.standort_id,
sollmenge=sollmenge,
istmenge=istmenge,
fehlmenge=unterdeckung,
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=kontrolle.benutzer_id,
kontrolle_id=kontrolle.id,
status=FehlbestandStatus.offen,
)
db.add(bestehender)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="fehlbestand_entstanden",
entitaet_typ="fehlbestand",
entitaet_id=bestehender.id,
neuer_wert={
"objekt_id": objekt.id,
"material_id": material_id,
"sollmenge": str(sollmenge),
"istmenge": str(istmenge),
"fehlmenge": str(unterdeckung),
},
)
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht
# final und darf berichtigt werden.
bestehender.sollmenge = sollmenge
bestehender.istmenge = istmenge
bestehender.fehlmenge = unterdeckung
elif bestehender is not None:
# Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle
# erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde.
await db.delete(bestehender)
bestehender = None
await db.flush()
return bestehender.id if bestehender is not None else None
async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]:
result = await db.execute(
select(Objektposition.material_id).where(
+14 -293
View File
@@ -2,19 +2,9 @@ import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import type { Beladungsvorlage, Material, Objekt, Standort } from "../api/types";
const MATERIALTYPEN = [
{ value: "standard", label: "Standard" },
{ value: "ablauf_charge", label: "Ablauf/Charge" },
{ value: "geraet_sn", label: "Gerät mit Seriennummer" },
];
const LEERES_MATERIAL_FORMULAR = {
name: "",
artikelnummer: "",
einheit: "Stück",
materialtyp: "standard",
};
import { MaterialSection } from "./admin/MaterialSection";
import { ObjektSection } from "./admin/ObjektSection";
import { StandortSection } from "./admin/StandortSection";
/**
* Minimale Admin-Seite (Standort/Material/Objekt-Anlage), nachgezogen weil
@@ -22,6 +12,9 @@ const LEERES_MATERIAL_FORMULAR = {
* und Stammdaten/Objekte nur per API/Skript angelegt werden konnten. Bewusst
* schlank: keine Bereich-/Kategorie-/Vorlagen-CRUD, nur was für die
* Pilotphase sofort nötig ist (Vorlagen kommen aus dem Excel-Import).
*
* Jeder Abschnitt (Standort/Material/Objekt) ist eine eigene Komponente unter
* pages/admin/ - hält diese Datei als reinen Orchestrator klein.
*/
export function AdminPage() {
const [standorte, setStandorte] = useState<Standort[]>([]);
@@ -31,19 +24,6 @@ export function AdminPage() {
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
const [standortName, setStandortName] = useState("");
const [standortWirdAngelegt, setStandortWirdAngelegt] = useState(false);
const [objektCode, setObjektCode] = useState("");
const [objektName, setObjektName] = useState("");
const [vorlageId, setVorlageId] = useState<number | "">("");
const [standortId, setStandortId] = useState<number | "">("");
const [objektWirdAngelegt, setObjektWirdAngelegt] = useState(false);
const [materialFormular, setMaterialFormular] = useState(LEERES_MATERIAL_FORMULAR);
const [bearbeiteterMaterialId, setBearbeiteterMaterialId] = useState<number | null>(null);
const [materialWirdGespeichert, setMaterialWirdGespeichert] = useState(false);
async function ladeAlles() {
const [s, v, o, m] = await Promise.all([
apiRequest<Standort[]>("/standorte"),
@@ -57,104 +37,12 @@ export function AdminPage() {
setMaterialien(m);
}
function materialBearbeiten(material: Material) {
setBearbeiteterMaterialId(material.id);
setMaterialFormular({
name: material.name,
artikelnummer: material.artikelnummer ?? "",
einheit: material.einheit,
materialtyp: material.materialtyp,
});
}
function materialFormularZuruecksetzen() {
setBearbeiteterMaterialId(null);
setMaterialFormular(LEERES_MATERIAL_FORMULAR);
}
async function materialSpeichern() {
setFehler(null);
setMaterialWirdGespeichert(true);
try {
if (bearbeiteterMaterialId !== null) {
await apiRequest(`/materialien/${bearbeiteterMaterialId}`, {
method: "PATCH",
body: {
name: materialFormular.name,
artikelnummer: materialFormular.artikelnummer || null,
einheit: materialFormular.einheit,
materialtyp: materialFormular.materialtyp,
},
});
} else {
await apiRequest("/materialien", {
method: "POST",
body: {
name: materialFormular.name,
artikelnummer: materialFormular.artikelnummer || null,
einheit: materialFormular.einheit,
materialtyp: materialFormular.materialtyp,
},
});
}
materialFormularZuruecksetzen();
await ladeAlles();
} catch {
setFehler("Material konnte nicht gespeichert werden.");
} finally {
setMaterialWirdGespeichert(false);
}
}
useEffect(() => {
ladeAlles()
.catch(() => setFehler("Stammdaten konnten nicht geladen werden."))
.finally(() => setLaedt(false));
}, []);
async function standortAnlegen() {
setFehler(null);
setStandortWirdAngelegt(true);
try {
await apiRequest("/standorte", { method: "POST", body: { name: standortName } });
setStandortName("");
await ladeAlles();
} catch {
setFehler("Standort konnte nicht angelegt werden.");
} finally {
setStandortWirdAngelegt(false);
}
}
async function objektAnlegen() {
if (vorlageId === "" || standortId === "") {
setFehler("Vorlage und Standort auswählen.");
return;
}
setFehler(null);
setObjektWirdAngelegt(true);
const vorlage = vorlagen.find((v) => v.id === vorlageId);
try {
await apiRequest("/objekte", {
method: "POST",
body: {
code: objektCode,
name: objektName,
objekttyp_id: vorlage?.objekttyp_id,
vorlage_id: vorlageId,
standort_id: standortId,
},
});
setObjektCode("");
setObjektName("");
await ladeAlles();
} catch {
setFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben).");
} finally {
setObjektWirdAngelegt(false);
}
}
if (laedt) {
return (
<main className="page">
@@ -168,182 +56,15 @@ export function AdminPage() {
<h1>Administration</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
<section className="section">
<h2>Standort anlegen</h2>
<div className="card">
<div className="row" style={{ marginBottom: standorte.length ? "1rem" : 0 }}>
<input
className="input"
value={standortName}
onChange={(e) => setStandortName(e.target.value)}
placeholder="Name des Standorts"
<StandortSection standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
<MaterialSection materialien={materialien} onGeaendert={ladeAlles} onFehler={setFehler} />
<ObjektSection
objekte={objekte}
vorlagen={vorlagen}
standorte={standorte}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
<button
className="btn btn-primary"
onClick={standortAnlegen}
disabled={!standortName || standortWirdAngelegt}
>
Anlegen
</button>
</div>
{standorte.length > 0 && (
<ul className="card-list">
{standorte.map((s) => (
<li key={s.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{s.name}</span>
</li>
))}
</ul>
)}
</div>
</section>
<section className="section">
<h2>Material {bearbeiteterMaterialId !== null ? "bearbeiten" : "anlegen"}</h2>
<div className="card">
<div className="stack" style={{ marginBottom: "1.25rem" }}>
<div className="field">
<label>Name</label>
<input
className="input"
value={materialFormular.name}
onChange={(e) => setMaterialFormular({ ...materialFormular, name: e.target.value })}
/>
</div>
<div className="row">
<div className="field" style={{ flex: 1 }}>
<label>Artikelnummer</label>
<input
className="input"
value={materialFormular.artikelnummer}
onChange={(e) => setMaterialFormular({ ...materialFormular, artikelnummer: e.target.value })}
/>
</div>
<div className="field" style={{ width: "7rem" }}>
<label>Einheit</label>
<input
className="input"
value={materialFormular.einheit}
onChange={(e) => setMaterialFormular({ ...materialFormular, einheit: e.target.value })}
/>
</div>
</div>
<div className="field">
<label>Materialtyp</label>
<select
className="input"
value={materialFormular.materialtyp}
onChange={(e) => setMaterialFormular({ ...materialFormular, materialtyp: e.target.value })}
>
{MATERIALTYPEN.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="row">
<button
className="btn btn-primary"
onClick={materialSpeichern}
disabled={!materialFormular.name || !materialFormular.einheit || materialWirdGespeichert}
>
{bearbeiteterMaterialId !== null ? "Speichern" : "Anlegen"}
</button>
{bearbeiteterMaterialId !== null && (
<button className="btn btn-secondary" onClick={materialFormularZuruecksetzen}>
Abbrechen
</button>
)}
</div>
</div>
{materialien.length > 0 && (
<ul className="card-list">
{materialien.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
{m.name} <span className="text-muted">({m.einheit})</span>
</span>
<button className="btn btn-secondary" onClick={() => materialBearbeiten(m)}>
Bearbeiten
</button>
</li>
))}
</ul>
)}
</div>
</section>
<section className="section">
<h2>Objekt aus Vorlage anlegen</h2>
<div className="card">
{vorlagen.length === 0 ? (
<p className="text-muted">
Keine aktive Vorlage vorhanden erst Excel-Import oder Vorlage per API anlegen.
</p>
) : (
<div className="stack">
<div className="field">
<label>Vorlage</label>
<select
className="input"
value={vorlageId}
onChange={(e) => setVorlageId(Number(e.target.value) || "")}
>
<option value="">Vorlage wählen</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name} (v{v.version})
</option>
))}
</select>
</div>
<div className="field">
<label>Standort</label>
<select
className="input"
value={standortId}
onChange={(e) => setStandortId(Number(e.target.value) || "")}
>
<option value="">Standort wählen</option>
{standorte.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Code (eindeutig)</label>
<input className="input" value={objektCode} onChange={(e) => setObjektCode(e.target.value)} />
</div>
<div className="field">
<label>Name</label>
<input className="input" value={objektName} onChange={(e) => setObjektName(e.target.value)} />
</div>
<button
className="btn btn-primary"
onClick={objektAnlegen}
disabled={!objektCode || !objektName || objektWirdAngelegt}
>
Anlegen
</button>
</div>
)}
{objekte.length > 0 && (
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
{objekte.map((o) => (
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{o.name}</span>
<span className="text-muted">{o.code}</span>
</li>
))}
</ul>
)}
</div>
</section>
</main>
);
}
+30 -206
View File
@@ -1,170 +1,37 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiRequest, ApiError } from "../api/client";
import type { Kontrolle, Material, Objekt, Objektposition } from "../api/types";
import { useOnlineStatus } from "../offline/useOnlineStatus";
import { enqueue, eintraegeFuerKontrolle, entferneFehlerEintrag, synchronisiereQueue } from "../offline/queue";
import type { QueueStatus } from "../offline/queue";
import { PositionCard } from "./kontrolle/PositionCard";
import { useKontrolle } from "./kontrolle/useKontrolle";
type PositionStatus = "unbestaetigt" | QueueStatus | "gespeichert";
interface PositionZustand {
position: Objektposition;
material: Material | undefined;
status: PositionStatus;
eingabe: string;
fehlerText: string | null;
}
interface Sperrmeldung {
benutzer: string | null;
seit: string;
}
// Prompt 11 Screens 2-6 (kompakt zusammengefasst) + Prompt 17 Offline-Härtung:
// Start -> Positionen erfassen (über die lokale Queue, nie direkt blockierend) ->
// Abschluss erst möglich, wenn nichts mehr in der Queue unbestätigt liegt.
export function KontrollPage() {
const { objektId } = useParams<{ objektId: string }>();
const navigate = useNavigate();
const online = useOnlineStatus();
const {
online,
objekt,
sperre,
positionen,
startFehler,
wirdAbgeschlossen,
abschlussFehler,
alleUebertragen,
starteKontrolle,
eingabeAendern,
positionSenden,
fehlerErneutVersuchen,
abschliessen,
abbrechen,
} = useKontrolle(objektId);
const [objekt, setObjekt] = useState<Objekt | null>(null);
const [kontrolle, setKontrolle] = useState<Kontrolle | null>(null);
const [sperre, setSperre] = useState<Sperrmeldung | null>(null);
const [positionen, setPositionen] = useState<PositionZustand[]>([]);
const [startFehler, setStartFehler] = useState<string | null>(null);
const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false);
const [abschlussFehler, setAbschlussFehler] = useState<string | null>(null);
useEffect(() => {
if (!objektId) return;
apiRequest<Objekt>(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined);
}, [objektId]);
async function starteKontrolle(uebernehmen: boolean) {
if (!objektId) return;
setStartFehler(null);
try {
const neueKontrolle = await apiRequest<Kontrolle>(`/objekte/${objektId}/kontrollen`, {
method: "POST",
body: { uebernehmen },
});
setKontrolle(neueKontrolle);
setSperre(null);
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const detail = error.detail as { benutzer?: string; seit?: string } | null;
setSperre({ benutzer: detail?.benutzer ?? null, seit: detail?.seit ?? "" });
} else {
setStartFehler("Kontrolle konnte nicht gestartet werden (keine Verbindung?).");
}
}
}
useEffect(() => {
void starteKontrolle(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
useEffect(() => {
if (!kontrolle) return;
Promise.all([
apiRequest<Objektposition[]>(`/objekte/${kontrolle.objekt_id}/positionen`),
apiRequest<Material[]>("/materialien"),
]).then(([positionsListe, materialListe]) => {
const materialById = new Map(materialListe.map((m) => [m.id, m]));
setPositionen(
positionsListe
.filter((p) => p.ist_status === "aktiv")
.map((p) => ({
position: p,
material: materialById.get(p.material_id),
status: "unbestaetigt",
eingabe: p.sollmenge_effektiv,
fehlerText: null,
}))
);
});
}, [kontrolle]);
// Reconciliation-Loop: gleicht lokalen Anzeigezustand mit der Offline-Queue ab.
// Ein Eintrag, der aus der Queue verschwunden ist, wurde erfolgreich übertragen
// (queue.ts entfernt Einträge ausschließlich bei Erfolg, siehe dort).
useEffect(() => {
if (!kontrolle) return;
const interval = window.setInterval(async () => {
const eintraege = await eintraegeFuerKontrolle(kontrolle.id);
const byMaterial = new Map(eintraege.map((e) => [e.materialId, e]));
setPositionen((bisherige) =>
bisherige.map((zustand) => {
const inQueue = byMaterial.get(zustand.position.material_id);
if (inQueue) {
return { ...zustand, status: inQueue.status, fehlerText: inQueue.fehlerText };
}
if (zustand.status === "wartet" || zustand.status === "wird_uebertragen") {
return { ...zustand, status: "gespeichert", fehlerText: null };
}
return zustand;
})
);
}, 2000);
return () => window.clearInterval(interval);
}, [kontrolle]);
useEffect(() => {
if (online) void synchronisiereQueue();
}, [online]);
function eingabeAendern(materialId: number, wert: string) {
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, eingabe: wert } : z))
);
}
async function positionSenden(materialId: number, istmenge: string) {
if (!kontrolle) return;
await enqueue(kontrolle.id, materialId, istmenge);
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z))
);
void synchronisiereQueue();
}
async function fehlerErneutVersuchen(materialId: number, istmenge: string) {
if (!kontrolle) return;
await entferneFehlerEintrag(kontrolle.id, materialId);
await positionSenden(materialId, istmenge);
}
const alleUebertragen = useMemo(
() => positionen.length > 0 && positionen.every((p) => p.status === "gespeichert"),
[positionen]
);
async function abschliessen() {
if (!kontrolle) return;
setWirdAbgeschlossen(true);
setAbschlussFehler(null);
try {
await apiRequest(`/kontrollen/${kontrolle.id}/abschliessen`, { method: "POST" });
async function handleAbschliessen() {
if (await abschliessen()) {
navigate("/objekte");
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
setAbschlussFehler("Nicht alle Positionen wurden erfasst.");
} else {
setAbschlussFehler("Abschluss fehlgeschlagen (keine Verbindung?).");
}
} finally {
setWirdAbgeschlossen(false);
}
}
async function abbrechen() {
if (!kontrolle) return;
async function handleAbbrechen() {
const grund = window.prompt("Grund für den Abbruch (optional):") ?? undefined;
await apiRequest(`/kontrollen/${kontrolle.id}/abbrechen`, { method: "POST", body: { grund } });
await abbrechen(grund);
navigate("/objekte");
}
@@ -205,44 +72,13 @@ export function KontrollPage() {
)}
<ul className="card-list" style={{ marginBottom: "1.25rem" }}>
{positionen.map((zustand) => (
<li key={zustand.position.id} className="card">
<div className="card-title-row">
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong>
<StatusBadge status={zustand.status} />
</div>
<div className="card-subtitle">
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
</div>
<div className="row" style={{ marginTop: "0.6rem" }}>
<input
type="number"
className="input"
value={zustand.eingabe}
onChange={(e) => eingabeAendern(zustand.position.material_id, e.target.value)}
style={{ width: "6rem" }}
<PositionCard
key={zustand.position.id}
zustand={zustand}
onEingabeAendern={eingabeAendern}
onSenden={positionSenden}
onErneutVersuchen={fehlerErneutVersuchen}
/>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => positionSenden(zustand.position.material_id, zustand.eingabe)}
>
Bestätigen
</button>
</div>
{zustand.status === "fehler" && (
<div style={{ marginTop: "0.6rem" }}>
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{zustand.fehlerText}
</p>
<button
className="btn btn-secondary"
onClick={() => fehlerErneutVersuchen(zustand.position.material_id, zustand.eingabe)}
>
Erneut versuchen
</button>
</div>
)}
</li>
))}
</ul>
@@ -250,27 +86,15 @@ export function KontrollPage() {
<div className="stack">
<button
className="btn btn-primary btn-block"
onClick={abschliessen}
onClick={handleAbschliessen}
disabled={!alleUebertragen || wirdAbgeschlossen}
>
{alleUebertragen ? "Kontrolle abschließen" : "Noch nicht alle Positionen gespeichert…"}
</button>
<button className="btn btn-danger btn-block" onClick={abbrechen}>
<button className="btn btn-danger btn-block" onClick={handleAbbrechen}>
Kontrolle abbrechen
</button>
</div>
</main>
);
}
function StatusBadge({ status }: { status: PositionStatus }) {
const konfiguration: Record<PositionStatus, { text: string; klasse: string }> = {
unbestaetigt: { text: "offen", klasse: "badge-neutral" },
wartet: { text: "nicht gespeichert", klasse: "badge-warning" },
wird_uebertragen: { text: "wird übertragen…", klasse: "badge-warning" },
gespeichert: { text: "gespeichert", klasse: "badge-success" },
fehler: { text: "Fehler", klasse: "badge-danger" },
};
const { text, klasse } = konfiguration[status];
return <span className={`badge ${klasse}`}>{text}</span>;
}
@@ -0,0 +1,146 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Material } from "../../api/types";
const MATERIALTYPEN = [
{ value: "standard", label: "Standard" },
{ value: "ablauf_charge", label: "Ablauf/Charge" },
{ value: "geraet_sn", label: "Gerät mit Seriennummer" },
];
const LEERES_FORMULAR = {
name: "",
artikelnummer: "",
einheit: "Stück",
materialtyp: "standard",
};
interface Props {
materialien: Material[];
onGeaendert: () => Promise<void>;
onFehler: (text: string) => void;
}
export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
const [formular, setFormular] = useState(LEERES_FORMULAR);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [wirdGespeichert, setWirdGespeichert] = useState(false);
function bearbeiten(material: Material) {
setBearbeiteteId(material.id);
setFormular({
name: material.name,
artikelnummer: material.artikelnummer ?? "",
einheit: material.einheit,
materialtyp: material.materialtyp,
});
}
function zuruecksetzen() {
setBearbeiteteId(null);
setFormular(LEERES_FORMULAR);
}
async function speichern() {
setWirdGespeichert(true);
const body = {
name: formular.name,
artikelnummer: formular.artikelnummer || null,
einheit: formular.einheit,
materialtyp: formular.materialtyp,
};
try {
if (bearbeiteteId !== null) {
await apiRequest(`/materialien/${bearbeiteteId}`, { method: "PATCH", body });
} else {
await apiRequest("/materialien", { method: "POST", body });
}
zuruecksetzen();
await onGeaendert();
} catch {
onFehler("Material konnte nicht gespeichert werden.");
} finally {
setWirdGespeichert(false);
}
}
return (
<section className="section">
<h2>Material {bearbeiteteId !== null ? "bearbeiten" : "anlegen"}</h2>
<div className="card">
<div className="stack" style={{ marginBottom: "1.25rem" }}>
<div className="field">
<label>Name</label>
<input
className="input"
value={formular.name}
onChange={(e) => setFormular({ ...formular, name: e.target.value })}
/>
</div>
<div className="row">
<div className="field" style={{ flex: 1 }}>
<label>Artikelnummer</label>
<input
className="input"
value={formular.artikelnummer}
onChange={(e) => setFormular({ ...formular, artikelnummer: e.target.value })}
/>
</div>
<div className="field" style={{ width: "7rem" }}>
<label>Einheit</label>
<input
className="input"
value={formular.einheit}
onChange={(e) => setFormular({ ...formular, einheit: e.target.value })}
/>
</div>
</div>
<div className="field">
<label>Materialtyp</label>
<select
className="input"
value={formular.materialtyp}
onChange={(e) => setFormular({ ...formular, materialtyp: e.target.value })}
>
{MATERIALTYPEN.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="row">
<button
className="btn btn-primary"
onClick={speichern}
disabled={!formular.name || !formular.einheit || wirdGespeichert}
>
{bearbeiteteId !== null ? "Speichern" : "Anlegen"}
</button>
{bearbeiteteId !== null && (
<button className="btn btn-secondary" onClick={zuruecksetzen}>
Abbrechen
</button>
)}
</div>
</div>
{materialien.length > 0 && (
<ul className="card-list">
{materialien.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
{m.name} <span className="text-muted">({m.einheit})</span>
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(m)}>
Bearbeiten
</button>
</li>
))}
</ul>
)}
</div>
</section>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Objekt, Standort } from "../../api/types";
interface Props {
objekte: Objekt[];
vorlagen: Beladungsvorlage[];
standorte: Standort[];
onGeaendert: () => Promise<void>;
onFehler: (text: string) => void;
}
export function ObjektSection({ objekte, vorlagen, standorte, onGeaendert, onFehler }: Props) {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [vorlageId, setVorlageId] = useState<number | "">("");
const [standortId, setStandortId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
async function anlegen() {
if (vorlageId === "" || standortId === "") {
onFehler("Vorlage und Standort auswählen.");
return;
}
setWirdAngelegt(true);
const vorlage = vorlagen.find((v) => v.id === vorlageId);
try {
await apiRequest("/objekte", {
method: "POST",
body: {
code,
name,
objekttyp_id: vorlage?.objekttyp_id,
vorlage_id: vorlageId,
standort_id: standortId,
},
});
setCode("");
setName("");
await onGeaendert();
} catch {
onFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben).");
} finally {
setWirdAngelegt(false);
}
}
return (
<section className="section">
<h2>Objekt aus Vorlage anlegen</h2>
<div className="card">
{vorlagen.length === 0 ? (
<p className="text-muted">
Keine aktive Vorlage vorhanden erst Excel-Import oder Vorlage per API anlegen.
</p>
) : (
<div className="stack">
<div className="field">
<label>Vorlage</label>
<select className="input" value={vorlageId} onChange={(e) => setVorlageId(Number(e.target.value) || "")}>
<option value="">Vorlage wählen</option>
{vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name} (v{v.version})
</option>
))}
</select>
</div>
<div className="field">
<label>Standort</label>
<select
className="input"
value={standortId}
onChange={(e) => setStandortId(Number(e.target.value) || "")}
>
<option value="">Standort wählen</option>
{standorte.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Code (eindeutig)</label>
<input className="input" value={code} onChange={(e) => setCode(e.target.value)} />
</div>
<div className="field">
<label>Name</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={anlegen} disabled={!code || !name || wirdAngelegt}>
Anlegen
</button>
</div>
)}
{objekte.length > 0 && (
<ul className="card-list" style={{ marginTop: "1.25rem" }}>
{objekte.map((o) => (
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{o.name}</span>
<span className="text-muted">{o.code}</span>
</li>
))}
</ul>
)}
</div>
</section>
);
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Standort } from "../../api/types";
interface Props {
standorte: Standort[];
onGeaendert: () => Promise<void>;
onFehler: (text: string) => void;
}
export function StandortSection({ standorte, onGeaendert, onFehler }: Props) {
const [name, setName] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
async function anlegen() {
setWirdAngelegt(true);
try {
await apiRequest("/standorte", { method: "POST", body: { name } });
setName("");
await onGeaendert();
} catch {
onFehler("Standort konnte nicht angelegt werden.");
} finally {
setWirdAngelegt(false);
}
}
return (
<section className="section">
<h2>Standort anlegen</h2>
<div className="card">
<div className="row" style={{ marginBottom: standorte.length ? "1rem" : 0 }}>
<input
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Standorts"
/>
<button className="btn btn-primary" onClick={anlegen} disabled={!name || wirdAngelegt}>
Anlegen
</button>
</div>
{standorte.length > 0 && (
<ul className="card-list">
{standorte.map((s) => (
<li key={s.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>{s.name}</span>
</li>
))}
</ul>
)}
</div>
</section>
);
}
@@ -0,0 +1,52 @@
import { StatusBadge } from "./StatusBadge";
import type { PositionZustand } from "./types";
interface Props {
zustand: PositionZustand;
onEingabeAendern: (materialId: number, wert: string) => void;
onSenden: (materialId: number, istmenge: string) => void;
onErneutVersuchen: (materialId: number, istmenge: string) => void;
}
export function PositionCard({ zustand, onEingabeAendern, onSenden, onErneutVersuchen }: Props) {
return (
<li className="card">
<div className="card-title-row">
<strong>{zustand.material?.name ?? `Material ${zustand.position.material_id}`}</strong>
<StatusBadge status={zustand.status} />
</div>
<div className="card-subtitle">
Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""}
</div>
<div className="row" style={{ marginTop: "0.6rem" }}>
<input
type="number"
className="input"
value={zustand.eingabe}
onChange={(e) => onEingabeAendern(zustand.position.material_id, e.target.value)}
style={{ width: "6rem" }}
/>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => onSenden(zustand.position.material_id, zustand.eingabe)}
>
Bestätigen
</button>
</div>
{zustand.status === "fehler" && (
<div style={{ marginTop: "0.6rem" }}>
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{zustand.fehlerText}
</p>
<button
className="btn btn-secondary"
onClick={() => onErneutVersuchen(zustand.position.material_id, zustand.eingabe)}
>
Erneut versuchen
</button>
</div>
)}
</li>
);
}
@@ -0,0 +1,14 @@
import type { PositionStatus } from "./types";
const KONFIGURATION: Record<PositionStatus, { text: string; klasse: string }> = {
unbestaetigt: { text: "offen", klasse: "badge-neutral" },
wartet: { text: "nicht gespeichert", klasse: "badge-warning" },
wird_uebertragen: { text: "wird übertragen…", klasse: "badge-warning" },
gespeichert: { text: "gespeichert", klasse: "badge-success" },
fehler: { text: "Fehler", klasse: "badge-danger" },
};
export function StatusBadge({ status }: { status: PositionStatus }) {
const { text, klasse } = KONFIGURATION[status];
return <span className={`badge ${klasse}`}>{text}</span>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Material, Objektposition } from "../../api/types";
import type { QueueStatus } from "../../offline/queue";
export type PositionStatus = "unbestaetigt" | QueueStatus | "gespeichert";
export interface PositionZustand {
position: Objektposition;
material: Material | undefined;
status: PositionStatus;
eingabe: string;
fehlerText: string | null;
}
export interface Sperrmeldung {
benutzer: string | null;
seit: string;
}
@@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from "react";
import { apiRequest, ApiError } from "../../api/client";
import type { Kontrolle, Material, Objekt, Objektposition } from "../../api/types";
import { enqueue, eintraegeFuerKontrolle, entferneFehlerEintrag, synchronisiereQueue } from "../../offline/queue";
import { useOnlineStatus } from "../../offline/useOnlineStatus";
import type { PositionZustand, Sperrmeldung } from "./types";
/**
* Bündelt den kompletten Zustand/Ablauf einer Kontrolle (Prompt 11 Screens 2-6
* + Prompt 17 Offline-Härtung), damit KontrollPage.tsx nur noch die Darstellung
* übernimmt. Start -> Positionen erfassen (über die lokale Queue, nie direkt
* blockierend) -> Abschluss erst möglich, wenn nichts mehr unbestätigt liegt.
*/
export function useKontrolle(objektId: string | undefined) {
const online = useOnlineStatus();
const [objekt, setObjekt] = useState<Objekt | null>(null);
const [kontrolle, setKontrolle] = useState<Kontrolle | null>(null);
const [sperre, setSperre] = useState<Sperrmeldung | null>(null);
const [positionen, setPositionen] = useState<PositionZustand[]>([]);
const [startFehler, setStartFehler] = useState<string | null>(null);
const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false);
const [abschlussFehler, setAbschlussFehler] = useState<string | null>(null);
useEffect(() => {
if (!objektId) return;
apiRequest<Objekt>(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined);
}, [objektId]);
async function starteKontrolle(uebernehmen: boolean) {
if (!objektId) return;
setStartFehler(null);
try {
const neueKontrolle = await apiRequest<Kontrolle>(`/objekte/${objektId}/kontrollen`, {
method: "POST",
body: { uebernehmen },
});
setKontrolle(neueKontrolle);
setSperre(null);
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const detail = error.detail as { benutzer?: string; seit?: string } | null;
setSperre({ benutzer: detail?.benutzer ?? null, seit: detail?.seit ?? "" });
} else {
setStartFehler("Kontrolle konnte nicht gestartet werden (keine Verbindung?).");
}
}
}
useEffect(() => {
void starteKontrolle(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
useEffect(() => {
if (!kontrolle) return;
Promise.all([
apiRequest<Objektposition[]>(`/objekte/${kontrolle.objekt_id}/positionen`),
apiRequest<Material[]>("/materialien"),
]).then(([positionsListe, materialListe]) => {
const materialById = new Map(materialListe.map((m) => [m.id, m]));
setPositionen(
positionsListe
.filter((p) => p.ist_status === "aktiv")
.map((p) => ({
position: p,
material: materialById.get(p.material_id),
status: "unbestaetigt",
eingabe: p.sollmenge_effektiv,
fehlerText: null,
}))
);
});
}, [kontrolle]);
// Reconciliation-Loop: gleicht lokalen Anzeigezustand mit der Offline-Queue ab.
// Ein Eintrag, der aus der Queue verschwunden ist, wurde erfolgreich übertragen
// (queue.ts entfernt Einträge ausschließlich bei Erfolg, siehe dort).
useEffect(() => {
if (!kontrolle) return;
const interval = window.setInterval(async () => {
const eintraege = await eintraegeFuerKontrolle(kontrolle.id);
const byMaterial = new Map(eintraege.map((e) => [e.materialId, e]));
setPositionen((bisherige) =>
bisherige.map((zustand) => {
const inQueue = byMaterial.get(zustand.position.material_id);
if (inQueue) {
return { ...zustand, status: inQueue.status, fehlerText: inQueue.fehlerText };
}
if (zustand.status === "wartet" || zustand.status === "wird_uebertragen") {
return { ...zustand, status: "gespeichert", fehlerText: null };
}
return zustand;
})
);
}, 2000);
return () => window.clearInterval(interval);
}, [kontrolle]);
useEffect(() => {
if (online) void synchronisiereQueue();
}, [online]);
function eingabeAendern(materialId: number, wert: string) {
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, eingabe: wert } : z))
);
}
async function positionSenden(materialId: number, istmenge: string) {
if (!kontrolle) return;
await enqueue(kontrolle.id, materialId, istmenge);
setPositionen((bisherige) =>
bisherige.map((z) => (z.position.material_id === materialId ? { ...z, status: "wartet" } : z))
);
void synchronisiereQueue();
}
async function fehlerErneutVersuchen(materialId: number, istmenge: string) {
if (!kontrolle) return;
await entferneFehlerEintrag(kontrolle.id, materialId);
await positionSenden(materialId, istmenge);
}
const alleUebertragen = useMemo(
() => positionen.length > 0 && positionen.every((p) => p.status === "gespeichert"),
[positionen]
);
async function abschliessen() {
if (!kontrolle) return false;
setWirdAbgeschlossen(true);
setAbschlussFehler(null);
try {
await apiRequest(`/kontrollen/${kontrolle.id}/abschliessen`, { method: "POST" });
return true;
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
setAbschlussFehler("Nicht alle Positionen wurden erfasst.");
} else {
setAbschlussFehler("Abschluss fehlgeschlagen (keine Verbindung?).");
}
return false;
} finally {
setWirdAbgeschlossen(false);
}
}
async function abbrechen(grund: string | undefined) {
if (!kontrolle) return;
await apiRequest(`/kontrollen/${kontrolle.id}/abbrechen`, { method: "POST", body: { grund } });
}
return {
online,
objekt,
sperre,
positionen,
startFehler,
wirdAbgeschlossen,
abschlussFehler,
alleUebertragen,
starteKontrolle,
eingabeAendern,
positionSenden,
fehlerErneutVersuchen,
abschliessen,
abbrechen,
};
}