AP1.8a: Portfolio-Rang im Cockpit mit Reorder und Next-Action-Sortierung.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m21s
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 18s
Test Suite / playwright-smoke (push) Successful in 21s
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m21s
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 18s
Test Suite / playwright-smoke (push) Successful in 21s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d7aefed0de
commit
a19c6abd26
|
|
@ -22,10 +22,10 @@ def get_active_initiatives(
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
status, priority, owner_actor_id, created_at, updated_at
|
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
|
||||||
FROM initiatives
|
FROM initiatives
|
||||||
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||||
ORDER BY updated_at DESC, title
|
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||||
"""
|
"""
|
||||||
params: list[Any] = [ctx.tenant_id]
|
params: list[Any] = [ctx.tenant_id]
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
|
|
|
||||||
24
backend/migrations/022_portfolio_rank.sql
Normal file
24
backend/migrations/022_portfolio_rank.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- AP1.8a: Portfolio-Priorität (relatives Ranking im Tenant)
|
||||||
|
|
||||||
|
ALTER TABLE initiatives
|
||||||
|
ADD COLUMN IF NOT EXISTS portfolio_rank INT;
|
||||||
|
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY tenant_id
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
) - 1 AS rn
|
||||||
|
FROM initiatives
|
||||||
|
)
|
||||||
|
UPDATE initiatives AS i
|
||||||
|
SET portfolio_rank = ranked.rn
|
||||||
|
FROM ranked
|
||||||
|
WHERE i.id = ranked.id AND i.portfolio_rank IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_initiatives_tenant_portfolio_rank
|
||||||
|
ON initiatives (tenant_id, portfolio_rank);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN initiatives.portfolio_rank IS
|
||||||
|
'Relatives Portfolio-Ranking (0 = höchste Aufmerksamkeit). AP1.8a';
|
||||||
|
|
@ -11,11 +11,17 @@ from data_layer import attention as dl_attention
|
||||||
from data_layer import initiatives as dl_initiatives
|
from data_layer import initiatives as dl_initiatives
|
||||||
from data_layer import workspace as dl_workspace
|
from data_layer import workspace as dl_workspace
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import initiatives as initiative_service
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioReorderRequest(BaseModel):
|
||||||
|
initiative_ids: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
|
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
|
||||||
if not ctx.actor_id:
|
if not ctx.actor_id:
|
||||||
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
||||||
|
|
@ -88,3 +94,18 @@ def workspace_next_actions(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
|
||||||
):
|
):
|
||||||
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
|
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/portfolio/reorder")
|
||||||
|
def workspace_portfolio_reorder(
|
||||||
|
body: PortfolioReorderRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return initiative_service.reorder_portfolio(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_ids=body.initiative_ids,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ PRIORITIES = frozenset({"low", "normal", "high"})
|
||||||
|
|
||||||
_INITIATIVE_COLUMNS = """
|
_INITIATIVE_COLUMNS = """
|
||||||
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
status, priority, owner_actor_id, created_at, updated_at
|
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -87,13 +87,22 @@ def create_initiative(
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(portfolio_rank), -1) + 1 AS next_rank
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id,),
|
||||||
|
)
|
||||||
|
next_rank = int(cur.fetchone()["next_rank"])
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO initiatives (
|
INSERT INTO initiatives (
|
||||||
tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
tenant_id, title, goal, vision, target_state_summary, archetype_key,
|
||||||
status, priority, owner_actor_id
|
status, priority, portfolio_rank, owner_actor_id
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING {_INITIATIVE_COLUMNS}
|
RETURNING {_INITIATIVE_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -105,6 +114,7 @@ def create_initiative(
|
||||||
archetype_key,
|
archetype_key,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
|
next_rank,
|
||||||
owner_actor_id,
|
owner_actor_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -140,7 +150,7 @@ def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
|
||||||
SELECT {_INITIATIVE_COLUMNS}
|
SELECT {_INITIATIVE_COLUMNS}
|
||||||
FROM initiatives
|
FROM initiatives
|
||||||
WHERE tenant_id = %s
|
WHERE tenant_id = %s
|
||||||
ORDER BY updated_at DESC, title
|
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||||
""",
|
""",
|
||||||
(tenant_id,),
|
(tenant_id,),
|
||||||
)
|
)
|
||||||
|
|
@ -294,3 +304,86 @@ def delete_initiative(
|
||||||
details={"initiative_id": initiative_id},
|
details={"initiative_id": initiative_id},
|
||||||
)
|
)
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def load_portfolio_rank_map(*, tenant_id: str) -> dict[str, Optional[int]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, portfolio_rank
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
""",
|
||||||
|
(tenant_id,),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
str(row["id"]): row["portfolio_rank"]
|
||||||
|
for row in cur.fetchall()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_portfolio(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_ids: list[str],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Setzt portfolio_rank für alle übergebenen Initiativen (0 = höchste Prio)."""
|
||||||
|
if not initiative_ids:
|
||||||
|
raise ValueError("initiative_ids darf nicht leer sein")
|
||||||
|
|
||||||
|
unique_ids = list(dict.fromkeys(initiative_ids))
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id,),
|
||||||
|
)
|
||||||
|
all_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||||
|
all_set = set(all_ids)
|
||||||
|
if set(unique_ids) - all_set:
|
||||||
|
raise ValueError("Unbekannte Initiative im Tenant")
|
||||||
|
if len(unique_ids) != len(all_ids):
|
||||||
|
raise ValueError("Alle Initiativen des Tenants müssen enthalten sein")
|
||||||
|
|
||||||
|
for rank, initiative_id in enumerate(unique_ids):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE initiatives
|
||||||
|
SET portfolio_rank = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(rank, initiative_id, tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_INITIATIVE_COLUMNS}
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s
|
||||||
|
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id,),
|
||||||
|
)
|
||||||
|
rows = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"portfolio.reordered",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"initiative_ids": unique_ids},
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
|
||||||
1
backend/steering/portfolio/__init__.py
Normal file
1
backend/steering/portfolio/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Portfolio steering helpers."""
|
||||||
39
backend/steering/portfolio/priority.py
Normal file
39
backend/steering/portfolio/priority.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Portfolio-Priorität — Sortierung für Next Action (AP1.8a)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_rank_sort_key(
|
||||||
|
initiative_id: Optional[str],
|
||||||
|
rank_map: dict[str, Optional[int]],
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Niedrigerer Rank = höhere Priorität; NULL ans Ende."""
|
||||||
|
if not initiative_id:
|
||||||
|
return (1_000_000, "")
|
||||||
|
rank = rank_map.get(initiative_id)
|
||||||
|
if rank is None:
|
||||||
|
return (999_999, initiative_id)
|
||||||
|
return (rank, initiative_id)
|
||||||
|
|
||||||
|
|
||||||
|
def sort_candidates_by_portfolio_rank(
|
||||||
|
candidates: list[dict[str, Any]],
|
||||||
|
rank_map: dict[str, Optional[int]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Sortiert Next-Action-Kandidaten nach Portfolio-Rang."""
|
||||||
|
enriched = []
|
||||||
|
for candidate in candidates:
|
||||||
|
item = dict(candidate)
|
||||||
|
initiative_id = item.get("initiative_id")
|
||||||
|
if initiative_id:
|
||||||
|
item["portfolio_rank"] = rank_map.get(str(initiative_id))
|
||||||
|
enriched.append(item)
|
||||||
|
enriched.sort(
|
||||||
|
key=lambda c: portfolio_rank_sort_key(
|
||||||
|
str(c["initiative_id"]) if c.get("initiative_id") else None,
|
||||||
|
rank_map,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return enriched
|
||||||
|
|
@ -632,11 +632,22 @@ def get_next_action_candidates(
|
||||||
item["initiative_id"] = str(item["initiative_id"])
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
candidates.append(item)
|
candidates.append(item)
|
||||||
|
|
||||||
return candidates[:limit]
|
sorted_candidates = _apply_portfolio_rank_sort(ctx, candidates)
|
||||||
|
return sorted_candidates[:limit]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_portfolio_rank_sort(
|
||||||
|
ctx: TenantContext, candidates: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
from services.initiatives import load_portfolio_rank_map
|
||||||
|
from steering.portfolio.priority import sort_candidates_by_portfolio_rank
|
||||||
|
|
||||||
|
rank_map = load_portfolio_rank_map(tenant_id=ctx.tenant_id)
|
||||||
|
return sort_candidates_by_portfolio_rank(candidates, rank_map)
|
||||||
|
|
||||||
|
|
||||||
def get_next_action_candidates_for_initiative(
|
def get_next_action_candidates_for_initiative(
|
||||||
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|
|
||||||
47
backend/tests/test_ap18_portfolio_priority.py
Normal file
47
backend/tests/test_ap18_portfolio_priority.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Unit tests for portfolio priority sorting — AP1.8a."""
|
||||||
|
|
||||||
|
from steering.portfolio.priority import sort_candidates_by_portfolio_rank
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_candidates_by_portfolio_rank():
|
||||||
|
candidates = [
|
||||||
|
{"kind": "a", "initiative_id": "low"},
|
||||||
|
{"kind": "b", "initiative_id": "high"},
|
||||||
|
{"kind": "c", "initiative_id": "mid"},
|
||||||
|
]
|
||||||
|
rank_map = {"high": 0, "mid": 1, "low": 2}
|
||||||
|
sorted_items = sort_candidates_by_portfolio_rank(candidates, rank_map)
|
||||||
|
assert [c["initiative_id"] for c in sorted_items] == ["high", "mid", "low"]
|
||||||
|
assert sorted_items[0]["portfolio_rank"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_rank_sorts_last():
|
||||||
|
candidates = [
|
||||||
|
{"initiative_id": "unknown"},
|
||||||
|
{"initiative_id": "ranked"},
|
||||||
|
]
|
||||||
|
rank_map = {"ranked": 0}
|
||||||
|
sorted_items = sort_candidates_by_portfolio_rank(candidates, rank_map)
|
||||||
|
assert sorted_items[0]["initiative_id"] == "ranked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_reorder_api(client):
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
first = _create_initiative(client, token, title="Alpha").json()
|
||||||
|
second = _create_initiative(client, token, title="Beta").json()
|
||||||
|
|
||||||
|
reorder = client.post(
|
||||||
|
"/api/workspace/portfolio/reorder",
|
||||||
|
json={"initiative_ids": [second["id"], first["id"]]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert reorder.status_code == 200
|
||||||
|
ordered = reorder.json()
|
||||||
|
assert ordered[0]["id"] == second["id"]
|
||||||
|
assert ordered[0]["portfolio_rank"] == 0
|
||||||
|
assert ordered[1]["portfolio_rank"] == 1
|
||||||
|
|
@ -314,6 +314,11 @@ REST für Agent-Actors. **Nach AP1.5 + Gate-Verify tragfähig.**
|
||||||
|
|
||||||
**Version:** `0.17.0-ap1.8`
|
**Version:** `0.17.0-ap1.8`
|
||||||
|
|
||||||
|
| Slice | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| **AP1.8a** | `portfolio_rank`, Cockpit-Reorder, Next-Action nach Portfolio-Rang |
|
||||||
|
| **AP1.8b** | Situativer Actor-Kontext, context_tags (deferred) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4.1 Erledigte Pakete (Referenzdetail)
|
## 4.1 Erledigte Pakete (Referenzdetail)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Method Registry | ◐ | generic_operating, product_milestone_driven; **Ziel AP2.0a:** program_delivery, continuous_product, … — siehe `ADP_Archetype_and_Method_Catalog_v0.2.md` |
|
| Method Registry | ◐ | generic_operating, product_milestone_driven; **Ziel AP2.0a:** program_delivery, continuous_product, … — siehe `ADP_Archetype_and_Method_Catalog_v0.2.md` |
|
||||||
| Archetyp-/Methoden-Katalog PO | ✓ | ADP v0.2 + MVP v0.3 freigegeben 2026-07-10 |
|
| Archetyp-/Methoden-Katalog PO | ✓ | ADP v0.2 + MVP v0.3 freigegeben 2026-07-10 |
|
||||||
| NextActionCandidate | ◐ | API + Widget; nicht konfigurierbar auf beiden Ebenen |
|
| NextActionCandidate | ◐ | API + Widget; nicht konfigurierbar auf beiden Ebenen |
|
||||||
| Portfolio-Priorität (Initiativen) | ✗ | |
|
| Portfolio-Priorität (Initiativen) | ◐ | AP1.8a: portfolio_rank, Cockpit-Reorder, Next-Action-Sort |
|
||||||
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
|
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
|
||||||
| AttentionItem | ◐ | |
|
| AttentionItem | ◐ | |
|
||||||
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
|
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,10 @@ export function listWorkspaceActiveInitiatives(limit = 5) {
|
||||||
export function getActorWorkload() {
|
export function getActorWorkload() {
|
||||||
return apiFetch('/api/workspace/actors/workload')
|
return apiFetch('/api/workspace/actors/workload')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reorderPortfolio(initiativeIds) {
|
||||||
|
return apiFetch('/api/workspace/portfolio/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ initiative_ids: initiativeIds }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -779,6 +779,11 @@
|
||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.next-action-portfolio-rank {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.next-action-kind {
|
.next-action-kind {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
|
|
@ -1174,10 +1179,43 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-portfolio-card {
|
.initiative-portfolio-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card__reorder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card__link {
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card__link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-portfolio-card__rank {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-accent, #2563eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-portfolio-card:hover {
|
.initiative-portfolio-card:hover {
|
||||||
|
|
@ -1185,7 +1223,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.initiative-portfolio-card-title {
|
.initiative-portfolio-card-title {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
frontend/src/utils/portfolioRank.js
Normal file
24
frontend/src/utils/portfolioRank.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Portfolio-Rang (AP1.8a) — niedrigerer Rank = höhere Aufmerksamkeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template {{ id: string, portfolio_rank?: number | null }} T
|
||||||
|
* @param {T[]} items
|
||||||
|
*/
|
||||||
|
export function sortByPortfolioRank(items) {
|
||||||
|
return [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.portfolio_rank ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(b.portfolio_rank ?? Number.MAX_SAFE_INTEGER) ||
|
||||||
|
String(a.id).localeCompare(String(b.id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number | null | undefined} rank
|
||||||
|
*/
|
||||||
|
export function formatPortfolioRank(rank) {
|
||||||
|
if (rank == null || Number.isNaN(rank)) return null
|
||||||
|
return `#${rank + 1}`
|
||||||
|
}
|
||||||
18
frontend/src/utils/portfolioRank.test.js
Normal file
18
frontend/src/utils/portfolioRank.test.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatPortfolioRank, sortByPortfolioRank } from './portfolioRank.js'
|
||||||
|
|
||||||
|
describe('portfolioRank', () => {
|
||||||
|
it('sorts by portfolio_rank ascending', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'c', portfolio_rank: 2 },
|
||||||
|
{ id: 'a', portfolio_rank: 0 },
|
||||||
|
{ id: 'b', portfolio_rank: 1 },
|
||||||
|
]
|
||||||
|
expect(sortByPortfolioRank(items).map((i) => i.id)).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats rank as 1-based display', () => {
|
||||||
|
expect(formatPortfolioRank(0)).toBe('#1')
|
||||||
|
expect(formatPortfolioRank(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listInitiatives } from '../api/initiatives.js'
|
import { listInitiatives } from '../api/initiatives.js'
|
||||||
|
import { reorderPortfolio } from '../api/workspace.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { ReorderControls } from '../components/ReorderControls.jsx'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
import { formatPortfolioRank, sortByPortfolioRank } from '../utils/portfolioRank.js'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
|
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
|
||||||
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
|
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
|
||||||
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
|
|
||||||
export function InitiativePortfolioWidget() {
|
export function InitiativePortfolioWidget() {
|
||||||
const { initiativeArchetypes } = useEntityArchetypes()
|
const { initiativeArchetypes } = useEntityArchetypes()
|
||||||
|
const { hasCapability } = useCapabilities()
|
||||||
|
const canManage = hasCapability('kairo.initiative.manage')
|
||||||
const [initiatives, setInitiatives] = useState([])
|
const [initiatives, setInitiatives] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -34,13 +41,42 @@ export function InitiativePortfolioWidget() {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const sortedInitiatives = useMemo(() => sortByPortfolioRank(initiatives), [initiatives])
|
||||||
|
|
||||||
|
async function handleMove(itemId, direction) {
|
||||||
|
const sorted = sortByPortfolioRank(initiatives)
|
||||||
|
const fromIndex = sorted.findIndex((item) => item.id === itemId)
|
||||||
|
if (fromIndex < 0) return
|
||||||
|
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
|
||||||
|
if (toIndex < 0 || toIndex >= sorted.length) return
|
||||||
|
|
||||||
|
const next = [...sorted]
|
||||||
|
const [moved] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, moved)
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const updated = await reorderPortfolio(next.map((item) => item.id))
|
||||||
|
setInitiatives(Array.isArray(updated) ? updated : next)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Portfolio — alle Vorhaben"
|
title="Portfolio — alle Vorhaben"
|
||||||
subtitle="Querschnitt über deine Initiativen. Klick öffnet die Steuerungs-Übersicht."
|
subtitle={
|
||||||
|
canManage
|
||||||
|
? 'Nach Portfolio-Priorität sortiert — ↑↓ setzt die Aufmerksamkeits-Reihenfolge.'
|
||||||
|
: 'Querschnitt über deine Initiativen. Klick öffnet die Steuerungs-Übersicht.'
|
||||||
|
}
|
||||||
className="widget-card--span-full"
|
className="widget-card--span-full"
|
||||||
actions={
|
actions={
|
||||||
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading || busy}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
@ -52,13 +88,33 @@ export function InitiativePortfolioWidget() {
|
||||||
)}
|
)}
|
||||||
{!loading && !error && initiatives.length > 0 && (
|
{!loading && !error && initiatives.length > 0 && (
|
||||||
<div className="initiative-portfolio-grid">
|
<div className="initiative-portfolio-grid">
|
||||||
{initiatives.map((item) => (
|
{sortedInitiatives.map((item, index) => {
|
||||||
|
const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index)
|
||||||
|
return (
|
||||||
|
<article key={item.id} className="initiative-portfolio-card card">
|
||||||
|
{canManage && (
|
||||||
|
<div className="initiative-portfolio-card__reorder">
|
||||||
|
<ReorderControls
|
||||||
|
itemId={item.id}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedInitiatives.length - 1}
|
||||||
|
onMove={(direction) => handleMove(item.id, direction)}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
|
||||||
to={scopedPath('/control/status', { initiativeId: item.id })}
|
to={scopedPath('/control/status', { initiativeId: item.id })}
|
||||||
className="initiative-portfolio-card card"
|
className="initiative-portfolio-card__link"
|
||||||
>
|
>
|
||||||
|
<div className="initiative-portfolio-card__head">
|
||||||
|
{rankLabel && (
|
||||||
|
<span className="initiative-portfolio-card__rank" title="Portfolio-Priorität">
|
||||||
|
{rankLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
|
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
|
||||||
|
</div>
|
||||||
{item.goal && (
|
{item.goal && (
|
||||||
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
|
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -71,7 +127,9 @@ export function InitiativePortfolioWidget() {
|
||||||
<PriorityBadge priority={item.priority} />
|
<PriorityBadge priority={item.priority} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WidgetCard>
|
</WidgetCard>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
import { actionPath, scopedPath } from '../utils/routes.js'
|
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||||
|
import { formatPortfolioRank } from '../utils/portfolioRank.js'
|
||||||
|
|
||||||
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
|
|
@ -43,6 +44,11 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||||
>
|
>
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<span className="next-action-rank">{index + 1}</span>
|
<span className="next-action-rank">{index + 1}</span>
|
||||||
|
{item.portfolio_rank != null && (
|
||||||
|
<span className="next-action-portfolio-rank muted" title="Portfolio-Priorität">
|
||||||
|
{formatPortfolioRank(item.portfolio_rank)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="badge status-badge status-active next-action-kind">
|
<span className="badge status-badge status-active next-action-kind">
|
||||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user