AP1.4e: Methodenprofile am Gate-Graph.
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4f8725e234
commit
1fa61b2dac
|
|
@ -1,5 +1,18 @@
|
||||||
"""Roadmap graph read models (AP1.4d)."""
|
"""Roadmap graph read models (AP1.4d/4e)."""
|
||||||
|
|
||||||
from steering.graph.roadmap_engine import compute_initiative_graph_state
|
from steering.graph.profiles import GraphMethodProfile, get_graph_profile
|
||||||
|
from steering.graph.roadmap_engine import (
|
||||||
|
apply_graph_method_profile,
|
||||||
|
compute_initiative_graph_state,
|
||||||
|
compute_initiative_graph_state_with_profile,
|
||||||
|
load_initiative_graph_state,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["compute_initiative_graph_state"]
|
__all__ = [
|
||||||
|
"GraphMethodProfile",
|
||||||
|
"get_graph_profile",
|
||||||
|
"apply_graph_method_profile",
|
||||||
|
"compute_initiative_graph_state",
|
||||||
|
"compute_initiative_graph_state_with_profile",
|
||||||
|
"load_initiative_graph_state",
|
||||||
|
]
|
||||||
|
|
|
||||||
51
backend/steering/graph/profiles.py
Normal file
51
backend/steering/graph/profiles.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Method graph profiles — AP1.4e."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
DEFAULT_METHOD_KEY = "generic_operating"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GraphMethodProfile:
|
||||||
|
"""Steuert Graph-Read-Models pro steering_context.method_key."""
|
||||||
|
|
||||||
|
enforce_gate_blocking: bool
|
||||||
|
emphasize_fulfillment: bool
|
||||||
|
|
||||||
|
|
||||||
|
_STRICT = GraphMethodProfile(enforce_gate_blocking=True, emphasize_fulfillment=False)
|
||||||
|
_FULFILLMENT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=True)
|
||||||
|
_LIGHT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=False)
|
||||||
|
|
||||||
|
METHOD_GRAPH_PROFILES: dict[str, GraphMethodProfile] = {
|
||||||
|
"generic_operating": _LIGHT,
|
||||||
|
"queue_pull": _LIGHT,
|
||||||
|
"dispute_procedure": _LIGHT,
|
||||||
|
"continuous_product": _FULFILLMENT,
|
||||||
|
"maturity_progression": _FULFILLMENT,
|
||||||
|
"chapter_based_progression": _FULFILLMENT,
|
||||||
|
"product_milestone_driven": _STRICT,
|
||||||
|
"program_delivery": _STRICT,
|
||||||
|
"sequential_dependency": _STRICT,
|
||||||
|
"agile_iteration": _STRICT,
|
||||||
|
"recurring_control": _FULFILLMENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph_profile(method_key: str | None) -> GraphMethodProfile:
|
||||||
|
if not method_key:
|
||||||
|
return METHOD_GRAPH_PROFILES[DEFAULT_METHOD_KEY]
|
||||||
|
return METHOD_GRAPH_PROFILES.get(method_key, _STRICT)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_gate_blocking_for_method(method_key: str | None) -> bool:
|
||||||
|
return get_graph_profile(method_key).enforce_gate_blocking
|
||||||
|
|
||||||
|
|
||||||
|
def graph_profile_as_dict(profile: GraphMethodProfile) -> dict[str, bool]:
|
||||||
|
return {
|
||||||
|
"enforce_gate_blocking": profile.enforce_gate_blocking,
|
||||||
|
"emphasize_fulfillment": profile.emphasize_fulfillment,
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d)."""
|
"""Roadmap graph read models — AP1.4d/4e."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from steering.graph.profiles import (
|
||||||
|
DEFAULT_METHOD_KEY,
|
||||||
|
GraphMethodProfile,
|
||||||
|
get_graph_profile,
|
||||||
|
graph_profile_as_dict,
|
||||||
|
)
|
||||||
|
|
||||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||||
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||||
BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"})
|
BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||||
|
|
@ -114,6 +121,7 @@ def compute_initiative_graph_state(
|
||||||
|
|
||||||
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
||||||
item_states[item_id] = {
|
item_states[item_id] = {
|
||||||
|
"status": status,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
"ready": ready,
|
"ready": ready,
|
||||||
"blocked_by": blocked_by,
|
"blocked_by": blocked_by,
|
||||||
|
|
@ -131,12 +139,72 @@ def compute_initiative_graph_state(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _initiative_fulfillment_ratio(item_states: dict[str, dict[str, Any]]) -> Optional[float]:
|
||||||
|
ratios = [
|
||||||
|
st["fulfillment_ratio"]
|
||||||
|
for st in item_states.values()
|
||||||
|
if st.get("fulfillment_ratio") is not None
|
||||||
|
]
|
||||||
|
if not ratios:
|
||||||
|
return None
|
||||||
|
return round(sum(ratios) / len(ratios), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_graph_method_profile(
|
||||||
|
state: dict[str, Any], profile: GraphMethodProfile
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Passt blocked/ready an Methodenprofil an (AP1.4e)."""
|
||||||
|
if profile.enforce_gate_blocking:
|
||||||
|
state["graph_profile"] = graph_profile_as_dict(profile)
|
||||||
|
state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(state["items"])
|
||||||
|
return state
|
||||||
|
|
||||||
|
item_states = state["items"]
|
||||||
|
blocked_items: list[str] = []
|
||||||
|
ready_items: list[str] = []
|
||||||
|
|
||||||
|
for item_id, item_state in item_states.items():
|
||||||
|
would_block = item_state["blocked"]
|
||||||
|
item_state["would_block"] = would_block
|
||||||
|
item_state["blocked"] = False
|
||||||
|
status = item_state.get("status", "planned")
|
||||||
|
item_state["ready"] = status in ACTIVE_STATUSES
|
||||||
|
if item_state["ready"]:
|
||||||
|
ready_items.append(item_id)
|
||||||
|
|
||||||
|
state["items"] = item_states
|
||||||
|
state["blocked_items"] = blocked_items
|
||||||
|
state["ready_items"] = ready_items
|
||||||
|
state["graph_profile"] = graph_profile_as_dict(profile)
|
||||||
|
state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(item_states)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def compute_initiative_graph_state_with_profile(
|
||||||
|
*,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
dependencies: list[dict[str, Any]],
|
||||||
|
criteria_progress: Optional[dict[str, dict[str, int]]] = None,
|
||||||
|
method_key: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
base = compute_initiative_graph_state(
|
||||||
|
items=items,
|
||||||
|
dependencies=dependencies,
|
||||||
|
criteria_progress=criteria_progress,
|
||||||
|
)
|
||||||
|
profile = get_graph_profile(method_key)
|
||||||
|
result = apply_graph_method_profile(base, profile)
|
||||||
|
result["method_key"] = method_key or DEFAULT_METHOD_KEY
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_initiative_graph_state(
|
def load_initiative_graph_state(
|
||||||
*, tenant_id: str, initiative_id: str
|
*, tenant_id: str, initiative_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Lädt Items, Kanten und Kriterien-Fortschritt und berechnet Graph-State."""
|
"""Lädt Items, Kanten, Kriterien und Methodenprofil."""
|
||||||
from services import roadmap as roadmap_service
|
from services import roadmap as roadmap_service
|
||||||
from services import roadmap_criteria as criteria_service
|
from services import roadmap_criteria as criteria_service
|
||||||
|
from services.steering_context import get_steering_context
|
||||||
|
|
||||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
items = roadmap_service.list_roadmap_items_for_initiative(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
|
@ -147,8 +215,12 @@ def load_initiative_graph_state(
|
||||||
criteria_progress = criteria_service.criteria_progress_for_initiative(
|
criteria_progress = criteria_service.criteria_progress_for_initiative(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
)
|
)
|
||||||
return compute_initiative_graph_state(
|
steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||||
|
method_key = (steering or {}).get("method_key") or DEFAULT_METHOD_KEY
|
||||||
|
|
||||||
|
return compute_initiative_graph_state_with_profile(
|
||||||
items=items,
|
items=items,
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
criteria_progress=criteria_progress,
|
criteria_progress=criteria_progress,
|
||||||
|
method_key=method_key,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ AttentionKind = Literal[
|
||||||
"initiative_without_next_action",
|
"initiative_without_next_action",
|
||||||
"stale_initiative",
|
"stale_initiative",
|
||||||
"milestone_at_risk",
|
"milestone_at_risk",
|
||||||
|
"gate_graph_blocked",
|
||||||
"overdue_action",
|
"overdue_action",
|
||||||
"review_due",
|
"review_due",
|
||||||
"recurring_due",
|
"recurring_due",
|
||||||
|
|
@ -250,6 +251,71 @@ def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
from steering.graph.profiles import enforce_gate_blocking_for_method
|
||||||
|
from steering.graph.roadmap_engine import load_initiative_graph_state
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT sc.initiative_id, sc.method_key
|
||||||
|
FROM steering_contexts sc
|
||||||
|
JOIN initiatives i
|
||||||
|
ON i.id = sc.initiative_id AND i.tenant_id = sc.tenant_id
|
||||||
|
WHERE sc.tenant_id = %s AND i.status IN ('active', 'paused')
|
||||||
|
ORDER BY i.updated_at DESC
|
||||||
|
LIMIT 30
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
method_key = row.get("method_key") or "generic_operating"
|
||||||
|
if not enforce_gate_blocking_for_method(method_key):
|
||||||
|
continue
|
||||||
|
|
||||||
|
initiative_id = str(row["initiative_id"])
|
||||||
|
state = load_initiative_graph_state(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
for gate_id in state.get("blocked_items", []):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT title FROM roadmap_items
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(gate_id, ctx.tenant_id),
|
||||||
|
)
|
||||||
|
gate_row = cur.fetchone()
|
||||||
|
if not gate_row:
|
||||||
|
continue
|
||||||
|
blocked_by = state["items"].get(gate_id, {}).get("blocked_by") or []
|
||||||
|
summary = (
|
||||||
|
"Graph-Voraussetzung noch nicht erreicht"
|
||||||
|
if len(blocked_by) != 1
|
||||||
|
else "Graph-Voraussetzung noch nicht erreicht"
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "gate_graph_blocked",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": gate_row["title"],
|
||||||
|
"summary": summary,
|
||||||
|
"scope_type": "milestone",
|
||||||
|
"scope_id": gate_id,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"action_id": None,
|
||||||
|
"blocker_id": None,
|
||||||
|
"milestone_id": gate_id,
|
||||||
|
"reason_code": "gate_graph_blocked",
|
||||||
|
"data_source": "roadmap_graph",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= 20:
|
||||||
|
return items
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -424,6 +490,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
items.extend(_unassigned_actions(cur, ctx))
|
items.extend(_unassigned_actions(cur, ctx))
|
||||||
items.extend(_initiatives_without_next_action(cur, ctx))
|
items.extend(_initiatives_without_next_action(cur, ctx))
|
||||||
items.extend(_stale_initiatives(cur, ctx))
|
items.extend(_stale_initiatives(cur, ctx))
|
||||||
|
items.extend(_graph_blocked_gates(cur, ctx))
|
||||||
items.extend(_milestones_at_risk(cur, ctx))
|
items.extend(_milestones_at_risk(cur, ctx))
|
||||||
items.extend(_overdue_actions(cur, ctx))
|
items.extend(_overdue_actions(cur, ctx))
|
||||||
items.extend(_actions_review_required(cur, ctx))
|
items.extend(_actions_review_required(cur, ctx))
|
||||||
|
|
|
||||||
|
|
@ -242,3 +242,30 @@ def test_initiative_roadmap_graph_state(client):
|
||||||
assert body["items"][gate_a["id"]]["blocked"] is True
|
assert body["items"][gate_a["id"]]["blocked"] is True
|
||||||
assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
|
assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
|
||||||
assert gate_a["id"] not in body["ready_items"]
|
assert gate_a["id"] not in body["ready_items"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_blocked_attention_for_gate_enforcing_method(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
client.patch(
|
||||||
|
f"/api/steering/initiatives/{initiative_id}/context",
|
||||||
|
json={"method_key": "product_milestone_driven"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A", sort_order=0).json()
|
||||||
|
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B", sort_order=1).json()
|
||||||
|
client.post(
|
||||||
|
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||||
|
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
attention = client.get("/api/workspace/attention", headers=_auth(token))
|
||||||
|
assert attention.status_code == 200
|
||||||
|
assert any(
|
||||||
|
i["kind"] == "gate_graph_blocked" and i["milestone_id"] == gate_a["id"]
|
||||||
|
for i in attention.json()
|
||||||
|
)
|
||||||
|
|
|
||||||
70
backend/tests/test_ap14e_graph_profiles.py
Normal file
70
backend/tests/test_ap14e_graph_profiles.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Unit tests for graph method profiles (AP1.4e)."""
|
||||||
|
|
||||||
|
from steering.graph.profiles import get_graph_profile
|
||||||
|
from steering.graph.roadmap_engine import (
|
||||||
|
apply_graph_method_profile,
|
||||||
|
compute_initiative_graph_state,
|
||||||
|
compute_initiative_graph_state_with_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item(item_id: str, status: str = "planned", **kwargs):
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"status": status,
|
||||||
|
"sort_order": kwargs.get("sort_order", 0),
|
||||||
|
"sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_operating_does_not_enforce_blocking():
|
||||||
|
items = [
|
||||||
|
_item("first", "planned", sort_order=0),
|
||||||
|
_item("second", "planned", sort_order=1),
|
||||||
|
]
|
||||||
|
state = compute_initiative_graph_state_with_profile(
|
||||||
|
items=items,
|
||||||
|
dependencies=[],
|
||||||
|
method_key="generic_operating",
|
||||||
|
)
|
||||||
|
assert state["graph_profile"]["enforce_gate_blocking"] is False
|
||||||
|
assert state["items"]["second"]["would_block"] is True
|
||||||
|
assert state["items"]["second"]["blocked"] is False
|
||||||
|
assert state["items"]["second"]["ready"] is True
|
||||||
|
assert state["blocked_items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_milestone_enforces_blocking():
|
||||||
|
items = [
|
||||||
|
_item("first", "planned", sort_order=0),
|
||||||
|
_item("second", "planned", sort_order=1),
|
||||||
|
]
|
||||||
|
state = compute_initiative_graph_state_with_profile(
|
||||||
|
items=items,
|
||||||
|
dependencies=[],
|
||||||
|
method_key="product_milestone_driven",
|
||||||
|
)
|
||||||
|
assert state["graph_profile"]["enforce_gate_blocking"] is True
|
||||||
|
assert state["items"]["second"]["blocked"] is True
|
||||||
|
assert "second" in state["blocked_items"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_maturity_progression_emphasizes_fulfillment():
|
||||||
|
profile = get_graph_profile("maturity_progression")
|
||||||
|
assert profile.emphasize_fulfillment is True
|
||||||
|
assert profile.enforce_gate_blocking is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_initiative_fulfillment_ratio_average():
|
||||||
|
items = [_item("a", "active"), _item("b", "active")]
|
||||||
|
base = compute_initiative_graph_state(
|
||||||
|
items=items,
|
||||||
|
dependencies=[],
|
||||||
|
criteria_progress={
|
||||||
|
"a": {"total": 2, "closed": 2, "open": 0},
|
||||||
|
"b": {"total": 4, "closed": 2, "open": 2},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile = get_graph_profile("maturity_progression")
|
||||||
|
state = apply_graph_method_profile(base, profile)
|
||||||
|
assert state["initiative_fulfillment_ratio"] == 0.75
|
||||||
19
frontend/src/components/GateFulfillmentBadge.jsx
Normal file
19
frontend/src/components/GateFulfillmentBadge.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function GateFulfillmentBadge({ graphState, itemId }) {
|
||||||
|
const ratio = graphState?.items?.[itemId]?.fulfillment_ratio
|
||||||
|
if (ratio == null) return null
|
||||||
|
|
||||||
|
const pct = Math.round(ratio * 100)
|
||||||
|
const emphasize = graphState?.graph_profile?.emphasize_fulfillment
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'status-pill status-pill--fulfillment' +
|
||||||
|
(emphasize ? ' status-pill--fulfillment-primary' : '')
|
||||||
|
}
|
||||||
|
title="Kriterien erfüllt / gesamt"
|
||||||
|
>
|
||||||
|
{pct}% Kriterien
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@ import { gateTitleById } from './GateSelect.jsx'
|
||||||
export function GateGraphStateBadge({ graphState, itemId, siblingItems = [] }) {
|
export function GateGraphStateBadge({ graphState, itemId, siblingItems = [] }) {
|
||||||
if (!graphState?.items?.[itemId]) return null
|
if (!graphState?.items?.[itemId]) return null
|
||||||
|
|
||||||
|
if (graphState.graph_profile?.enforce_gate_blocking === false) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const { blocked, ready, blocked_by: blockedBy = [] } = graphState.items[itemId]
|
const { blocked, ready, blocked_by: blockedBy = [] } = graphState.items[itemId]
|
||||||
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
|
|
|
||||||
|
|
@ -18,27 +18,29 @@ function truncateTitle(title, max = 28) {
|
||||||
return `${title.slice(0, max - 1)}…`
|
return `${title.slice(0, max - 1)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GateMapView({ initiativeId, items }) {
|
export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [dependencies, setDependencies] = useState([])
|
const [dependencies, setDependencies] = useState([])
|
||||||
const [graphState, setGraphState] = useState(null)
|
const [graphStateLocal, setGraphStateLocal] = useState(null)
|
||||||
|
const graphState = graphStateProp ?? graphStateLocal
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
setDependencies([])
|
setDependencies([])
|
||||||
setGraphState(null)
|
if (!graphStateProp) setGraphStateLocal(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
Promise.allSettled([
|
const loaders = [listInitiativeRoadmapDependencies(initiativeId)]
|
||||||
listInitiativeRoadmapDependencies(initiativeId),
|
if (!graphStateProp) {
|
||||||
listInitiativeRoadmapGraphState(initiativeId),
|
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
|
||||||
]).then(([depsResult, graphResult]) => {
|
}
|
||||||
|
Promise.allSettled(loaders).then(([depsResult, graphResult]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (depsResult.status === 'fulfilled') {
|
if (depsResult.status === 'fulfilled') {
|
||||||
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
|
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
|
||||||
|
|
@ -47,17 +49,17 @@ export function GateMapView({ initiativeId, items }) {
|
||||||
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
|
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (graphResult.status === 'fulfilled') {
|
if (!graphStateProp && graphResult?.status === 'fulfilled') {
|
||||||
setGraphState(graphResult.value || null)
|
setGraphStateLocal(graphResult.value || null)
|
||||||
} else {
|
} else if (!graphStateProp) {
|
||||||
setGraphState(null)
|
setGraphStateLocal(null)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [initiativeId])
|
}, [initiativeId, graphStateProp])
|
||||||
|
|
||||||
const layout = useMemo(
|
const layout = useMemo(
|
||||||
() => computeGateGraphLayout(items, dependencies),
|
() => computeGateGraphLayout(items, dependencies),
|
||||||
|
|
@ -152,10 +154,11 @@ export function GateMapView({ initiativeId, items }) {
|
||||||
{layout.nodes.map((node) => {
|
{layout.nodes.map((node) => {
|
||||||
const item = node.item
|
const item = node.item
|
||||||
const nodeGraph = graphState?.items?.[node.id]
|
const nodeGraph = graphState?.items?.[node.id]
|
||||||
|
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
|
||||||
const nodeClass =
|
const nodeClass =
|
||||||
'gate-map-view__node gate-map-view__node--' +
|
'gate-map-view__node gate-map-view__node--' +
|
||||||
(item.status || 'planned') +
|
(item.status || 'planned') +
|
||||||
(nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
|
(enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
|
||||||
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '')
|
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '')
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
||||||
import { GateMapView } from './GateMapView.jsx'
|
import { GateMapView } from './GateMapView.jsx'
|
||||||
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
||||||
|
import { listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||||
|
|
||||||
export function GatesPlanPanel({
|
export function GatesPlanPanel({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
|
|
@ -13,18 +14,56 @@ export function GatesPlanPanel({
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
|
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
|
||||||
|
const [graphState, setGraphState] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewMode(resolveGatesViewMode(initiativeId, items.length))
|
setViewMode(resolveGatesViewMode(initiativeId, items.length))
|
||||||
}, [initiativeId, items.length])
|
}, [initiativeId, items.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId) {
|
||||||
|
setGraphState(null)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
listInitiativeRoadmapGraphState(initiativeId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setGraphState(data || null)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setGraphState(null)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId, items])
|
||||||
|
|
||||||
function handleViewChange(mode) {
|
function handleViewChange(mode) {
|
||||||
setViewMode(mode)
|
setViewMode(mode)
|
||||||
storeGatesViewMode(initiativeId, mode)
|
storeGatesViewMode(initiativeId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fulfillmentPct =
|
||||||
|
graphState?.initiative_fulfillment_ratio != null
|
||||||
|
? Math.round(graphState.initiative_fulfillment_ratio * 100)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gates-plan-panel">
|
<div className="gates-plan-panel">
|
||||||
|
{graphState?.graph_profile?.emphasize_fulfillment && fulfillmentPct != null && (
|
||||||
|
<p className="gates-plan-panel__fulfillment muted">
|
||||||
|
Erfüllungsgrad (Plan): <strong>{fulfillmentPct}%</strong> — Reifegrad zählt, kein
|
||||||
|
Gate-Zwang.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{graphState?.graph_profile?.enforce_gate_blocking === false &&
|
||||||
|
!graphState?.graph_profile?.emphasize_fulfillment && (
|
||||||
|
<p className="gates-plan-panel__method-hint muted">
|
||||||
|
Gates sind optional — der Graph blockiert nicht hart; Reihenfolge über Kanten oder
|
||||||
|
Liste.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
|
<div className="gates-plan-panel__toolbar" role="tablist" aria-label="Zielzustände Ansicht">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -59,9 +98,10 @@ export function GatesPlanPanel({
|
||||||
onReorder={onReorder}
|
onReorder={onReorder}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
graphState={graphState}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GateMapView initiativeId={initiativeId} items={items} />
|
<GateMapView initiativeId={initiativeId} items={items} graphState={graphState} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||||
import {
|
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
|
||||||
} from '../constants/status.js'
|
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
||||||
|
import { GateFulfillmentBadge } from './GateFulfillmentBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { Modal } from './Modal.jsx'
|
import { Modal } from './Modal.jsx'
|
||||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||||
|
|
@ -33,42 +32,46 @@ export function RoadmapPlanSection({
|
||||||
onReorder,
|
onReorder,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
|
graphState: graphStateProp,
|
||||||
}) {
|
}) {
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [dragItemId, setDragItemId] = useState('')
|
const [dragItemId, setDragItemId] = useState('')
|
||||||
const [dropTargetId, setDropTargetId] = useState('')
|
const [dropTargetId, setDropTargetId] = useState('')
|
||||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||||
const [graphState, setGraphState] = useState(null)
|
const [graphStateLocal, setGraphStateLocal] = useState(null)
|
||||||
|
const graphState = graphStateProp ?? graphStateLocal
|
||||||
const isDesktop = useMinWidth(1024)
|
const isDesktop = useMinWidth(1024)
|
||||||
const canReorder = canManage && typeof onReorder === 'function'
|
const canReorder = canManage && typeof onReorder === 'function'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
setCriteriaProgress({})
|
setCriteriaProgress({})
|
||||||
setGraphState(null)
|
if (!graphStateProp) setGraphStateLocal(null)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
Promise.all([
|
const loaders = [listInitiativeRoadmapCriteriaProgress(initiativeId)]
|
||||||
listInitiativeRoadmapCriteriaProgress(initiativeId),
|
if (!graphStateProp) {
|
||||||
listInitiativeRoadmapGraphState(initiativeId),
|
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
|
||||||
])
|
}
|
||||||
.then(([progressData, graphData]) => {
|
Promise.all(loaders)
|
||||||
if (!cancelled) {
|
.then((results) => {
|
||||||
setCriteriaProgress(progressData || {})
|
if (cancelled) return
|
||||||
setGraphState(graphData || null)
|
setCriteriaProgress(results[0] || {})
|
||||||
|
if (!graphStateProp && results[1]) {
|
||||||
|
setGraphStateLocal(results[1] || null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCriteriaProgress({})
|
setCriteriaProgress({})
|
||||||
setGraphState(null)
|
if (!graphStateProp) setGraphStateLocal(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [initiativeId, items])
|
}, [initiativeId, items, graphStateProp])
|
||||||
|
|
||||||
const sortedItems = useMemo(() => sortByOrder(items), [items])
|
const sortedItems = useMemo(() => sortByOrder(items), [items])
|
||||||
|
|
||||||
|
|
@ -204,6 +207,7 @@ export function RoadmapPlanSection({
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
siblingItems={sortedItems}
|
siblingItems={sortedItems}
|
||||||
/>
|
/>
|
||||||
|
<GateFulfillmentBadge graphState={graphState} itemId={item.id} />
|
||||||
{canReorder && !isDesktop && (
|
{canReorder && !isDesktop && (
|
||||||
<ReorderControls
|
<ReorderControls
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export const ATTENTION_KIND_LABELS = {
|
||||||
initiative_without_next_action: 'Keine nächste Maßnahme',
|
initiative_without_next_action: 'Keine nächste Maßnahme',
|
||||||
stale_initiative: 'Inaktives Vorhaben',
|
stale_initiative: 'Inaktives Vorhaben',
|
||||||
milestone_at_risk: 'Meilenstein gefährdet',
|
milestone_at_risk: 'Meilenstein gefährdet',
|
||||||
|
gate_graph_blocked: 'Gate blockiert (Graph)',
|
||||||
overdue_action: 'Überfällig',
|
overdue_action: 'Überfällig',
|
||||||
review_due: 'Review fällig',
|
review_due: 'Review fällig',
|
||||||
recurring_due: 'Wiederkehrend fällig',
|
recurring_due: 'Wiederkehrend fällig',
|
||||||
|
|
|
||||||
|
|
@ -1797,6 +1797,22 @@
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-pill--fulfillment {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--fulfillment-primary {
|
||||||
|
background: #dbeafe;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gates-plan-panel__fulfillment,
|
||||||
|
.gates-plan-panel__method-hint {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.gate-map-view__node-title {
|
.gate-map-view__node-title {
|
||||||
fill: var(--jk-text, #1a1a1a);
|
fill: var(--jk-text, #1a1a1a);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
||||||
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
||||||
import { actionPath, scopedPath } from '../utils/routes.js'
|
import { actionPath, gatePath, scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
const SEVERITY_LABELS = {
|
const SEVERITY_LABELS = {
|
||||||
critical: 'Kritisch',
|
critical: 'Kritisch',
|
||||||
|
|
@ -16,6 +16,9 @@ const SEVERITY_LABELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function attentionLink(item) {
|
function attentionLink(item) {
|
||||||
|
if (item.milestone_id) {
|
||||||
|
return gatePath(item.milestone_id)
|
||||||
|
}
|
||||||
if (item.action_id) {
|
if (item.action_id) {
|
||||||
return actionPath(item.action_id)
|
return actionPath(item.action_id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user