From 687aad6cbd9f0fa687e99571c4064b531023477b Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Jul 2026 18:29:47 +0200 Subject: [PATCH] =?UTF-8?q?AP1.2c:=20IA-Skeleton=20=E2=80=94=20Workspace-P?= =?UTF-8?q?ortfolio,=20Initiative-=C3=9Cbersicht,=20Unterseiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/version.py | 2 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 11 +- frontend/package.json | 2 +- frontend/src/App.jsx | 28 +- frontend/src/components/ActionHubCard.jsx | 11 + .../src/components/InitiativeActionsHub.jsx | 2 + .../src/components/InitiativeNavCards.jsx | 55 ++++ frontend/src/components/InitiativeSubNav.jsx | 31 ++ .../InitiativeOperationsContext.jsx} | 295 ++++++------------ frontend/src/layout/InitiativeLayout.jsx | 68 ++++ frontend/src/pages/ActionDetailPage.jsx | 241 ++++++++++++++ frontend/src/pages/WorkspacePage.jsx | 2 +- .../initiative/InitiativeExecutionPage.jsx | 70 +++++ .../pages/initiative/InitiativeInboxPage.jsx | 34 ++ .../initiative/InitiativeJourneyPage.jsx | 57 ++++ .../initiative/InitiativeOverviewPage.jsx | 78 +++++ .../pages/initiative/InitiativePlanPage.jsx | 32 ++ frontend/src/registry/registry.test.js | 4 +- frontend/src/registry/viewRegistry.js | 10 - frontend/src/registry/widgetRegistry.js | 12 +- frontend/src/styles/components.css | 157 ++++++++++ .../src/widgets/InitiativePortfolioWidget.jsx | 71 +++++ frontend/src/widgets/NextActionWidget.jsx | 163 +++++++--- 23 files changed, 1171 insertions(+), 265 deletions(-) create mode 100644 frontend/src/components/InitiativeNavCards.jsx create mode 100644 frontend/src/components/InitiativeSubNav.jsx rename frontend/src/{pages/InitiativeDetailPage.jsx => context/InitiativeOperationsContext.jsx} (59%) create mode 100644 frontend/src/layout/InitiativeLayout.jsx create mode 100644 frontend/src/pages/ActionDetailPage.jsx create mode 100644 frontend/src/pages/initiative/InitiativeExecutionPage.jsx create mode 100644 frontend/src/pages/initiative/InitiativeInboxPage.jsx create mode 100644 frontend/src/pages/initiative/InitiativeJourneyPage.jsx create mode 100644 frontend/src/pages/initiative/InitiativeOverviewPage.jsx create mode 100644 frontend/src/pages/initiative/InitiativePlanPage.jsx create mode 100644 frontend/src/widgets/InitiativePortfolioWidget.jsx diff --git a/backend/version.py b/backend/version.py index e5b558f..ab1fa76 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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" diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 8802462..4682ae0 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -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 ○ | diff --git a/frontend/package.json b/frontend/package.json index f37e4a7..e7860f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7fd2ce8..9609575 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 +} + function AppRoutes() { const { isAuthenticated, loading } = useSession() @@ -16,12 +28,24 @@ function AppRoutes() { return } + const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail') + return ( }> - {VIEWS.map((view) => ( + {topLevelViews.map((view) => ( } /> ))} + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> } /> } /> diff --git a/frontend/src/components/ActionHubCard.jsx b/frontend/src/components/ActionHubCard.jsx index ebb1be7..b3fb157 100644 --- a/frontend/src/components/ActionHubCard.jsx +++ b/frontend/src/components/ActionHubCard.jsx @@ -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 + {initiativeId && !detailMode && ( + + Details + + )} )} diff --git a/frontend/src/components/InitiativeActionsHub.jsx b/frontend/src/components/InitiativeActionsHub.jsx index 69f2363..944cb80 100644 --- a/frontend/src/components/InitiativeActionsHub.jsx +++ b/frontend/src/components/InitiativeActionsHub.jsx @@ -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} /> ))} diff --git a/frontend/src/components/InitiativeNavCards.jsx b/frontend/src/components/InitiativeNavCards.jsx new file mode 100644 index 0000000..721248c --- /dev/null +++ b/frontend/src/components/InitiativeNavCards.jsx @@ -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 ( +
+

Pflege & Planung

+
+ {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 ( + +

{area.title}

+

{area.description}

+ {hint && {hint}} + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/InitiativeSubNav.jsx b/frontend/src/components/InitiativeSubNav.jsx new file mode 100644 index 0000000..2be8770 --- /dev/null +++ b/frontend/src/components/InitiativeSubNav.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/context/InitiativeOperationsContext.jsx similarity index 59% rename from frontend/src/pages/InitiativeDetailPage.jsx rename to frontend/src/context/InitiativeOperationsContext.jsx index 7d7d833..6b0c509 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -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 ( -
- -
- ) - } - if (error && !initiative) { - return ( -
- -
- ) - } - if (!initiative) { - return ( -
- -
- ) + 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 ( -
- - -
-
-

{initiative.title}

- {initiative.goal &&

{initiative.goal}

} -
- - -
-
-
- - {error &&

{error}

} - - {capabilities.has('kairo.initiative.read') && ( - - )} - - { - 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') && ( - - )} - - {capabilities.has('kairo.initiative.read') && ( - -
- - - - - - -
-
- )} -
+ + {children} + ) } + +export function useInitiativeOperations() { + const ctx = useContext(InitiativeOperationsContext) + if (!ctx) { + throw new Error('useInitiativeOperations must be used within InitiativeOperationsProvider') + } + return ctx +} diff --git a/frontend/src/layout/InitiativeLayout.jsx b/frontend/src/layout/InitiativeLayout.jsx new file mode 100644 index 0000000..c594e58 --- /dev/null +++ b/frontend/src/layout/InitiativeLayout.jsx @@ -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 ( +
+ +
+ ) + } + if (error && !initiative) { + return ( +
+ +
+ ) + } + if (!initiative) { + return ( +
+ +
+ ) + } + + return ( +
+ + +
+
+

{initiative.title}

+ {initiative.goal &&

{initiative.goal}

} +
+ + +
+
+
+ + + + +
+ ) +} + +export function InitiativeLayout() { + return ( + + + + ) +} diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx new file mode 100644 index 0000000..bae6aaa --- /dev/null +++ b/frontend/src/pages/ActionDetailPage.jsx @@ -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 ( + + ) + } + + return ( + + ) +} + +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 ( +
+ +
+ ) + } + + if (error || !action) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+
+

Arbeitspaket

+
+ setEditing(true)} + onCancelEdit={() => setEditing(false)} + onSubmit={handleSubmit} + onQuickStatus={handleQuickStatus} + onCreateBlocker={handleCreateBlocker} + formBusy={formBusy} + capabilities={capabilities} + actorsState={actorsState} + /> +
+
+ ) +} + +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 + } + + return ( +
+
+

Arbeitspaket

+
+ 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, + }} + /> +
+ ) +} + +export function ActionDetailPage({ nested = false }) { + if (nested) { + return + } + return +} diff --git a/frontend/src/pages/WorkspacePage.jsx b/frontend/src/pages/WorkspacePage.jsx index a6b307e..7a454be 100644 --- a/frontend/src/pages/WorkspacePage.jsx +++ b/frontend/src/pages/WorkspacePage.jsx @@ -38,7 +38,7 @@ export function WorkspacePage() {

Workspace

-

Was ist dein nächster sinnvoller Schritt?

+

Portfolio-Überblick und nächste Schritte über alle Vorhaben.

{hasCapability('kairo.initiative.manage') && ( + } + > + {loading && } + {!loading && error && } + {!loading && !error && initiatives.length === 0 && ( + + )} + {!loading && !error && initiatives.length > 0 && ( +
+ {initiatives.map((item) => ( + +

{item.title}

+ {item.goal && ( +

{item.goal}

+ )} +
+ + +
+ + ))} +
+ )} + + ) +} diff --git a/frontend/src/widgets/NextActionWidget.jsx b/frontend/src/widgets/NextActionWidget.jsx index 1563363..b548962 100644 --- a/frontend/src/widgets/NextActionWidget.jsx +++ b/frontend/src/widgets/NextActionWidget.jsx @@ -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 ( + + ) + } + + return ( +
    + {items.map((item, index) => ( +
  1. +
    + {index + 1} + + {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} + + {item.title} + {item.summary &&

    {item.summary}

    } + {item.recommended_action && ( +

    → {item.recommended_action}

    + )} +
    +
    + {item.action_id && initiativeId && ( + + Arbeitspaket + + )} + {showInitiativeLink && item.initiative_id && !initiativeId && ( + + Zum Vorhaben + + )} +
    +
  2. + ))} +
+ ) +} + +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 && } + {!isLoading && error && } + {!isLoading && !error && ( + + )} + + ) + + if (embedded) { + return ( +
+
+
+

{heading}

+

{sub}

+
+
+ {body} +
+ ) + } return ( - Aktualisieren - + scope === 'portfolio' ? ( + + ) : null } > - {loading && } - {!loading && error && } - {!loading && !error && items.length === 0 && ( - - )} - {!loading && !error && items.length > 0 && ( -
    - {items.map((item, index) => ( -
  1. -
    - {index + 1} - - {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} - - {item.title} - {item.summary &&

    {item.summary}

    } - {item.recommended_action && ( -

    - → {item.recommended_action} -

    - )} -
    -
    - {item.initiative_id && ( - - Zum Vorhaben - - )} -
    -
  2. - ))} -
- )} + {body}
) }