AP1.16d + AP1.9c: Planning Debt in Attention und Cockpit-Signale.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 12s
Attention-Regeln planning_debt/execution_waiting; Portfolio-Kacheln zeigen Steuerungssignale. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8546c7021b
commit
b9bfa9e5bd
|
|
@ -111,6 +111,77 @@ def compute_planning_debt(
|
|||
return debts
|
||||
|
||||
|
||||
def planning_debt_to_attention_items(
|
||||
*,
|
||||
initiative_id: str,
|
||||
debts: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Map Planning Debt read model → Attention Items (AP1.16d)."""
|
||||
items: list[dict[str, Any]] = []
|
||||
for debt in debts:
|
||||
gate_id = str(debt["roadmap_item_id"])
|
||||
items.append(
|
||||
{
|
||||
"kind": "planning_debt",
|
||||
"severity": "warning",
|
||||
"title": debt.get("title") or "Zielzustand",
|
||||
"summary": debt.get("message") or "Durchführungsplan fehlt",
|
||||
"scope_type": "milestone",
|
||||
"scope_id": gate_id,
|
||||
"initiative_id": initiative_id,
|
||||
"action_id": None,
|
||||
"blocker_id": None,
|
||||
"milestone_id": gate_id,
|
||||
"reason_code": "planning_debt",
|
||||
"data_source": "execution_graph",
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def execution_waiting_to_attention_items(
|
||||
*,
|
||||
initiative_id: str,
|
||||
actions: list[dict[str, Any]],
|
||||
graph_state: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Actions blocked by Execution-Graph (nicht Status=blocked) → Attention."""
|
||||
action_by_id = {str(action["id"]): action for action in actions}
|
||||
items: list[dict[str, Any]] = []
|
||||
for action_id in graph_state.get("blocked_actions", []):
|
||||
action = action_by_id.get(str(action_id))
|
||||
if not action:
|
||||
continue
|
||||
if action.get("status") in ("done", "discarded", "blocked"):
|
||||
continue
|
||||
meta = graph_state.get("items", {}).get(str(action_id), {})
|
||||
blocked_by = meta.get("blocked_by") or []
|
||||
summary = (
|
||||
"Wartet auf Vorgänger-Arbeitspaket"
|
||||
if len(blocked_by) == 1
|
||||
else f"Wartet auf {len(blocked_by)} Vorgänger"
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"kind": "execution_waiting",
|
||||
"severity": "info",
|
||||
"title": action.get("title") or "Arbeitspaket",
|
||||
"summary": summary,
|
||||
"scope_type": "action",
|
||||
"scope_id": str(action_id),
|
||||
"initiative_id": initiative_id,
|
||||
"action_id": str(action_id),
|
||||
"blocker_id": None,
|
||||
"milestone_id": (
|
||||
str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None
|
||||
),
|
||||
"reason_code": "execution_waiting",
|
||||
"data_source": "execution_graph",
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def compute_execution_graph_state(
|
||||
*,
|
||||
actions: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ AttentionKind = Literal[
|
|||
"stale_initiative",
|
||||
"milestone_at_risk",
|
||||
"gate_graph_blocked",
|
||||
"planning_debt",
|
||||
"execution_waiting",
|
||||
"overdue_action",
|
||||
"review_due",
|
||||
"recurring_due",
|
||||
|
|
@ -316,6 +318,61 @@ def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
return items
|
||||
|
||||
|
||||
def _execution_plan_attention(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
"""Planning Debt + Execution-Waiting — AP1.16d / Execution-Graph."""
|
||||
from services import actions as action_service
|
||||
from services import roadmap as roadmap_service
|
||||
from steering.graph.execution_engine import (
|
||||
compute_execution_graph_state,
|
||||
compute_planning_debt,
|
||||
execution_waiting_to_attention_items,
|
||||
planning_debt_to_attention_items,
|
||||
)
|
||||
from services.execution_plan import list_dependencies_for_initiative
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM initiatives
|
||||
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
(ctx.tenant_id,),
|
||||
)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in cur.fetchall():
|
||||
initiative_id = str(row["id"])
|
||||
actions = action_service.list_actions_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
roadmap_items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
debts = compute_planning_debt(actions=actions, roadmap_items=roadmap_items)
|
||||
items.extend(
|
||||
planning_debt_to_attention_items(initiative_id=initiative_id, debts=debts)
|
||||
)
|
||||
|
||||
dependencies = list_dependencies_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
graph_state = compute_execution_graph_state(
|
||||
actions=actions, dependencies=dependencies
|
||||
)
|
||||
items.extend(
|
||||
execution_waiting_to_attention_items(
|
||||
initiative_id=initiative_id,
|
||||
actions=actions,
|
||||
graph_state=graph_state,
|
||||
)
|
||||
)
|
||||
|
||||
if len(items) >= 30:
|
||||
return items[:30]
|
||||
return items
|
||||
|
||||
|
||||
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -491,6 +548,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
items.extend(_initiatives_without_next_action(cur, ctx))
|
||||
items.extend(_stale_initiatives(cur, ctx))
|
||||
items.extend(_graph_blocked_gates(cur, ctx))
|
||||
items.extend(_execution_plan_attention(cur, ctx))
|
||||
items.extend(_milestones_at_risk(cur, ctx))
|
||||
items.extend(_overdue_actions(cur, ctx))
|
||||
items.extend(_actions_review_required(cur, ctx))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from steering.graph.execution_engine import (
|
||||
compute_execution_graph_state,
|
||||
compute_planning_debt,
|
||||
execution_waiting_to_attention_items,
|
||||
planning_debt_to_attention_items,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -114,3 +116,45 @@ def test_scope_filters_to_gate():
|
|||
scope_roadmap_item_id="g1",
|
||||
)
|
||||
assert set(state["items"].keys()) == {"a"}
|
||||
|
||||
|
||||
def test_planning_debt_attention_items():
|
||||
items = planning_debt_to_attention_items(
|
||||
initiative_id="init-1",
|
||||
debts=[
|
||||
{
|
||||
"roadmap_item_id": "gate-1",
|
||||
"title": "G5 Plan",
|
||||
"message": "Aktiver Zielzustand ohne Durchführungsplan",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(items) == 1
|
||||
assert items[0]["kind"] == "planning_debt"
|
||||
assert items[0]["initiative_id"] == "init-1"
|
||||
assert items[0]["milestone_id"] == "gate-1"
|
||||
|
||||
|
||||
def test_execution_waiting_attention_items():
|
||||
actions = [
|
||||
_action("a", "open", sort_order=0),
|
||||
_action("b", "open", sort_order=1),
|
||||
]
|
||||
state = compute_execution_graph_state(
|
||||
actions=actions,
|
||||
dependencies=[
|
||||
{
|
||||
"predecessor_action_id": "a",
|
||||
"successor_action_id": "b",
|
||||
"dependency_kind": "requires",
|
||||
}
|
||||
],
|
||||
)
|
||||
items = execution_waiting_to_attention_items(
|
||||
initiative_id="init-1",
|
||||
actions=actions,
|
||||
graph_state=state,
|
||||
)
|
||||
assert len(items) == 1
|
||||
assert items[0]["kind"] == "execution_waiting"
|
||||
assert items[0]["action_id"] == "b"
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b–9e
|
|||
Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12a–d, 10c
|
||||
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c
|
||||
Phase I2 Plan/Ist Snapshots AP1.14 ✓
|
||||
Phase I3 Execution-Plan AP1.16 ◐ 16a–b ✓ / 16c UI / 16d offen
|
||||
Phase I3 Execution-Plan AP1.16 ◐ 16a–c ✓ / 16d ◐ / AP1.9c ◐
|
||||
Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred
|
||||
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f
|
||||
Phase L Agent Interface AP1.7 ○
|
||||
|
|
@ -180,7 +180,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro
|
|||
|-------|-------|--------|------------|
|
||||
| D0 | DOC-Sync (Truth Table, Gap, Review) | ✓ | eine Wahrheit |
|
||||
| D1 | Dogfooding R1 — Kairo-Jinkendo in Kairo | **→ nächstes** | B2b-Validation |
|
||||
| 1 | AP1.9c Cockpit-Signale | offen | MVP §5.7 |
|
||||
| 1 | AP1.9c Cockpit-Signale | **◐ Code** | Portfolio-Kacheln via Attention |
|
||||
| 2 | AP1.16a–b Execution-Graph (Schema + Engine) | **◐ Code** | **vor** AP2.0d; Remote-Verifikation nach Deploy |
|
||||
| 3 | AP2.0d Next-Action-Strategien | offen | MVP Stufe A; nutzt `ready_actions` |
|
||||
| 4 | AP1.16c–d Plan-Outline-Kanten + Planning Debt | offen | nach 16a–b |
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Initiative Archetypes | ◐ | Migration 016, Registry AP2.0b |
|
||||
| Method Profiles (Code-Seeds) | ◐ | `product.kairo_dev`, Kumite, Buch — AP2.0b |
|
||||
| Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` |
|
||||
| Planning Debt (Attention) | ◐ | Read Model AP1.16b; Cockpit AP1.16d offen |
|
||||
| Planning Debt (Attention) | ◐ | AP1.16d: Attention + Cockpit-Kacheln AP1.9c |
|
||||
| `work_cycle` / Sprint | ✗ | 📄 MVP v0.3 B3; Migration AP2.0f geplant |
|
||||
|
||||
---
|
||||
|
|
@ -99,7 +99,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Sicht | Stand | Anmerkung |
|
||||
|-------|-------|-----------|
|
||||
| PM Work Modes Shell | ✓ | AP1.9a: Cockpit, Work, Plan, Control + Redirects |
|
||||
| Cockpit (Portfolio) | ◐ | Widget-Grid; Rang AP1.8a; Signale auf Kacheln ✗ AP1.9c |
|
||||
| Cockpit (Portfolio) | ◐ | Widget-Grid; Rang AP1.8a; Signale AP1.9c ◐ |
|
||||
| Ausführen (/work) | ◐ | Heute, Meine APs |
|
||||
| Planen (/plan) | ◐ | Outline AP1.12, Gates, Inbox, Struktur, Profil |
|
||||
| Kontrolle (/control) | ◐ | Status, Plan/Ist AP1.14, Journey AP1.6b |
|
||||
|
|
|
|||
|
|
@ -69,21 +69,22 @@ Kanten-Richtung in DB: `predecessor_action_id` → `successor_action_id` (Vorgä
|
|||
| 9 | `GET/POST/DELETE …/execution/dependencies` |
|
||||
| 10 | pytest: Engine + Cycle-Detection (Unit) |
|
||||
|
||||
### AP1.16c — Frontend (folgt)
|
||||
### AP1.16c — Frontend ✓
|
||||
|
||||
| # | Inhalt |
|
||||
|---|--------|
|
||||
| 11 | Plan-Outline „Arbeit“: blocked/ready Badges |
|
||||
| 12 | Vorgänger-Kanten (Liste oder Mini-Graph) |
|
||||
| 13 | Dependency anlegen/löschen im Action-Kontext |
|
||||
| 12 | Action-Detail: Vorgänger-Verwaltung |
|
||||
| 13 | Planning Debt Banner auf Plan → Arbeit |
|
||||
|
||||
### AP1.16d — Steuerung (folgt)
|
||||
### AP1.16d + AP1.9c — Attention & Cockpit ◐
|
||||
|
||||
| # | Inhalt |
|
||||
|---|--------|
|
||||
| 14 | Method Profile: `planning_levels`, `planning_mode` |
|
||||
| 15 | Planning Debt in Attention / Cockpit |
|
||||
| 16 | AP2.0d: Next-Action nutzt `ready_actions` |
|
||||
| 14 | Attention: `planning_debt`, `execution_waiting` |
|
||||
| 15 | Cockpit Portfolio-Kacheln: Attention-Chips (AP1.9c) |
|
||||
| 16 | Method Profile `planning_levels` | deferred |
|
||||
| 17 | AP2.0d: Next-Action nutzt `ready_actions` | offen |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
27
frontend/src/components/InitiativePortfolioSignalChips.jsx
Normal file
27
frontend/src/components/InitiativePortfolioSignalChips.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { attentionKindLabel, topAttentionSignals } from '../utils/attentionSignals.js'
|
||||
|
||||
export function InitiativePortfolioSignalChips({ signals = [] }) {
|
||||
const top = topAttentionSignals(signals, 2)
|
||||
if (top.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="initiative-portfolio-card-signals" aria-label="Steuerungssignale">
|
||||
{top.map((item) => (
|
||||
<li
|
||||
key={`${item.kind}-${item.scope_id}`}
|
||||
className={`initiative-portfolio-card-signal initiative-portfolio-card-signal--${item.severity}`}
|
||||
title={item.summary || item.title}
|
||||
>
|
||||
{attentionKindLabel(item.kind)}
|
||||
</li>
|
||||
))}
|
||||
{signals.length > top.length && (
|
||||
<li className="initiative-portfolio-card-signal initiative-portfolio-card-signal--more muted">
|
||||
+{signals.length - top.length}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
@ -77,6 +77,8 @@ export const ATTENTION_KIND_LABELS = {
|
|||
stale_initiative: 'Inaktives Vorhaben',
|
||||
milestone_at_risk: 'Meilenstein gefährdet',
|
||||
gate_graph_blocked: 'Gate blockiert (Graph)',
|
||||
planning_debt: 'Durchführungsplan fehlt',
|
||||
execution_waiting: 'AP wartet (Reihenfolge)',
|
||||
overdue_action: 'Überfällig',
|
||||
review_due: 'Review fällig',
|
||||
recurring_due: 'Wiederkehrend fällig',
|
||||
|
|
|
|||
|
|
@ -1242,6 +1242,43 @@
|
|||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signal {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signal--critical {
|
||||
background: color-mix(in srgb, #dc2626 15%, transparent);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signal--warning {
|
||||
background: color-mix(in srgb, #d97706 15%, transparent);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signal--info {
|
||||
background: color-mix(in srgb, #2563eb 12%, transparent);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.initiative-portfolio-card-signal--more {
|
||||
background: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.initiative-journey-lead {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
|
|
|||
55
frontend/src/utils/attentionSignals.js
Normal file
55
frontend/src/utils/attentionSignals.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
||||
|
||||
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 }
|
||||
|
||||
/**
|
||||
* @param {Array<{ initiative_id?: string, severity?: string, kind?: string }>} items
|
||||
*/
|
||||
export function groupAttentionByInitiative(items) {
|
||||
/** @type {Record<string, Array>} */
|
||||
const grouped = {}
|
||||
for (const item of items || []) {
|
||||
const initiativeId = item.initiative_id
|
||||
if (!initiativeId) continue
|
||||
if (!grouped[initiativeId]) grouped[initiativeId] = []
|
||||
grouped[initiativeId].push(item)
|
||||
}
|
||||
for (const list of Object.values(grouped)) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99),
|
||||
)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export function attentionKindLabel(kind) {
|
||||
return ATTENTION_KIND_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} items
|
||||
* @param {number} limit
|
||||
*/
|
||||
export function topAttentionSignals(items, limit = 2) {
|
||||
if (!items?.length) return []
|
||||
return [...items]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} items
|
||||
*/
|
||||
export function initiativeSignalSummary(items) {
|
||||
const top = topAttentionSignals(items, 1)[0]
|
||||
if (!top) return null
|
||||
return {
|
||||
count: items.length,
|
||||
topSeverity: top.severity,
|
||||
topLabel: attentionKindLabel(top.kind),
|
||||
}
|
||||
}
|
||||
31
frontend/src/utils/attentionSignals.test.js
Normal file
31
frontend/src/utils/attentionSignals.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
groupAttentionByInitiative,
|
||||
initiativeSignalSummary,
|
||||
topAttentionSignals,
|
||||
} from './attentionSignals.js'
|
||||
|
||||
describe('attentionSignals', () => {
|
||||
const sample = [
|
||||
{ initiative_id: 'a', kind: 'planning_debt', severity: 'warning' },
|
||||
{ initiative_id: 'a', kind: 'execution_waiting', severity: 'info' },
|
||||
{ initiative_id: 'b', kind: 'open_blocker', severity: 'critical' },
|
||||
]
|
||||
|
||||
it('groups by initiative', () => {
|
||||
const grouped = groupAttentionByInitiative(sample)
|
||||
expect(grouped.a).toHaveLength(2)
|
||||
expect(grouped.b).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('picks top signals by severity', () => {
|
||||
const top = topAttentionSignals(sample.filter((i) => i.initiative_id === 'a'))
|
||||
expect(top[0].kind).toBe('planning_debt')
|
||||
})
|
||||
|
||||
it('summarizes initiative signals', () => {
|
||||
const summary = initiativeSignalSummary(sample.filter((i) => i.initiative_id === 'b'))
|
||||
expect(summary?.count).toBe(1)
|
||||
expect(summary?.topSeverity).toBe('critical')
|
||||
})
|
||||
})
|
||||
|
|
@ -16,6 +16,9 @@ const SEVERITY_LABELS = {
|
|||
}
|
||||
|
||||
function attentionLink(item) {
|
||||
if (item.kind === 'planning_debt' && item.initiative_id) {
|
||||
return scopedPath('/plan/work', { initiativeId: item.initiative_id })
|
||||
}
|
||||
if (item.milestone_id) {
|
||||
return gatePath(item.milestone_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getAttentionItems } from '../api/attention.js'
|
||||
import { listInitiatives } from '../api/initiatives.js'
|
||||
import { groupAttentionByInitiative } from '../utils/attentionSignals.js'
|
||||
import { InitiativePortfolioSignalChips } from '../components/InitiativePortfolioSignalChips.jsx'
|
||||
import { reorderPortfolio } from '../api/workspace.js'
|
||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||
|
|
@ -20,6 +23,7 @@ export function InitiativePortfolioWidget() {
|
|||
const { hasCapability } = useCapabilities()
|
||||
const canManage = hasCapability('kairo.initiative.manage')
|
||||
const [initiatives, setInitiatives] = useState([])
|
||||
const [attentionByInitiative, setAttentionByInitiative] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
|
@ -28,8 +32,12 @@ export function InitiativePortfolioWidget() {
|
|||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await listInitiatives()
|
||||
const [data, attention] = await Promise.all([
|
||||
listInitiatives(),
|
||||
getAttentionItems().catch(() => []),
|
||||
])
|
||||
setInitiatives(Array.isArray(data) ? data : [])
|
||||
setAttentionByInitiative(groupAttentionByInitiative(attention))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
|
|
@ -126,6 +134,9 @@ export function InitiativePortfolioWidget() {
|
|||
<StatusBadge kind="initiative" status={item.status} />
|
||||
<PriorityBadge priority={item.priority} />
|
||||
</div>
|
||||
<InitiativePortfolioSignalChips
|
||||
signals={attentionByInitiative[item.id] || []}
|
||||
/>
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user