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:
|
||||
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:
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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"])
|
||||
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]]:
|
||||
|
|
|
|||
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`
|
||||
|
||||
| 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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
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 { 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 (
|
||||
<WidgetCard
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
}
|
||||
|
|
@ -52,13 +88,33 @@ export function InitiativePortfolioWidget() {
|
|||
)}
|
||||
{!loading && !error && initiatives.length > 0 && (
|
||||
<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
|
||||
key={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>
|
||||
</div>
|
||||
{item.goal && (
|
||||
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
|
||||
)}
|
||||
|
|
@ -71,7 +127,9 @@ export function InitiativePortfolioWidget() {
|
|||
<PriorityBadge priority={item.priority} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
>
|
||||
<div className="list-item-main">
|
||||
<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">
|
||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user