diff --git a/DEVLOG.md b/DEVLOG.md index 3689c45..59befd7 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- + +--- diff --git a/backend/app/services/kontrolle/__init__.py b/backend/app/services/kontrolle/__init__.py new file mode 100644 index 0000000..997293f --- /dev/null +++ b/backend/app/services/kontrolle/__init__.py @@ -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", +] diff --git a/backend/app/services/kontrolle/erfassung.py b/backend/app/services/kontrolle/erfassung.py new file mode 100644 index 0000000..b4b6e0d --- /dev/null +++ b/backend/app/services/kontrolle/erfassung.py @@ -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 diff --git a/backend/app/services/kontrolle/exceptions.py b/backend/app/services/kontrolle/exceptions.py new file mode 100644 index 0000000..22dd448 --- /dev/null +++ b/backend/app/services/kontrolle/exceptions.py @@ -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") diff --git a/backend/app/services/kontrolle.py b/backend/app/services/kontrolle/lifecycle.py similarity index 52% rename from backend/app/services/kontrolle.py rename to backend/app/services/kontrolle/lifecycle.py index bcb98a3..efdfb00 100644 --- a/backend/app/services/kontrolle.py +++ b/backend/app/services/kontrolle/lifecycle.py @@ -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( diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 4d1ed0c..641c48b 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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([]); @@ -31,19 +24,6 @@ export function AdminPage() { const [fehler, setFehler] = useState(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(""); - const [standortId, setStandortId] = useState(""); - const [objektWirdAngelegt, setObjektWirdAngelegt] = useState(false); - - const [materialFormular, setMaterialFormular] = useState(LEERES_MATERIAL_FORMULAR); - const [bearbeiteterMaterialId, setBearbeiteterMaterialId] = useState(null); - const [materialWirdGespeichert, setMaterialWirdGespeichert] = useState(false); - async function ladeAlles() { const [s, v, o, m] = await Promise.all([ apiRequest("/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 (
@@ -168,182 +56,15 @@ export function AdminPage() {

Administration

{fehler &&
{fehler}
} -
-

Standort anlegen

-
-
- setStandortName(e.target.value)} - placeholder="Name des Standorts" - /> - -
- {standorte.length > 0 && ( -
    - {standorte.map((s) => ( -
  • - {s.name} -
  • - ))} -
- )} -
-
- -
-

Material {bearbeiteterMaterialId !== null ? "bearbeiten" : "anlegen"}

-
-
-
- - setMaterialFormular({ ...materialFormular, name: e.target.value })} - /> -
-
-
- - setMaterialFormular({ ...materialFormular, artikelnummer: e.target.value })} - /> -
-
- - setMaterialFormular({ ...materialFormular, einheit: e.target.value })} - /> -
-
-
- - -
-
- - {bearbeiteterMaterialId !== null && ( - - )} -
-
- - {materialien.length > 0 && ( -
    - {materialien.map((m) => ( -
  • - - {m.name} ({m.einheit}) - - -
  • - ))} -
- )} -
-
- -
-

Objekt aus Vorlage anlegen

-
- {vorlagen.length === 0 ? ( -

- Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen. -

- ) : ( -
-
- - -
-
- - -
-
- - setObjektCode(e.target.value)} /> -
-
- - setObjektName(e.target.value)} /> -
- -
- )} - - {objekte.length > 0 && ( -
    - {objekte.map((o) => ( -
  • - {o.name} - {o.code} -
  • - ))} -
- )} -
-
+ + +
); } diff --git a/frontend/src/pages/KontrollPage.tsx b/frontend/src/pages/KontrollPage.tsx index ebca54a..ef4964c 100644 --- a/frontend/src/pages/KontrollPage.tsx +++ b/frontend/src/pages/KontrollPage.tsx @@ -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(null); - const [kontrolle, setKontrolle] = useState(null); - const [sperre, setSperre] = useState(null); - const [positionen, setPositionen] = useState([]); - const [startFehler, setStartFehler] = useState(null); - const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false); - const [abschlussFehler, setAbschlussFehler] = useState(null); - - useEffect(() => { - if (!objektId) return; - apiRequest(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined); - }, [objektId]); - - async function starteKontrolle(uebernehmen: boolean) { - if (!objektId) return; - setStartFehler(null); - try { - const neueKontrolle = await apiRequest(`/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(`/objekte/${kontrolle.objekt_id}/positionen`), - apiRequest("/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() { )}
    {positionen.map((zustand) => ( -
  • -
    - {zustand.material?.name ?? `Material ${zustand.position.material_id}`} - -
    -
    - Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""} -
    -
    - eingabeAendern(zustand.position.material_id, e.target.value)} - style={{ width: "6rem" }} - /> - -
    - {zustand.status === "fehler" && ( -
    -

    - {zustand.fehlerText} -

    - -
    - )} -
  • + ))}
@@ -250,27 +86,15 @@ export function KontrollPage() {
-
); } - -function StatusBadge({ status }: { status: PositionStatus }) { - const konfiguration: Record = { - 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 {text}; -} diff --git a/frontend/src/pages/admin/MaterialSection.tsx b/frontend/src/pages/admin/MaterialSection.tsx new file mode 100644 index 0000000..8d9b360 --- /dev/null +++ b/frontend/src/pages/admin/MaterialSection.tsx @@ -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; + onFehler: (text: string) => void; +} + +export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) { + const [formular, setFormular] = useState(LEERES_FORMULAR); + const [bearbeiteteId, setBearbeiteteId] = useState(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 ( +
+

Material {bearbeiteteId !== null ? "bearbeiten" : "anlegen"}

+
+
+
+ + setFormular({ ...formular, name: e.target.value })} + /> +
+
+
+ + setFormular({ ...formular, artikelnummer: e.target.value })} + /> +
+
+ + setFormular({ ...formular, einheit: e.target.value })} + /> +
+
+
+ + +
+
+ + {bearbeiteteId !== null && ( + + )} +
+
+ + {materialien.length > 0 && ( +
    + {materialien.map((m) => ( +
  • + + {m.name} ({m.einheit}) + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx new file mode 100644 index 0000000..eb3b9fb --- /dev/null +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -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; + 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(""); + const [standortId, setStandortId] = useState(""); + 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 ( +
+

Objekt aus Vorlage anlegen

+
+ {vorlagen.length === 0 ? ( +

+ Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen. +

+ ) : ( +
+
+ + +
+
+ + +
+
+ + setCode(e.target.value)} /> +
+
+ + setName(e.target.value)} /> +
+ +
+ )} + + {objekte.length > 0 && ( +
    + {objekte.map((o) => ( +
  • + {o.name} + {o.code} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/admin/StandortSection.tsx b/frontend/src/pages/admin/StandortSection.tsx new file mode 100644 index 0000000..422571d --- /dev/null +++ b/frontend/src/pages/admin/StandortSection.tsx @@ -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; + 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 ( +
+

Standort anlegen

+
+
+ setName(e.target.value)} + placeholder="Name des Standorts" + /> + +
+ {standorte.length > 0 && ( +
    + {standorte.map((s) => ( +
  • + {s.name} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/kontrolle/PositionCard.tsx b/frontend/src/pages/kontrolle/PositionCard.tsx new file mode 100644 index 0000000..9fb4d25 --- /dev/null +++ b/frontend/src/pages/kontrolle/PositionCard.tsx @@ -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 ( +
  • +
    + {zustand.material?.name ?? `Material ${zustand.position.material_id}`} + +
    +
    + Soll: {zustand.position.sollmenge_effektiv} {zustand.material?.einheit ?? ""} +
    +
    + onEingabeAendern(zustand.position.material_id, e.target.value)} + style={{ width: "6rem" }} + /> + +
    + {zustand.status === "fehler" && ( +
    +

    + {zustand.fehlerText} +

    + +
    + )} +
  • + ); +} diff --git a/frontend/src/pages/kontrolle/StatusBadge.tsx b/frontend/src/pages/kontrolle/StatusBadge.tsx new file mode 100644 index 0000000..ecedab7 --- /dev/null +++ b/frontend/src/pages/kontrolle/StatusBadge.tsx @@ -0,0 +1,14 @@ +import type { PositionStatus } from "./types"; + +const KONFIGURATION: Record = { + 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 {text}; +} diff --git a/frontend/src/pages/kontrolle/types.ts b/frontend/src/pages/kontrolle/types.ts new file mode 100644 index 0000000..177182b --- /dev/null +++ b/frontend/src/pages/kontrolle/types.ts @@ -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; +} diff --git a/frontend/src/pages/kontrolle/useKontrolle.ts b/frontend/src/pages/kontrolle/useKontrolle.ts new file mode 100644 index 0000000..f83fc3c --- /dev/null +++ b/frontend/src/pages/kontrolle/useKontrolle.ts @@ -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(null); + const [kontrolle, setKontrolle] = useState(null); + const [sperre, setSperre] = useState(null); + const [positionen, setPositionen] = useState([]); + const [startFehler, setStartFehler] = useState(null); + const [wirdAbgeschlossen, setWirdAbgeschlossen] = useState(false); + const [abschlussFehler, setAbschlussFehler] = useState(null); + + useEffect(() => { + if (!objektId) return; + apiRequest(`/objekte/${objektId}`).then(setObjekt).catch(() => undefined); + }, [objektId]); + + async function starteKontrolle(uebernehmen: boolean) { + if (!objektId) return; + setStartFehler(null); + try { + const neueKontrolle = await apiRequest(`/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(`/objekte/${kontrolle.objekt_id}/positionen`), + apiRequest("/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, + }; +}