feat(readiness): READY-001 an/abschaltbare Bereitschafts-Kriterien (abgespeckte Regel-Engine)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
d41d04c26c
commit
46a65ba6ff
12 files changed
+343
-15
No files matched your search
@@ -25,12 +25,25 @@ Details zu READY-001 … READY-006. READY-001-005 vollständig, READY-006 ergän
|
||||
- **Akzeptanzkriterien:** Regeln iterierbar, Ergebnis = Liste erfüllter/nicht erfüllter
|
||||
Kriterien.
|
||||
- **Tests:** Regel-Iterations-Test.
|
||||
- **DoD:** **HIGH RISK** (größte technische Unsicherheit im gesamten Backlog) — MABEA hat
|
||||
stattdessen eine fest verdrahtete Funktion (`einsatzbereitschaft()` mit hartcodierten
|
||||
Kriterien Fehlbestand/Ablauf/Prüfung/Sperre/Mangel/in_wartung/HU-UVV). Umbau zu einer
|
||||
echten Regel-Engine ist ein substanzieller Umbau, kein Nachzug — vor Umsetzung klären,
|
||||
ob der Konfigurierbarkeits-Bedarf real ist (bisher nur eine Organisation im Einsatz, ein
|
||||
fester Kriterienkatalog reicht evtl. aus).
|
||||
- **Bedarfsklärung 2026-09-10:** Nutzer hat sich trotz Empfehlung (nur eine
|
||||
Organisation im Einsatz, fester Katalog deckt Bedarf ab) für eine
|
||||
Umsetzung entschieden — bewusst als **abgespeckte Version** umgesetzt (siehe
|
||||
DoD), keine volle Regel-Engine mit Gewichtung.
|
||||
- **DoD:** ✅ umgesetzt (2026-09-10), **bewusst reduzierter Scope gegenüber
|
||||
ursprünglicher Kachel-Beschreibung:** `readiness_regel` (Migration 0042,
|
||||
sieben Zeilen = die sieben bisher hartcodierten Kriterien
|
||||
Fehlbestand/Ablauf/Prüfung/Sperre/Mangel/in_wartung/HU-UVV), jede Zeile nur
|
||||
**an/aus** (`aktiv: bool`) — KEINE Gewichtung, KEIN freier Regelkatalog
|
||||
(neue Kriterien bräuchten weiterhin Code-Änderung). Die Kombinationslogik
|
||||
selbst (Mindermenge-Sonderfall, „noch nie kontrolliert") bleibt fest
|
||||
verdrahtet, nur die sieben Einzelkriterien sind umschaltbar.
|
||||
`app/services/dashboard.py:aktive_regel_schluessel()` lädt den aktuellen
|
||||
Stand bei jeder Auswertung neu (kein Cache, keine App-Neustart nötig nach
|
||||
Umschalten). `GET/PATCH /readiness-regeln` (Administrator-only), Admin-Tab
|
||||
„Bereitschaftsregeln". `backend/tests/test_readiness_regel.py`
|
||||
(Regressionstest unverändertes Verhalten bei allen Regeln aktiv,
|
||||
Deaktivierungstest, Rechte-Test). Wirkt auf READY-002/READY-004/READY-006/
|
||||
Akte-Readiness, da alle über `_kategorie_und_gruende()` laufen.
|
||||
|
||||
## READY-002 — Zustände READY/LIMITED/NOT_READY/UNKNOWN
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""READY-001: Regelkatalog für Einsatzbereitschaft (an/aus je Kriterium).
|
||||
|
||||
Revision ID: 0042_readiness_regel
|
||||
Revises: 0041_wartung_benachrichtigung
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0042_readiness_regel"
|
||||
down_revision: Union[str, None] = "0041_wartung_benachrichtigung"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_REGELN = [
|
||||
("fehlbestand_offen", "Offener, nicht genehmigter Fehlbestand"),
|
||||
("ablaufdatum_abgelaufen", "Abgelaufenes Ablaufdatum (Charge)"),
|
||||
("pruefung_ueberfaellig", "Überfällige Geräteprüfung"),
|
||||
("kontrolle_laeuft", "Kontrolle läuft gerade (Sperre)"),
|
||||
("mangel_kritisch_offen", "Kritischer, offener Mangel"),
|
||||
("in_wartung", "Objekt in Wartung (Status)"),
|
||||
("hu_uvv_ueberfaellig", "Überfällige HU/UVV (Fahrzeug)"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE readiness_regel (
|
||||
schluessel TEXT PRIMARY KEY,
|
||||
beschreibung TEXT NOT NULL,
|
||||
aktiv BOOLEAN NOT NULL DEFAULT true
|
||||
)
|
||||
"""
|
||||
)
|
||||
for schluessel, beschreibung in _REGELN:
|
||||
op.execute(
|
||||
"INSERT INTO readiness_regel (schluessel, beschreibung, aktiv) VALUES "
|
||||
f"('{schluessel}', '{beschreibung}', true)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE readiness_regel")
|
||||
@@ -22,6 +22,7 @@ from app.api.v1.endpoints import (
|
||||
objekte,
|
||||
permission,
|
||||
personal,
|
||||
readiness_regel,
|
||||
readiness_snapshot,
|
||||
stammdaten,
|
||||
suche,
|
||||
@@ -66,3 +67,4 @@ api_router.include_router(wartung.router, tags=["wartung"])
|
||||
api_router.include_router(suche.router, tags=["suche"])
|
||||
api_router.include_router(tankbuch.router, tags=["tankbuch"])
|
||||
api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"])
|
||||
api_router.include_router(readiness_regel.router, tags=["readiness_regel"])
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import RolleTyp
|
||||
from app.models.readiness_regel import ReadinessRegel
|
||||
from app.schemas.readiness_regel import ReadinessRegelRead, ReadinessRegelUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_admin_only = require_roles(RolleTyp.administration)
|
||||
|
||||
|
||||
@router.get("/readiness-regeln", response_model=list[ReadinessRegelRead])
|
||||
async def liste_readiness_regeln(
|
||||
db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
|
||||
) -> list[ReadinessRegel]:
|
||||
result = await db.execute(select(ReadinessRegel).order_by(ReadinessRegel.schluessel))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.patch("/readiness-regeln/{schluessel}", response_model=ReadinessRegelRead)
|
||||
async def aendere_readiness_regel(
|
||||
schluessel: str,
|
||||
payload: ReadinessRegelUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_admin_only),
|
||||
) -> ReadinessRegel:
|
||||
"""READY-001: an/abschalten eines Einzelkriteriums - wirkt sofort auf
|
||||
jede folgende Auswertung (kein Cache), da `aktive_regel_schluessel()` bei
|
||||
jedem Aufruf frisch aus der DB liest."""
|
||||
regel = await db.get(ReadinessRegel, schluessel)
|
||||
if regel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Regel nicht gefunden")
|
||||
regel.aktiv = payload.aktiv
|
||||
await db.commit()
|
||||
await db.refresh(regel)
|
||||
return regel
|
||||
@@ -17,6 +17,7 @@ from app.models.notiz import AkteNotiz
|
||||
from app.models.objekt import Objekt, ObjektStatus
|
||||
from app.models.objektposition import Objektposition, ObjektpositionStatus
|
||||
from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung
|
||||
from app.models.readiness_regel import ReadinessRegel
|
||||
from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand
|
||||
from app.models.personal import (
|
||||
BenutzerQualifikation,
|
||||
@@ -82,6 +83,7 @@ __all__ = [
|
||||
"BenutzerRolleZuordnung",
|
||||
"Rolle",
|
||||
"RolleBerechtigung",
|
||||
"ReadinessRegel",
|
||||
"ReadinessSnapshot",
|
||||
"ReadinessZustand",
|
||||
"BenutzerQualifikation",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ReadinessRegel(Base):
|
||||
"""READY-001: Regelkatalog für `dashboard.einsatzbereitschaft()` - jede
|
||||
Zeile entspricht einem der bisher hartcodierten Kriterien
|
||||
(`_kategorie_und_gruende` in app/services/dashboard.py), `aktiv=False`
|
||||
blendet das Kriterium aus der Bewertung aus. Bewusst NUR an/aus, keine
|
||||
Gewichtung - die Kachel selbst nennt Gewichtung als "evtl."-Ausbaustufe,
|
||||
nicht als Kernanforderung, und eine reine On/Off-Auswahl deckt den
|
||||
genannten Bedarf ("Organisationen haben unterschiedliche Kriterien") ohne
|
||||
die volle Komplexität einer gewichteten Regel-Engine. Die
|
||||
Kombinationslogik selbst (z.B. "noch nie kontrolliert"/Mindermenge-
|
||||
Sonderfall) bleibt fest verdrahtet - nur die sieben Einzelkriterien sind
|
||||
umschaltbar, nicht die Fachlogik, wie sie sich kombinieren."""
|
||||
|
||||
__tablename__ = "readiness_regel"
|
||||
|
||||
schluessel: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
beschreibung: Mapped[str] = mapped_column(String, nullable=False)
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ReadinessRegelRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
schluessel: str
|
||||
beschreibung: str
|
||||
aktiv: bool
|
||||
|
||||
|
||||
class ReadinessRegelUpdate(BaseModel):
|
||||
aktiv: bool
|
||||
@@ -12,6 +12,7 @@ from app.models.auth import Benutzer
|
||||
from app.models.objekt import Objekt, ObjektStatus
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.models.personal import BenutzerQualifikation, Qualifikationstyp
|
||||
from app.models.readiness_regel import ReadinessRegel
|
||||
from app.models.stammdaten import Material
|
||||
from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel
|
||||
from app.services.objekte import letzte_kontrollen_map
|
||||
@@ -101,6 +102,25 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]:
|
||||
return eintraege
|
||||
|
||||
|
||||
async def aktive_regel_schluessel(db: AsyncSession) -> set[str]:
|
||||
"""READY-001: welche der sieben Einzelkriterien aktuell an sind - alle
|
||||
fehlenden Zeilen (sollte praktisch nie vorkommen, Seed-Migration legt alle
|
||||
sieben an) gelten als aktiv (Fail-Safe: lieber ein Kriterium zu viel
|
||||
prüfen als eines fälschlich stumm zu schalten)."""
|
||||
result = await db.execute(select(ReadinessRegel.schluessel, ReadinessRegel.aktiv))
|
||||
bekannt = {schluessel: aktiv for schluessel, aktiv in result.all()}
|
||||
alle_schluessel = {
|
||||
"fehlbestand_offen",
|
||||
"ablaufdatum_abgelaufen",
|
||||
"pruefung_ueberfaellig",
|
||||
"kontrolle_laeuft",
|
||||
"mangel_kritisch_offen",
|
||||
"in_wartung",
|
||||
"hu_uvv_ueberfaellig",
|
||||
}
|
||||
return {s for s in alle_schluessel if bekannt.get(s, True)}
|
||||
|
||||
|
||||
async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[str]]]:
|
||||
"""Aus `einsatzbereitschaft()` extrahiert (READY-006), damit der
|
||||
Snapshot-Job (app/services/readiness_snapshot.py) dieselbe Fachlogik je
|
||||
@@ -195,6 +215,8 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st
|
||||
.all()
|
||||
)
|
||||
|
||||
aktive_regeln = await aktive_regel_schluessel(db)
|
||||
|
||||
return [
|
||||
(
|
||||
objekt,
|
||||
@@ -208,6 +230,7 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st
|
||||
objekt_ids_gesperrt,
|
||||
objekt_ids_kritischer_mangel,
|
||||
objekt_ids_hu_uvv_ueberfaellig,
|
||||
aktive_regeln,
|
||||
),
|
||||
)
|
||||
for objekt in objekte
|
||||
@@ -263,25 +286,43 @@ def _kategorie_und_gruende(
|
||||
objekt_ids_gesperrt: set,
|
||||
objekt_ids_kritischer_mangel: set,
|
||||
objekt_ids_hu_uvv_ueberfaellig: set,
|
||||
aktive_regeln: set[str] | None = None,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""aus der `einsatzbereitschaft()`-Schleife extrahiert, damit
|
||||
`objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt
|
||||
sie zu duplizieren."""
|
||||
sie zu duplizieren.
|
||||
|
||||
READY-001: `aktive_regeln` (None = alle sieben aktiv, Rückwärtskompatibilität
|
||||
für Aufrufer ohne Regel-Engine-Anbindung) blendet einzelne Kriterien aus -
|
||||
die KOMBINATIONSLOGIK selbst (Mindermenge-Sonderfall, "noch nie
|
||||
kontrolliert") bleibt bewusst fest, nur die sieben Einzelkriterien sind
|
||||
umschaltbar."""
|
||||
if aktive_regeln is None:
|
||||
aktive_regeln = {
|
||||
"fehlbestand_offen",
|
||||
"ablaufdatum_abgelaufen",
|
||||
"pruefung_ueberfaellig",
|
||||
"kontrolle_laeuft",
|
||||
"mangel_kritisch_offen",
|
||||
"in_wartung",
|
||||
"hu_uvv_ueberfaellig",
|
||||
}
|
||||
|
||||
gruende: list[str] = []
|
||||
ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt
|
||||
if ungenehmigt:
|
||||
if ungenehmigt and "fehlbestand_offen" in aktive_regeln:
|
||||
gruende.append("fehlbestand_offen")
|
||||
if objekt.id in objekt_ids_abgelaufen:
|
||||
if objekt.id in objekt_ids_abgelaufen and "ablaufdatum_abgelaufen" in aktive_regeln:
|
||||
gruende.append("ablaufdatum_abgelaufen")
|
||||
if objekt.id in objekt_ids_pruefung_ueberfaellig:
|
||||
if objekt.id in objekt_ids_pruefung_ueberfaellig and "pruefung_ueberfaellig" in aktive_regeln:
|
||||
gruende.append("pruefung_ueberfaellig")
|
||||
if objekt.id in objekt_ids_gesperrt:
|
||||
if objekt.id in objekt_ids_gesperrt and "kontrolle_laeuft" in aktive_regeln:
|
||||
gruende.append("kontrolle_laeuft")
|
||||
if objekt.id in objekt_ids_kritischer_mangel:
|
||||
if objekt.id in objekt_ids_kritischer_mangel and "mangel_kritisch_offen" in aktive_regeln:
|
||||
gruende.append("mangel_kritisch_offen")
|
||||
if objekt.status == ObjektStatus.in_wartung:
|
||||
if objekt.status == ObjektStatus.in_wartung and "in_wartung" in aktive_regeln:
|
||||
gruende.append("in_wartung")
|
||||
if objekt.id in objekt_ids_hu_uvv_ueberfaellig:
|
||||
if objekt.id in objekt_ids_hu_uvv_ueberfaellig and "hu_uvv_ueberfaellig" in aktive_regeln:
|
||||
gruende.append("hu_uvv_ueberfaellig")
|
||||
|
||||
# "noch nie kontrolliert" gilt zusätzlich zu jedem anderen Grund, nicht nur
|
||||
@@ -375,6 +416,7 @@ async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
|
||||
)
|
||||
).first() is not None
|
||||
letzte_kontrolle_map = await letzte_kontrollen_map(db, [objekt_id])
|
||||
aktive_regeln = await aktive_regel_schluessel(db)
|
||||
|
||||
kategorie, gruende = _kategorie_und_gruende(
|
||||
objekt,
|
||||
@@ -386,6 +428,7 @@ async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict:
|
||||
{objekt_id} if ist_gesperrt else set(),
|
||||
{objekt_id} if hat_kritischen_mangel else set(),
|
||||
{objekt_id} if hat_ueberfaellige_hu_uvv else set(),
|
||||
aktive_regeln,
|
||||
)
|
||||
return {"status": kategorie, "gruende": gruende}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from app.models.objekt import ObjektStatus
|
||||
from app.models.readiness_regel import ReadinessRegel
|
||||
from app.services.dashboard import einsatzbereitschaft
|
||||
from tests.conftest import auth_header, login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_wartung_zaehlt_normal_als_nicht_bereit(db_session, standort_factory, objekt_factory):
|
||||
standort = await standort_factory("Wache-Regel-1")
|
||||
objekt = await objekt_factory(name="Objekt Regel1", code="REGEL-1", standort=standort)
|
||||
objekt.status = ObjektStatus.in_wartung
|
||||
await db_session.commit()
|
||||
|
||||
ergebnis = await einsatzbereitschaft(db_session)
|
||||
eintrag = next(d for d in ergebnis["details"] if d["objekt_id"] == objekt.id)
|
||||
assert "in_wartung" in eintrag["gruende"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deaktivierte_regel_wird_nicht_mehr_beruecksichtigt(
|
||||
client, db_session, standort_factory, objekt_factory, admin_user
|
||||
):
|
||||
standort = await standort_factory("Wache-Regel-2")
|
||||
objekt = await objekt_factory(name="Objekt Regel2", code="REGEL-2", standort=standort)
|
||||
objekt.status = ObjektStatus.in_wartung
|
||||
await db_session.commit()
|
||||
|
||||
admin_token = await login(client, "admin1")
|
||||
response = await client.patch(
|
||||
"/api/v1/readiness-regeln/in_wartung", json={"aktiv": False}, headers=auth_header(admin_token)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["aktiv"] is False
|
||||
|
||||
ergebnis = await einsatzbereitschaft(db_session)
|
||||
eintrag_details = [d for d in ergebnis["details"] if d["objekt_id"] == objekt.id]
|
||||
# "in_wartung" darf nach dem Deaktivieren in keinem Grund mehr auftauchen
|
||||
# (Objekt ist nie kontrolliert worden, taucht deshalb weiterhin unter
|
||||
# "unbekannt" auf statt komplett zu verschwinden - das ist unabhängig von
|
||||
# dieser Regel).
|
||||
assert all("in_wartung" not in d["gruende"] for d in eintrag_details)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liste_und_rechte(client, admin_user, mitarbeiter_user):
|
||||
admin_token = await login(client, "admin1")
|
||||
liste = await client.get("/api/v1/readiness-regeln", headers=auth_header(admin_token))
|
||||
assert liste.status_code == 200
|
||||
schluessel = {r["schluessel"] for r in liste.json()}
|
||||
assert "hu_uvv_ueberfaellig" in schluessel
|
||||
assert len(liste.json()) == 7
|
||||
|
||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||
verboten = await client.get("/api/v1/readiness-regeln", headers=auth_header(mitarbeiter_token))
|
||||
assert verboten.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbekannte_regel_404(client, admin_user):
|
||||
admin_token = await login(client, "admin1")
|
||||
response = await client.patch(
|
||||
"/api/v1/readiness-regeln/gibt-es-nicht", json={"aktiv": False}, headers=auth_header(admin_token)
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -477,6 +477,12 @@ export interface CustomRolle {
|
||||
berechtigung_ids: number[];
|
||||
}
|
||||
|
||||
export interface ReadinessRegel {
|
||||
schluessel: string;
|
||||
beschreibung: string;
|
||||
aktiv: boolean;
|
||||
}
|
||||
|
||||
export interface Einheit {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { StandortSection } from "./admin/StandortSection";
|
||||
import { VorlageSection } from "./admin/VorlageSection";
|
||||
import { WartungsplanSection } from "./admin/WartungsplanSection";
|
||||
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
|
||||
import { ReadinessRegelSection } from "./admin/ReadinessRegelSection";
|
||||
|
||||
type Tab =
|
||||
| "objekte"
|
||||
@@ -44,7 +45,8 @@ type Tab =
|
||||
| "lager"
|
||||
| "ausgabe"
|
||||
| "wartungsplaene"
|
||||
| "rollen";
|
||||
| "rollen"
|
||||
| "readinessRegeln";
|
||||
|
||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
|
||||
@@ -87,6 +89,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||
},
|
||||
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
||||
{ key: "readinessRegeln", label: "Bereitschaftsregeln", rollen: ["administration"] },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -304,6 +307,7 @@ export function AdminPage() {
|
||||
<WartungsplanSection objekttypen={objekttypen} onFehler={setFehler} />
|
||||
)}
|
||||
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} standorte={standorte} onFehler={setFehler} />}
|
||||
{aktiverTab === "readinessRegeln" && <ReadinessRegelSection onFehler={setFehler} />}
|
||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||
{aktiverTab === "personal" && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { ReadinessRegel } from "../../api/types";
|
||||
|
||||
interface Props {
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
// READY-001: Regelkatalog für dashboard.einsatzbereitschaft() - jedes der
|
||||
// sieben hartcodierten Kriterien lässt sich hier an-/abschalten, ohne
|
||||
// Codeänderung/Deploy. Kombinationslogik (Mindermenge-Sonderfall, "noch nie
|
||||
// kontrolliert") bleibt bewusst fest, siehe app/models/readiness_regel.py.
|
||||
export function ReadinessRegelSection({ onFehler }: Props) {
|
||||
const [regeln, setRegeln] = useState<ReadinessRegel[]>([]);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
|
||||
async function laden() {
|
||||
setLaedt(true);
|
||||
try {
|
||||
setRegeln(await apiRequest<ReadinessRegel[]>("/readiness-regeln"));
|
||||
} catch {
|
||||
onFehler("Regeln konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLaedt(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
}, []);
|
||||
|
||||
async function umschalten(regel: ReadinessRegel) {
|
||||
try {
|
||||
await apiRequest(`/readiness-regeln/${regel.schluessel}`, {
|
||||
method: "PATCH",
|
||||
body: { aktiv: !regel.aktiv },
|
||||
});
|
||||
await laden();
|
||||
} catch {
|
||||
onFehler("Regel konnte nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>Einsatzbereitschaft: Regeln</h2>
|
||||
<div className="card">
|
||||
<p className="text-muted" style={{ fontSize: "0.85rem", marginTop: 0 }}>
|
||||
Deaktivierte Kriterien fließen nicht mehr in die Einsatzbereitschafts-Bewertung
|
||||
ein (Dashboard, Akte, Bereitschafts-Verlauf).
|
||||
</p>
|
||||
{laedt && <p className="text-muted">Lade…</p>}
|
||||
{!laedt && (
|
||||
<ul className="card-list">
|
||||
{regeln.map((r) => (
|
||||
<li key={r.schluessel} className="row-between" style={{ padding: "0.35rem 0" }}>
|
||||
<span>{r.beschreibung}</span>
|
||||
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
|
||||
<input type="checkbox" checked={r.aktiv} onChange={() => umschalten(r)} />
|
||||
{r.aktiv ? "Aktiv" : "Aus"}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user