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 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"})
|
||||
ACTIVE_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))
|
||||
item_states[item_id] = {
|
||||
"status": status,
|
||||
"blocked": blocked,
|
||||
"ready": ready,
|
||||
"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(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> 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_criteria as criteria_service
|
||||
from services.steering_context import get_steering_context
|
||||
|
||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
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(
|
||||
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,
|
||||
dependencies=dependencies,
|
||||
criteria_progress=criteria_progress,
|
||||
method_key=method_key,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ AttentionKind = Literal[
|
|||
"initiative_without_next_action",
|
||||
"stale_initiative",
|
||||
"milestone_at_risk",
|
||||
"gate_graph_blocked",
|
||||
"overdue_action",
|
||||
"review_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()]
|
||||
|
||||
|
||||
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]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -424,6 +490,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
items.extend(_unassigned_actions(cur, ctx))
|
||||
items.extend(_initiatives_without_next_action(cur, ctx))
|
||||
items.extend(_stale_initiatives(cur, ctx))
|
||||
items.extend(_graph_blocked_gates(cur, ctx))
|
||||
items.extend(_milestones_at_risk(cur, ctx))
|
||||
items.extend(_overdue_actions(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 gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
|
||||
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 = [] }) {
|
||||
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]
|
||||
|
||||
if (ready) {
|
||||
|
|
|
|||
|
|
@ -18,27 +18,29 @@ function truncateTitle(title, max = 28) {
|
|||
return `${title.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
export function GateMapView({ initiativeId, items }) {
|
||||
export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {
|
||||
const navigate = useNavigate()
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
const [graphStateLocal, setGraphStateLocal] = useState(null)
|
||||
const graphState = graphStateProp ?? graphStateLocal
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setDependencies([])
|
||||
setGraphState(null)
|
||||
if (!graphStateProp) setGraphStateLocal(null)
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
Promise.allSettled([
|
||||
listInitiativeRoadmapDependencies(initiativeId),
|
||||
listInitiativeRoadmapGraphState(initiativeId),
|
||||
]).then(([depsResult, graphResult]) => {
|
||||
const loaders = [listInitiativeRoadmapDependencies(initiativeId)]
|
||||
if (!graphStateProp) {
|
||||
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
|
||||
}
|
||||
Promise.allSettled(loaders).then(([depsResult, graphResult]) => {
|
||||
if (cancelled) return
|
||||
if (depsResult.status === 'fulfilled') {
|
||||
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.',
|
||||
)
|
||||
}
|
||||
if (graphResult.status === 'fulfilled') {
|
||||
setGraphState(graphResult.value || null)
|
||||
} else {
|
||||
setGraphState(null)
|
||||
if (!graphStateProp && graphResult?.status === 'fulfilled') {
|
||||
setGraphStateLocal(graphResult.value || null)
|
||||
} else if (!graphStateProp) {
|
||||
setGraphStateLocal(null)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId])
|
||||
}, [initiativeId, graphStateProp])
|
||||
|
||||
const layout = useMemo(
|
||||
() => computeGateGraphLayout(items, dependencies),
|
||||
|
|
@ -152,10 +154,11 @@ export function GateMapView({ initiativeId, items }) {
|
|||
{layout.nodes.map((node) => {
|
||||
const item = node.item
|
||||
const nodeGraph = graphState?.items?.[node.id]
|
||||
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
|
||||
const nodeClass =
|
||||
'gate-map-view__node gate-map-view__node--' +
|
||||
(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' : '')
|
||||
return (
|
||||
<g
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { RoadmapPlanSection } from './RoadmapPlanSection.jsx'
|
||||
import { GateMapView } from './GateMapView.jsx'
|
||||
import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js'
|
||||
import { listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||
|
||||
export function GatesPlanPanel({
|
||||
initiativeId,
|
||||
|
|
@ -13,18 +14,56 @@ export function GatesPlanPanel({
|
|||
busy,
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length))
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setViewMode(resolveGatesViewMode(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) {
|
||||
setViewMode(mode)
|
||||
storeGatesViewMode(initiativeId, mode)
|
||||
}
|
||||
|
||||
const fulfillmentPct =
|
||||
graphState?.initiative_fulfillment_ratio != null
|
||||
? Math.round(graphState.initiative_fulfillment_ratio * 100)
|
||||
: null
|
||||
|
||||
return (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -59,9 +98,10 @@ export function GatesPlanPanel({
|
|||
onReorder={onReorder}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
graphState={graphState}
|
||||
/>
|
||||
) : (
|
||||
<GateMapView initiativeId={initiativeId} items={items} />
|
||||
<GateMapView initiativeId={initiativeId} items={items} graphState={graphState} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js'
|
||||
import {
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { GateGraphStateBadge } from './GateGraphStateBadge.jsx'
|
||||
import { GateFulfillmentBadge } from './GateFulfillmentBadge.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||
|
|
@ -33,42 +32,46 @@ export function RoadmapPlanSection({
|
|||
onReorder,
|
||||
onDelete,
|
||||
busy,
|
||||
graphState: graphStateProp,
|
||||
}) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [dragItemId, setDragItemId] = useState('')
|
||||
const [dropTargetId, setDropTargetId] = useState('')
|
||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
const [graphStateLocal, setGraphStateLocal] = useState(null)
|
||||
const graphState = graphStateProp ?? graphStateLocal
|
||||
const isDesktop = useMinWidth(1024)
|
||||
const canReorder = canManage && typeof onReorder === 'function'
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setCriteriaProgress({})
|
||||
setGraphState(null)
|
||||
if (!graphStateProp) setGraphStateLocal(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
listInitiativeRoadmapCriteriaProgress(initiativeId),
|
||||
listInitiativeRoadmapGraphState(initiativeId),
|
||||
])
|
||||
.then(([progressData, graphData]) => {
|
||||
if (!cancelled) {
|
||||
setCriteriaProgress(progressData || {})
|
||||
setGraphState(graphData || null)
|
||||
const loaders = [listInitiativeRoadmapCriteriaProgress(initiativeId)]
|
||||
if (!graphStateProp) {
|
||||
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
|
||||
}
|
||||
Promise.all(loaders)
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
setCriteriaProgress(results[0] || {})
|
||||
if (!graphStateProp && results[1]) {
|
||||
setGraphStateLocal(results[1] || null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCriteriaProgress({})
|
||||
setGraphState(null)
|
||||
if (!graphStateProp) setGraphStateLocal(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId, items])
|
||||
}, [initiativeId, items, graphStateProp])
|
||||
|
||||
const sortedItems = useMemo(() => sortByOrder(items), [items])
|
||||
|
||||
|
|
@ -204,6 +207,7 @@ export function RoadmapPlanSection({
|
|||
itemId={item.id}
|
||||
siblingItems={sortedItems}
|
||||
/>
|
||||
<GateFulfillmentBadge graphState={graphState} itemId={item.id} />
|
||||
{canReorder && !isDesktop && (
|
||||
<ReorderControls
|
||||
itemId={item.id}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const ATTENTION_KIND_LABELS = {
|
|||
initiative_without_next_action: 'Keine nächste Maßnahme',
|
||||
stale_initiative: 'Inaktives Vorhaben',
|
||||
milestone_at_risk: 'Meilenstein gefährdet',
|
||||
gate_graph_blocked: 'Gate blockiert (Graph)',
|
||||
overdue_action: 'Überfällig',
|
||||
review_due: 'Review fällig',
|
||||
recurring_due: 'Wiederkehrend fällig',
|
||||
|
|
|
|||
|
|
@ -1797,6 +1797,22 @@
|
|||
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 {
|
||||
fill: var(--jk-text, #1a1a1a);
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { WidgetCard } from '../components/WidgetCard.jsx'
|
|||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
|
||||
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 = {
|
||||
critical: 'Kritisch',
|
||||
|
|
@ -16,6 +16,9 @@ const SEVERITY_LABELS = {
|
|||
}
|
||||
|
||||
function attentionLink(item) {
|
||||
if (item.milestone_id) {
|
||||
return gatePath(item.milestone_id)
|
||||
}
|
||||
if (item.action_id) {
|
||||
return actionPath(item.action_id)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user