{item.title}
+{item.goal}
+ )} +diff --git a/backend/data_layer/initiatives.py b/backend/data_layer/initiatives.py
index 36b23a7..a4b9538 100644
--- a/backend/data_layer/initiatives.py
+++ b/backend/data_layer/initiatives.py
@@ -22,10 +22,10 @@ def get_active_initiatives(
with conn.cursor(cursor_factory=RealDictCursor) as cur:
sql = """
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
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]
if limit is not None:
diff --git a/backend/migrations/022_portfolio_rank.sql b/backend/migrations/022_portfolio_rank.sql
new file mode 100644
index 0000000..20ea265
--- /dev/null
+++ b/backend/migrations/022_portfolio_rank.sql
@@ -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';
diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py
index 7e7c990..7f5fe25 100644
--- a/backend/routers/workspace.py
+++ b/backend/routers/workspace.py
@@ -11,11 +11,17 @@ from data_layer import attention as dl_attention
from data_layer import initiatives as dl_initiatives
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from services import initiatives as initiative_service
from tenant_context import TenantContext
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:
if not ctx.actor_id:
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")),
):
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
diff --git a/backend/services/initiatives.py b/backend/services/initiatives.py
index e18e314..be3aa0f 100644
--- a/backend/services/initiatives.py
+++ b/backend/services/initiatives.py
@@ -18,7 +18,7 @@ PRIORITIES = frozenset({"low", "normal", "high"})
_INITIATIVE_COLUMNS = """
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()
try:
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(
f"""
INSERT INTO initiatives (
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}
""",
(
@@ -105,6 +114,7 @@ def create_initiative(
archetype_key,
status,
priority,
+ next_rank,
owner_actor_id,
),
)
@@ -140,7 +150,7 @@ def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
- ORDER BY updated_at DESC, title
+ ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
@@ -294,3 +304,86 @@ def delete_initiative(
details={"initiative_id": initiative_id},
)
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
diff --git a/backend/steering/portfolio/__init__.py b/backend/steering/portfolio/__init__.py
new file mode 100644
index 0000000..4a83cb1
--- /dev/null
+++ b/backend/steering/portfolio/__init__.py
@@ -0,0 +1 @@
+"""Portfolio steering helpers."""
diff --git a/backend/steering/portfolio/priority.py b/backend/steering/portfolio/priority.py
new file mode 100644
index 0000000..f4538a8
--- /dev/null
+++ b/backend/steering/portfolio/priority.py
@@ -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
diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py
index 4f1ce7e..fb65041 100644
--- a/backend/steering/signals/default_rules.py
+++ b/backend/steering/signals/default_rules.py
@@ -632,11 +632,22 @@ def get_next_action_candidates(
item["initiative_id"] = str(item["initiative_id"])
candidates.append(item)
- return candidates[:limit]
+ sorted_candidates = _apply_portfolio_rank_sort(ctx, candidates)
+ return sorted_candidates[:limit]
finally:
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(
ctx: TenantContext, *, initiative_id: str, limit: int = 5
) -> list[dict[str, Any]]:
diff --git a/backend/tests/test_ap18_portfolio_priority.py b/backend/tests/test_ap18_portfolio_priority.py
new file mode 100644
index 0000000..70c8ea1
--- /dev/null
+++ b/backend/tests/test_ap18_portfolio_priority.py
@@ -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
diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
index d1320f4..db7a1fc 100644
--- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
+++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
@@ -314,6 +314,11 @@ REST für Agent-Actors. **Nach AP1.5 + Gate-Verify tragfähig.**
**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)
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 4852fff..a3db155 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -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` |
| Archetyp-/Methoden-Katalog PO | ✓ | ADP v0.2 + MVP v0.3 freigegeben 2026-07-10 |
| 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 |
| AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
diff --git a/frontend/src/api/workspace.js b/frontend/src/api/workspace.js
index 88b3efd..ad3355f 100644
--- a/frontend/src/api/workspace.js
+++ b/frontend/src/api/workspace.js
@@ -23,3 +23,10 @@ export function listWorkspaceActiveInitiatives(limit = 5) {
export function getActorWorkload() {
return apiFetch('/api/workspace/actors/workload')
}
+
+export function reorderPortfolio(initiativeIds) {
+ return apiFetch('/api/workspace/portfolio/reorder', {
+ method: 'POST',
+ body: JSON.stringify({ initiative_ids: initiativeIds }),
+ })
+}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index d4f3e96..3809304 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -779,6 +779,11 @@
margin-right: 0.35rem;
}
+.next-action-portfolio-rank {
+ font-size: 0.8125rem;
+ margin-right: 0.35rem;
+}
+
.next-action-kind {
font-size: 0.75rem;
margin-right: 0.35rem;
@@ -1174,10 +1179,43 @@
}
.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;
- text-decoration: none;
- color: inherit;
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 {
@@ -1185,7 +1223,7 @@
}
.initiative-portfolio-card-title {
- margin: 0 0 0.35rem;
+ margin: 0;
font-size: 1rem;
}
diff --git a/frontend/src/utils/portfolioRank.js b/frontend/src/utils/portfolioRank.js
new file mode 100644
index 0000000..d3fdfc3
--- /dev/null
+++ b/frontend/src/utils/portfolioRank.js
@@ -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}`
+}
diff --git a/frontend/src/utils/portfolioRank.test.js b/frontend/src/utils/portfolioRank.test.js
new file mode 100644
index 0000000..23826c8
--- /dev/null
+++ b/frontend/src/utils/portfolioRank.test.js
@@ -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()
+ })
+})
diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx
index eb75083..bbc9bad 100644
--- a/frontend/src/widgets/InitiativePortfolioWidget.jsx
+++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx
@@ -1,20 +1,27 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.js'
+import { reorderPortfolio } from '../api/workspace.js'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
+import { ReorderControls } from '../components/ReorderControls.jsx'
import { scopedPath } from '../utils/routes.js'
+import { formatPortfolioRank, sortByPortfolioRank } from '../utils/portfolioRank.js'
import { WidgetCard } from '../components/WidgetCard.jsx'
import { ArchetypeBadge } from '../components/ArchetypeBadge.jsx'
import { useEntityArchetypes } from '../hooks/useEntityArchetypes.js'
+import { useCapabilities } from '../hooks/useCapabilities.js'
export function InitiativePortfolioWidget() {
const { initiativeArchetypes } = useEntityArchetypes()
+ const { hasCapability } = useCapabilities()
+ const canManage = hasCapability('kairo.initiative.manage')
const [initiatives, setInitiatives] = useState([])
const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const load = useCallback(async () => {
@@ -34,13 +41,42 @@ export function InitiativePortfolioWidget() {
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 (
{item.goal} {item.goal}{item.title}
- {item.goal && (
- {item.title}
+