AP1.2c: IA-Skeleton — Workspace-Portfolio, Initiative-Übersicht, Unterseiten
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 1m26s
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 17s
Test Suite / playwright-smoke (push) Successful in 12s

Neue Navigation: Steuerung/Plan/Ausführung/Eingang/Journey. InitiativeOperationsContext, Action-Detail-Route, NextActionWidget portfolio/initiative, Portfolio-Widget auf Workspace. Omnibus InitiativeDetail entfernt. Version 0.12.0-ap1.2c.
This commit is contained in:
Lars 2026-07-05 18:29:47 +02:00
parent 2796965aed
commit 687aad6cbd
23 changed files with 1171 additions and 265 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.11.2-ap1.1b"
APP_VERSION = "0.12.0-ap1.2c"
DB_SCHEMA_VERSION = "009"
APP_NAME = "jinkendo-kairo"

View File

@ -81,10 +81,11 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Sicht | Stand | Anmerkung |
|-------|-------|-----------|
| Workspace (Portfolio aller Initiativen) | ◐ | Widgets; keine Initiative-Kacheln mit Steuerungssignal |
| Initiative-Übersicht (operative Steuerung) | ✗ | Heute: Omnibus InitiativeDetail |
| Initiative Unterseiten (Plan, Inbox, …) | ✗ | |
| Operational Actor Interface (Vibe-Coder) | ✗ | Actors `agent` in DB; keine Agent-API |
| Workspace (Portfolio aller Initiativen) | ✓ | Portfolio-Widget |
| Initiative-Übersicht (operative Steuerung) | ✓ | AP1.2c |
| Initiative Unterseiten (Plan, Inbox, …) | ✓ | Shell + Pflege verschoben |
| Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative |
| Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` |
| Action Detail-Seite | ✗ | |
| Gate Detail-Seite | ✗ | |
| Modal-Bearbeitung | ✗ | Inline-Formulare überall |
@ -131,7 +132,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| AP | Erwartete Truth-Table-Änderung |
|----|--------------------------------|
| AP1.2c | IA-Routen ◐→✓, Modal-Pattern ◐ |
| AP1.2c | IA-Skeleton ✓ |
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
| AP1.5 | Task ○, Project GUI ◐ |
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.11.2-ap1.1b",
"version": "0.12.0-ap1.2c",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,10 +1,22 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes, useParams } from 'react-router-dom'
import { SessionProvider, useSession } from './context/SessionContext.jsx'
import { AppLayout } from './layout/AppLayout.jsx'
import { InitiativeLayout } from './layout/InitiativeLayout.jsx'
import { AuthPanel } from './pages/AuthPanel.jsx'
import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx'
import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
import { VIEWS } from './registry/viewRegistry.js'
import './app.css'
function InitiativeLegacyDetailRedirect() {
const { id } = useParams()
return <Navigate to={`/initiatives/${id}`} replace />
}
function AppRoutes() {
const { isAuthenticated, loading } = useSession()
@ -16,12 +28,24 @@ function AppRoutes() {
return <AuthPanel />
}
const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail')
return (
<Routes>
<Route element={<AppLayout />}>
{VIEWS.map((view) => (
{topLevelViews.map((view) => (
<Route key={view.key} path={view.path} element={<view.component />} />
))}
<Route path="/initiatives/:id" element={<InitiativeLayout />}>
<Route index element={<InitiativeOverviewPage />} />
<Route path="plan" element={<InitiativePlanPage />} />
<Route path="execution" element={<InitiativeExecutionPage />} />
<Route path="inbox" element={<InitiativeInboxPage />} />
<Route path="journey" element={<InitiativeJourneyPage />} />
<Route path="actions/:actionId" element={<ActionDetailPage nested />} />
</Route>
<Route path="/initiatives/:id/detail" element={<InitiativeLegacyDetailRedirect />} />
<Route path="/actions/:actionId" element={<ActionDetailPage />} />
<Route path="/" element={<Navigate to="/workspace" replace />} />
<Route path="*" element={<Navigate to="/workspace" replace />} />
</Route>

View File

@ -1,3 +1,4 @@
import { Link } from 'react-router-dom'
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
@ -49,6 +50,8 @@ export function ActionHubCard({
actorsError,
actorsUsedFallback,
onReloadActors,
initiativeId,
detailMode = false,
}) {
const due = formatDue(action.due_at)
const isBlocked = action.status === 'blocked' || context?.has_open_blocker
@ -166,6 +169,14 @@ export function ActionHubCard({
>
Bearbeiten
</button>
{initiativeId && !detailMode && (
<Link
to={`/initiatives/${initiativeId}/actions/${action.id}`}
className="btn btn-secondary btn-sm"
>
Details
</Link>
)}
</footer>
)}
</article>

View File

@ -3,6 +3,7 @@ import { ActionForm } from './ActionForm.jsx'
import { EmptyState } from './EmptyState.jsx'
export function InitiativeActionsHub({
initiativeId,
actions,
actionContextById,
hideDone,
@ -95,6 +96,7 @@ export function InitiativeActionsHub({
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={onReloadActors}
initiativeId={initiativeId}
/>
))}
</div>

View File

@ -0,0 +1,55 @@
import { Link } from 'react-router-dom'
const AREAS = [
{
to: 'plan',
title: 'Plan',
description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.',
},
{
to: 'execution',
title: 'Ausführung',
description: 'Arbeitspakete, Zuweisungen, Blocker am Objekt.',
},
{
to: 'inbox',
title: 'Eingang',
description: 'Backlog triagieren und in Arbeitspakete überführen.',
},
{
to: 'journey',
title: 'Nachvollziehbarkeit',
description: 'Entscheidungen, Reviews, Nachweise über die Zeit.',
},
]
export function InitiativeNavCards({ initiativeId, counts = {} }) {
return (
<section className="initiative-nav-cards">
<h2 className="initiative-nav-cards-title">Pflege & Planung</h2>
<div className="initiative-nav-cards-grid">
{AREAS.map((area) => {
const hint =
area.to === 'inbox' && counts.backlog
? `${counts.backlog} Backlog`
: area.to === 'execution' && counts.actionsOpen
? `${counts.actionsOpen} offen`
: area.to === 'plan' && counts.milestones
? `${counts.milestones} Meilensteine`
: null
return (
<Link
key={area.to}
to={`/initiatives/${initiativeId}/${area.to}`}
className="initiative-nav-card card"
>
<h3>{area.title}</h3>
<p className="muted">{area.description}</p>
{hint && <span className="initiative-nav-card-hint">{hint}</span>}
</Link>
)
})}
</div>
</section>
)
}

View File

@ -0,0 +1,31 @@
import { NavLink, useParams } from 'react-router-dom'
const TABS = [
{ to: '', label: 'Steuerung', end: true },
{ to: 'plan', label: 'Plan' },
{ to: 'execution', label: 'Ausführung' },
{ to: 'inbox', label: 'Eingang' },
{ to: 'journey', label: 'Nachvollziehbarkeit' },
]
export function InitiativeSubNav() {
const { id } = useParams()
const base = `/initiatives/${id}`
return (
<nav className="initiative-subnav" aria-label="Vorhaben-Bereiche">
{TABS.map((tab) => (
<NavLink
key={tab.to || 'overview'}
to={tab.to ? `${base}/${tab.to}` : base}
end={tab.end}
className={({ isActive }) =>
`initiative-subnav-link${isActive ? ' initiative-subnav-link--active' : ''}`
}
>
{tab.label}
</NavLink>
))}
</nav>
)
}

View File

@ -1,5 +1,12 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useParams } from 'react-router-dom'
import {
getInitiative,
getInitiativeSteeringSnapshot,
@ -50,36 +57,18 @@ import {
updateRecurring,
deleteRecurring,
} from '../api/recurring.js'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { BlockersSection } from '../components/BlockersSection.jsx'
import { BacklogSection } from '../components/BacklogSection.jsx'
import { MilestonesSection } from '../components/MilestonesSection.jsx'
import { EvidenceSection } from '../components/EvidenceSection.jsx'
import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { InitiativeActionsHub } from '../components/InitiativeActionsHub.jsx'
import { CollapsibleSection } from '../components/CollapsibleSection.jsx'
import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from '../context/SessionContext.jsx'
import { useSession } from './SessionContext.jsx'
export function InitiativeDetailPage() {
const InitiativeOperationsContext = createContext(null)
export function InitiativeOperationsProvider({ children }) {
const { id } = useParams()
const { context, loading: sessionLoading } = useSession()
const { capabilities } = useCapabilities()
const {
actors,
loading: actorsLoading,
error: actorsError,
usedFallback: actorsUsedFallback,
reload: reloadActors,
} = useActors()
const actorsState = useActors()
const [initiative, setInitiative] = useState(null)
const [actions, setActions] = useState([])
@ -103,7 +92,7 @@ export function InitiativeDetailPage() {
const [hideDone, setHideDone] = useState(true)
const load = useCallback(async () => {
if (sessionLoading) return
if (sessionLoading || !id) return
setLoading(true)
setError(null)
try {
@ -156,27 +145,23 @@ export function InitiativeDetailPage() {
load()
}, [load])
const visibleActions = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
const actionContextById = Object.fromEntries(
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
const visibleActions = useMemo(
() =>
hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions,
[actions, hideDone]
)
const unlinkedBlockers = blockers.filter((b) => !b.action_id)
const actionContextById = useMemo(
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
[steeringSnapshot]
)
const advancedSummary = (() => {
const c = steeringSnapshot?.counts
const parts = []
if (backlogItems.length) parts.push(`${backlogItems.length} Backlog`)
if (decisions.length) parts.push(`${decisions.length} Entscheidungen`)
if (recurringItems.length) parts.push(`${recurringItems.length} Wiederkehrend`)
if (unlinkedBlockers.length) parts.push(`${unlinkedBlockers.length} lose Blocker`)
if (c?.unlinked_evidence) parts.push(`${c.unlinked_evidence} lose Nachweise`)
if (reviews.length) parts.push(`${reviews.length} Reviews`)
return parts.length ? parts.join(' · ') : 'Backlog, Entscheidungen, Nachweise …'
})()
const unlinkedBlockers = useMemo(
() => blockers.filter((b) => !b.action_id),
[blockers]
)
async function handleMethodChange(methodKey) {
setMethodBusy(true)
@ -481,157 +466,81 @@ export function InitiativeDetailPage() {
}
}
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Vorhaben …" />
</div>
)
}
if (error && !initiative) {
return (
<div className="app-page">
<ErrorState message={error} onRetry={load} />
</div>
)
}
if (!initiative) {
return (
<div className="app-page">
<ErrorState message="Vorhaben nicht gefunden." />
</div>
)
const value = {
initiativeId: id,
initiative,
actions,
visibleActions,
blockers,
unlinkedBlockers,
backlogItems,
milestones,
evidenceItems,
decisions,
reviews,
recurringItems,
steeringSnapshot,
steeringSnapshotLoading,
steeringSnapshotError,
steeringMethods,
methodBusy,
loading,
error,
showActionForm,
setShowActionForm,
editingAction,
setEditingAction,
formBusy,
hideDone,
setHideDone,
actionContextById,
capabilities,
actors: actorsState.actors,
actorsLoading: actorsState.loading,
actorsError: actorsState.error,
actorsUsedFallback: actorsState.usedFallback,
reloadActors: actorsState.reload,
reload: load,
handleMethodChange,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
handleCreateBlocker,
handleCreateBlockerForAction,
handleBlockerStatus,
handleDeleteBlocker,
handleCreateBacklog,
handleBacklogStatus,
handleConvertBacklog,
handleDeleteBacklog,
handleCreateMilestone,
handleMilestoneStatus,
handleDeleteMilestone,
handleCreateEvidence,
handleEvidenceStatus,
handleDeleteEvidence,
handleCreateDecision,
handleDecisionStatus,
handleDeleteDecision,
handleCreateReview,
handleReviewStatus,
handleDeleteReview,
handleCreateRecurring,
handleRecurringStatus,
handleDeleteRecurring,
}
return (
<div className="app-page">
<nav className="breadcrumb">
<Link to="/initiatives">Vorhaben</Link>
<span aria-hidden="true"> / </span>
<span>{initiative.title}</span>
</nav>
<header className="page-header detail-header">
<div>
<h1>{initiative.title}</h1>
{initiative.goal && <p className="detail-goal">{initiative.goal}</p>}
<div className="detail-badges">
<StatusBadge kind="initiative" status={initiative.status} />
<PriorityBadge priority={initiative.priority} />
</div>
</div>
</header>
{error && <p className="error">{error}</p>}
{capabilities.has('kairo.initiative.read') && (
<SteeringSnapshotPanel
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
methods={steeringMethods}
canManageMethod={capabilities.has('kairo.initiative.manage')}
onMethodChange={handleMethodChange}
methodBusy={methodBusy}
/>
)}
<InitiativeActionsHub
actions={visibleActions}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => {
setShowActionForm((v) => !v)
setEditingAction(null)
}}
editingActionId={editingAction}
onEditAction={setEditingAction}
onCancelEdit={() => setEditingAction(null)}
onCreateAction={handleCreateAction}
onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
/>
{capabilities.has('kairo.initiative.read') && (
<MilestonesSection
milestones={milestones}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone}
onUpdateStatus={handleMilestoneStatus}
onDelete={handleDeleteMilestone}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<CollapsibleSection title="Erweitert" summary={advancedSummary}>
<div className="initiative-advanced-stack">
<BacklogSection
items={backlogItems}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdateStatus={handleBacklogStatus}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
/>
<DecisionsSection
items={decisions}
canManage={capabilities.has('kairo.decision.manage')}
onCreate={handleCreateDecision}
onUpdateStatus={handleDecisionStatus}
onDelete={handleDeleteDecision}
busy={formBusy}
/>
<RecurringSection
items={recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
onCreate={handleCreateRecurring}
onUpdateStatus={handleRecurringStatus}
onDelete={handleDeleteRecurring}
busy={formBusy}
/>
<BlockersSection
blockers={unlinkedBlockers}
heading="Vorhaben-Blocker"
lead="Ohne Maßnahmenbezug — verknüpfte Blocker erscheinen am Maßnahmen-Hub."
emptyMessage="Keine vorhabenweiten Blocker."
canManage={capabilities.has('kairo.blocker.manage')}
onCreate={handleCreateBlocker}
onUpdateStatus={handleBlockerStatus}
onDelete={handleDeleteBlocker}
busy={formBusy}
/>
<EvidenceSection
items={evidenceItems}
canManage={capabilities.has('kairo.evidence.manage')}
onCreate={handleCreateEvidence}
onUpdateStatus={handleEvidenceStatus}
onDelete={handleDeleteEvidence}
busy={formBusy}
/>
<ReviewsSection
items={reviews}
canManage={capabilities.has('kairo.review.manage')}
onCreate={handleCreateReview}
onUpdateStatus={handleReviewStatus}
onDelete={handleDeleteReview}
busy={formBusy}
/>
</div>
</CollapsibleSection>
)}
</div>
<InitiativeOperationsContext.Provider value={value}>
{children}
</InitiativeOperationsContext.Provider>
)
}
export function useInitiativeOperations() {
const ctx = useContext(InitiativeOperationsContext)
if (!ctx) {
throw new Error('useInitiativeOperations must be used within InitiativeOperationsProvider')
}
return ctx
}

View File

@ -0,0 +1,68 @@
import { Link, Outlet } from 'react-router-dom'
import { InitiativeOperationsProvider, useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { InitiativeSubNav } from '../components/InitiativeSubNav.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
function InitiativeLayoutInner() {
const { initiative, loading, error, reload } = useInitiativeOperations()
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Vorhaben …" />
</div>
)
}
if (error && !initiative) {
return (
<div className="app-page">
<ErrorState message={error} onRetry={reload} />
</div>
)
}
if (!initiative) {
return (
<div className="app-page">
<ErrorState message="Vorhaben nicht gefunden." />
</div>
)
}
return (
<div className="app-page initiative-layout">
<nav className="breadcrumb">
<Link to="/workspace">Workspace</Link>
<span aria-hidden="true"> / </span>
<Link to="/initiatives">Vorhaben</Link>
<span aria-hidden="true"> / </span>
<span>{initiative.title}</span>
</nav>
<header className="page-header detail-header">
<div>
<h1>{initiative.title}</h1>
{initiative.goal && <p className="detail-goal">{initiative.goal}</p>}
<div className="detail-badges">
<StatusBadge kind="initiative" status={initiative.status} />
<PriorityBadge priority={initiative.priority} />
</div>
</div>
</header>
<InitiativeSubNav />
<Outlet />
</div>
)
}
export function InitiativeLayout() {
return (
<InitiativeOperationsProvider>
<InitiativeLayoutInner />
</InitiativeOperationsProvider>
)
}

View File

@ -0,0 +1,241 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js'
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
function ActionDetailBody({
action,
context,
editing,
onEdit,
onCancelEdit,
onSubmit,
onQuickStatus,
onCreateBlocker,
formBusy,
capabilities,
actorsState,
}) {
if (editing) {
return (
<ActionForm
initial={action}
actors={actorsState.actors}
actorsLoading={actorsState.loading}
actorsError={actorsState.error}
actorsUsedFallback={actorsState.usedFallback}
onReloadActors={actorsState.reload}
onSubmit={onSubmit}
onCancel={onCancelEdit}
busy={formBusy}
submitLabel="Speichern"
/>
)
}
return (
<ActionHubCard
action={action}
context={context}
editing={false}
onEdit={onEdit}
onCancelEdit={onCancelEdit}
onSubmitEdit={onSubmit}
onQuickStatus={onQuickStatus}
onCreateBlocker={onCreateBlocker}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actorsState.actors}
actorsLoading={actorsState.loading}
actorsError={actorsState.error}
actorsUsedFallback={actorsState.usedFallback}
onReloadActors={actorsState.reload}
detailMode
/>
)
}
function ActionDetailStandalone() {
const { actionId } = useParams()
const { capabilities } = useCapabilities()
const actorsState = useActors()
const [action, setAction] = useState(null)
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [editing, setEditing] = useState(false)
const [formBusy, setFormBusy] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const actionData = await getAction(actionId)
setAction(actionData)
const snap = await getInitiativeSteeringSnapshot(actionData.initiative_id)
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [actionId])
useEffect(() => {
load()
}, [load])
async function handleSubmit(payload) {
setFormBusy(true)
try {
await updateAction(action.id, {
title: payload.title,
description: payload.description,
status: payload.status,
priority: payload.priority,
due_at: payload.due_at,
clear_due_at: !payload.due_at,
})
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(action.id, payload.assigned_actor_ids || [])
}
setEditing(false)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleQuickStatus(status) {
if (!capabilities.has('kairo.action.manage')) return
try {
await updateAction(action.id, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBlocker() {
if (!capabilities.has('kairo.blocker.manage')) return
setFormBusy(true)
try {
await createInitiativeBlocker(action.initiative_id, {
title: 'Blocker',
action_id: action.id,
set_action_blocked: true,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Arbeitspaket …" />
</div>
)
}
if (error || !action) {
return (
<div className="app-page">
<ErrorState message={error || 'Arbeitspaket nicht gefunden.'} onRetry={load} />
</div>
)
}
return (
<div className="app-page">
<nav className="breadcrumb">
<Link to="/workspace">Workspace</Link>
<span aria-hidden="true"> / </span>
<Link to={`/initiatives/${action.initiative_id}`}>Vorhaben</Link>
<span aria-hidden="true"> / </span>
<Link to={`/initiatives/${action.initiative_id}/execution`}>Ausführung</Link>
<span aria-hidden="true"> / </span>
<span>{action.title}</span>
</nav>
<section className="card action-detail-page">
<header className="section-header">
<h2>Arbeitspaket</h2>
</header>
<ActionDetailBody
action={action}
context={context}
editing={editing}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSubmit={handleSubmit}
onQuickStatus={handleQuickStatus}
onCreateBlocker={handleCreateBlocker}
formBusy={formBusy}
capabilities={capabilities}
actorsState={actorsState}
/>
</section>
</div>
)
}
function ActionDetailNested() {
const { actionId } = useParams()
const ops = useInitiativeOperations()
const action = ops.actions.find((a) => a.id === actionId)
const context = ops.actionContextById[actionId]
const [editing, setEditing] = useState(false)
if (!action) {
return <ErrorState message="Arbeitspaket nicht in diesem Vorhaben." onRetry={ops.reload} />
}
return (
<section className="card action-detail-page">
<header className="section-header">
<h2>Arbeitspaket</h2>
</header>
<ActionDetailBody
action={action}
context={context}
editing={editing}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSubmit={(payload) => ops.handleUpdateAction(action.id, payload)}
onQuickStatus={(status) => ops.handleQuickStatus(action, status)}
onCreateBlocker={() => ops.handleCreateBlockerForAction(action.id)}
formBusy={ops.formBusy}
capabilities={ops.capabilities}
actorsState={{
actors: ops.actors,
loading: ops.actorsLoading,
error: ops.actorsError,
usedFallback: ops.actorsUsedFallback,
reload: ops.reloadActors,
}}
/>
</section>
)
}
export function ActionDetailPage({ nested = false }) {
if (nested) {
return <ActionDetailNested />
}
return <ActionDetailStandalone />
}

View File

@ -38,7 +38,7 @@ export function WorkspacePage() {
<header className="page-header">
<div>
<h1>Workspace</h1>
<p className="page-lead">Was ist dein nächster sinnvoller Schritt?</p>
<p className="page-lead">Portfolio-Überblick und nächste Schritte über alle Vorhaben.</p>
</div>
{hasCapability('kairo.initiative.manage') && (
<button

View File

@ -0,0 +1,70 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { BlockersSection } from '../../components/BlockersSection.jsx'
export function InitiativeExecutionPage() {
const ops = useInitiativeOperations()
const {
visibleActions,
actionContextById,
hideDone,
setHideDone,
showActionForm,
setShowActionForm,
editingAction,
setEditingAction,
capabilities,
formBusy,
unlinkedBlockers,
initiativeId,
error,
} = ops
return (
<>
{error && <p className="error">{error}</p>}
<InitiativeActionsHub
initiativeId={initiativeId}
actions={visibleActions}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => {
setShowActionForm((v) => !v)
setEditingAction(null)
}}
editingActionId={editingAction}
onEditAction={setEditingAction}
onCancelEdit={() => setEditingAction(null)}
onCreateAction={ops.handleCreateAction}
onUpdateAction={ops.handleUpdateAction}
onQuickStatus={ops.handleQuickStatus}
onCreateBlockerForAction={ops.handleCreateBlockerForAction}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={ops.actors}
actorsLoading={ops.actorsLoading}
actorsError={ops.actorsError}
actorsUsedFallback={ops.actorsUsedFallback}
onReloadActors={ops.reloadActors}
/>
{capabilities.has('kairo.initiative.read') && (
<BlockersSection
blockers={unlinkedBlockers}
heading="Vorhaben-Blocker"
lead="Ohne Maßnahmenbezug — verknüpfte Blocker erscheinen am Arbeitspaket."
emptyMessage="Keine vorhabenweiten Blocker."
canManage={capabilities.has('kairo.blocker.manage')}
onCreate={ops.handleCreateBlocker}
onUpdateStatus={ops.handleBlockerStatus}
onDelete={ops.handleDeleteBlocker}
busy={formBusy}
/>
)}
</>
)
}

View File

@ -0,0 +1,34 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { BacklogSection } from '../../components/BacklogSection.jsx'
export function InitiativeInboxPage() {
const {
backlogItems,
capabilities,
formBusy,
error,
handleCreateBacklog,
handleBacklogStatus,
handleConvertBacklog,
handleDeleteBacklog,
} = useInitiativeOperations()
if (!capabilities.has('kairo.initiative.read')) {
return null
}
return (
<>
{error && <p className="error">{error}</p>}
<BacklogSection
items={backlogItems}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdateStatus={handleBacklogStatus}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
/>
</>
)
}

View File

@ -0,0 +1,57 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { DecisionsSection } from '../../components/DecisionsSection.jsx'
import { ReviewsSection } from '../../components/ReviewsSection.jsx'
import { RecurringSection } from '../../components/RecurringSection.jsx'
import { EvidenceSection } from '../../components/EvidenceSection.jsx'
export function InitiativeJourneyPage() {
const ops = useInitiativeOperations()
const { capabilities, error, formBusy } = ops
if (!capabilities.has('kairo.initiative.read')) {
return null
}
return (
<>
{error && <p className="error">{error}</p>}
<p className="section-lead muted initiative-journey-lead">
Entscheidungen, Reviews und Nachweise die Reise-Timeline folgt in AP1.6.
</p>
<div className="initiative-journey-stack">
<DecisionsSection
items={ops.decisions}
canManage={capabilities.has('kairo.decision.manage')}
onCreate={ops.handleCreateDecision}
onUpdateStatus={ops.handleDecisionStatus}
onDelete={ops.handleDeleteDecision}
busy={formBusy}
/>
<ReviewsSection
items={ops.reviews}
canManage={capabilities.has('kairo.review.manage')}
onCreate={ops.handleCreateReview}
onUpdateStatus={ops.handleReviewStatus}
onDelete={ops.handleDeleteReview}
busy={formBusy}
/>
<RecurringSection
items={ops.recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
onCreate={ops.handleCreateRecurring}
onUpdateStatus={ops.handleRecurringStatus}
onDelete={ops.handleDeleteRecurring}
busy={formBusy}
/>
<EvidenceSection
items={ops.evidenceItems}
canManage={capabilities.has('kairo.evidence.manage')}
onCreate={ops.handleCreateEvidence}
onUpdateStatus={ops.handleEvidenceStatus}
onDelete={ops.handleDeleteEvidence}
busy={formBusy}
/>
</div>
</>
)
}

View File

@ -0,0 +1,78 @@
import { Link } from 'react-router-dom'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import { InitiativeNavCards } from '../../components/InitiativeNavCards.jsx'
export function InitiativeOverviewPage() {
const {
initiativeId,
error,
steeringSnapshot,
steeringSnapshotLoading,
steeringSnapshotError,
steeringMethods,
methodBusy,
capabilities,
handleMethodChange,
backlogItems,
milestones,
} = useInitiativeOperations()
const counts = steeringSnapshot?.counts || {}
return (
<>
{error && <p className="error">{error}</p>}
{capabilities.has('kairo.initiative.read') && (
<SteeringSnapshotPanel
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
methods={steeringMethods}
canManageMethod={capabilities.has('kairo.initiative.manage')}
onMethodChange={handleMethodChange}
methodBusy={methodBusy}
/>
)}
{capabilities.has('kairo.workspace.read') && (
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
/>
)}
{(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
<section className="card initiative-summary-strip">
<h2 className="card-title">Roadblocker</h2>
<p className="muted">
{counts.actions_blocked > 0 && (
<span>{counts.actions_blocked} blockierte Arbeitspakete</span>
)}
{counts.actions_blocked > 0 && counts.blockers_open > 0 && ' · '}
{counts.blockers_open > 0 && (
<span>{counts.blockers_open} offene Blocker</span>
)}
</p>
<Link to={`/initiatives/${initiativeId}/execution`} className="btn btn-secondary btn-sm">
Zur Ausführung
</Link>
</section>
)}
<InitiativeNavCards
initiativeId={initiativeId}
counts={{
backlog: backlogItems.length,
milestones: milestones.length,
actionsOpen: counts.actions_open,
}}
/>
</>
)
}

View File

@ -0,0 +1,32 @@
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { MilestonesSection } from '../../components/MilestonesSection.jsx'
export function InitiativePlanPage() {
const {
milestones,
capabilities,
formBusy,
handleCreateMilestone,
handleMilestoneStatus,
handleDeleteMilestone,
error,
} = useInitiativeOperations()
if (!capabilities.has('kairo.initiative.read')) {
return null
}
return (
<>
{error && <p className="error">{error}</p>}
<MilestonesSection
milestones={milestones}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone}
onUpdateStatus={handleMilestoneStatus}
onDelete={handleDeleteMilestone}
busy={formBusy}
/>
</>
)
}

View File

@ -7,7 +7,7 @@ describe('widgetRegistry', () => {
it('contains expected AP0.6 widgets', () => {
const keys = WIDGETS.map((w) => w.key)
expect(keys).toContain('kairo.my_open_actions')
expect(keys).toContain('kairo.active_initiatives')
expect(keys).toContain('kairo.initiative_portfolio')
expect(keys).toContain('kairo.blocked_actions')
expect(keys).toContain('kairo.tenant_context')
expect(keys).toContain('kairo.workspace_summary')
@ -26,7 +26,7 @@ describe('widgetRegistry', () => {
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
expect(readOnly.some((w) => w.key === 'kairo.active_initiatives')).toBe(true)
expect(readOnly.some((w) => w.key === 'kairo.initiative_portfolio')).toBe(true)
expect(readOnly.some((w) => w.key === 'kairo.attention')).toBe(false)
})
})

View File

@ -1,6 +1,5 @@
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
@ -36,15 +35,6 @@ export const VIEWS = [
component: MyActionsPage,
showInNav: true,
},
{
key: 'kairo.initiative_detail',
path: '/initiatives/:id',
title: 'Vorhaben-Detail',
requiredCapability: 'kairo.initiative.read',
component: InitiativeDetailPage,
showInNav: false,
matchPrefix: '/initiatives',
},
]
/**

View File

@ -1,5 +1,5 @@
import { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx'
import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
import { InitiativePortfolioWidget } from '../widgets/InitiativePortfolioWidget.jsx'
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
@ -85,13 +85,13 @@ export const WIDGETS = [
component: BlockedActionsWidget,
},
{
key: 'kairo.active_initiatives',
title: 'Aktive Vorhaben',
description: 'Aktive und pausierte Vorhaben im Tenant',
key: 'kairo.initiative_portfolio',
title: 'Portfolio — alle Vorhaben',
description: 'Alle Initiativen im Tenant — Einstieg in die Steuerungs-Übersicht',
area: 'workspace',
requiredCapability: 'kairo.initiative.read',
defaultOrder: 40,
component: InitiativesWidget,
defaultOrder: 1,
component: InitiativePortfolioWidget,
},
]

View File

@ -967,3 +967,160 @@
border-style: dashed;
}
/* AP1.2c — IA skeleton */
.initiative-subnav {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding: 0.25rem;
background: var(--jk-surface-raised, #f3f4f6);
border-radius: 0.5rem;
}
.initiative-subnav-link {
padding: 0.45rem 0.75rem;
border-radius: 0.35rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--jk-text-muted, #6b7280);
text-decoration: none;
}
.initiative-subnav-link:hover {
background: var(--jk-surface, #fff);
color: var(--jk-text, #111);
}
.initiative-subnav-link--active {
background: var(--jk-surface, #fff);
color: var(--jk-accent, #2563eb);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.initiative-nav-cards {
margin-top: 1rem;
}
.initiative-nav-cards-title {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.initiative-nav-cards-grid {
display: grid;
gap: 0.75rem;
}
@media (min-width: 640px) {
.initiative-nav-cards-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.initiative-nav-card {
display: block;
text-decoration: none;
color: inherit;
padding: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.initiative-nav-card:hover {
border-color: var(--jk-accent-muted, #dbeafe);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.08);
}
.initiative-nav-card h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.initiative-nav-card-hint {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--jk-accent, #2563eb);
}
.initiative-summary-strip {
margin-top: 1rem;
}
.initiative-summary-strip .card-title {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.initiative-portfolio-grid {
display: grid;
gap: 0.75rem;
}
@media (min-width: 640px) {
.initiative-portfolio-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 960px) {
.initiative-portfolio-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.initiative-portfolio-card {
display: block;
text-decoration: none;
color: inherit;
padding: 1rem;
}
.initiative-portfolio-card:hover {
border-color: var(--jk-accent-muted, #dbeafe);
}
.initiative-portfolio-card-title {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.initiative-portfolio-card-goal {
margin: 0 0 0.5rem;
font-size: 0.85rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.initiative-portfolio-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.initiative-journey-lead {
margin-bottom: 1rem;
}
.initiative-journey-stack {
display: grid;
gap: 0.75rem;
}
.initiative-journey-stack .card {
box-shadow: none;
}
.next-action-embedded {
margin-top: 1rem;
}
.action-detail-page {
margin-top: 0.5rem;
}

View File

@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.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 { WidgetCard } from '../components/WidgetCard.jsx'
export function InitiativePortfolioWidget() {
const [initiatives, setInitiatives] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await listInitiatives()
setInitiatives(Array.isArray(data) ? data : [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return (
<WidgetCard
title="Portfolio — alle Vorhaben"
subtitle="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}>
Aktualisieren
</button>
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && initiatives.length === 0 && (
<EmptyState message="Noch keine Vorhaben — lege dein erstes Programm an." />
)}
{!loading && !error && initiatives.length > 0 && (
<div className="initiative-portfolio-grid">
{initiatives.map((item) => (
<Link
key={item.id}
to={`/initiatives/${item.id}`}
className="initiative-portfolio-card card"
>
<h3 className="initiative-portfolio-card-title">{item.title}</h3>
{item.goal && (
<p className="initiative-portfolio-card-goal muted">{item.goal}</p>
)}
<div className="initiative-portfolio-card-badges">
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
</div>
</Link>
))}
</div>
)}
</WidgetCard>
)
}

View File

@ -7,72 +7,147 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
export function NextActionWidget() {
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
if (items.length === 0) {
return (
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
)
}
return (
<ol className="item-list next-action-list">
{items.map((item, index) => (
<li
key={`${item.kind}-${item.initiative_id}-${item.action_id || item.backlog_item_id || index}`}
className="list-item card-list-item"
>
<div className="list-item-main">
<span className="next-action-rank">{index + 1}</span>
<span className="badge status-badge status-active next-action-kind">
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
</span>
<strong>{item.title}</strong>
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
{item.recommended_action && (
<p className="list-item-sub recommended-action"> {item.recommended_action}</p>
)}
</div>
<div className="list-item-meta">
{item.action_id && initiativeId && (
<Link
to={`/initiatives/${initiativeId}/actions/${item.action_id}`}
className="btn btn-primary btn-sm"
>
Arbeitspaket
</Link>
)}
{showInitiativeLink && item.initiative_id && !initiativeId && (
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm">
Zum Vorhaben
</Link>
)}
</div>
</li>
))}
</ol>
)
}
export function NextActionWidget({
scope = 'portfolio',
initiativeId = null,
limit = 5,
items: externalItems = null,
loading: externalLoading = null,
embedded = false,
title,
subtitle,
onReload: externalReload,
}) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(scope === 'portfolio')
const [error, setError] = useState(null)
const load = useCallback(async () => {
if (scope === 'initiative') return
setLoading(true)
setError(null)
try {
const data = await getNextActionCandidates(5)
const data = await getNextActionCandidates(limit)
setItems(Array.isArray(data) ? data : [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
}, [scope, limit])
useEffect(() => {
load()
}, [load])
if (scope === 'portfolio') {
load()
}
}, [scope, load])
const displayItems =
scope === 'initiative'
? (externalItems || []).slice(0, limit)
: items
const isLoading = scope === 'initiative' ? externalLoading : loading
const handleReload = scope === 'initiative' ? externalReload : load
const heading =
title ||
(scope === 'initiative'
? 'Nächste Schritte in diesem Vorhaben'
: 'Nächste sinnvolle Schritte')
const sub =
subtitle ||
(scope === 'initiative'
? 'Empfehlungen aus dem Steuerungs-Snapshot.'
: 'Was bringt deine Vorhaben jetzt am wirksamsten voran?')
const body = (
<>
{isLoading && <LoadingState />}
{!isLoading && error && <ErrorState message={error} onRetry={handleReload} />}
{!isLoading && !error && (
<NextActionList
items={displayItems}
initiativeId={initiativeId}
showInitiativeLink={scope === 'portfolio'}
/>
)}
</>
)
if (embedded) {
return (
<section className="card next-action-embedded">
<div className="section-header">
<div>
<h2>{heading}</h2>
<p className="section-lead muted">{sub}</p>
</div>
</div>
{body}
</section>
)
}
return (
<WidgetCard
title="Nächste sinnvolle Schritte"
subtitle="Was bringt deine Vorhaben jetzt am wirksamsten voran?"
title={heading}
subtitle={sub}
className="widget-card--span-full"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren
</button>
scope === 'portfolio' ? (
<button type="button" className="btn btn-ghost" onClick={load} disabled={isLoading}>
Aktualisieren
</button>
) : null
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && items.length === 0 && (
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
)}
{!loading && !error && items.length > 0 && (
<ol className="item-list next-action-list">
{items.map((item, index) => (
<li key={`${item.kind}-${item.initiative_id}-${item.action_id || item.backlog_item_id || index}`} className="list-item card-list-item">
<div className="list-item-main">
<span className="next-action-rank">{index + 1}</span>
<span className="badge status-badge status-active next-action-kind">
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
</span>
<strong>{item.title}</strong>
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
{item.recommended_action && (
<p className="list-item-sub recommended-action">
{item.recommended_action}
</p>
)}
</div>
<div className="list-item-meta">
{item.initiative_id && (
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm">
Zum Vorhaben
</Link>
)}
</div>
</li>
))}
</ol>
)}
{body}
</WidgetCard>
)
}