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 ( + } @@ -52,26 +88,48 @@ export function InitiativePortfolioWidget() { )} {!loading && !error && initiatives.length > 0 && (
- {initiatives.map((item) => ( - -

{item.title}

- {item.goal && ( -

{item.goal}

- )} -
- - - -
- - ))} + {sortedInitiatives.map((item, index) => { + const rankLabel = formatPortfolioRank(item.portfolio_rank ?? index) + return ( +
+ {canManage && ( +
+ 0} + canMoveDown={index < sortedInitiatives.length - 1} + onMove={(direction) => handleMove(item.id, direction)} + busy={busy} + /> +
+ )} + +
+ {rankLabel && ( + + {rankLabel} + + )} +

{item.title}

+
+ {item.goal && ( +

{item.goal}

+ )} +
+ + + +
+ +
+ ) + })}
)}
diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx index e8c8ed0..29210eb 100644 --- a/frontend/src/widgets/NextActionWidget.jsx +++ b/frontend/src/widgets/NextActionWidget.jsx @@ -7,6 +7,7 @@ import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { WidgetCard } from '../components/WidgetCard.jsx' import { actionPath, scopedPath } from '../utils/routes.js' +import { formatPortfolioRank } from '../utils/portfolioRank.js' function NextActionList({ items, initiativeId, showInitiativeLink = true }) { if (items.length === 0) { @@ -43,6 +44,11 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) { >
{index + 1} + {item.portfolio_rank != null && ( + + {formatPortfolioRank(item.portfolio_rank)} + + )} {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}