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
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:
parent
2796965aed
commit
687aad6cbd
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ○ |
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
55
frontend/src/components/InitiativeNavCards.jsx
Normal file
55
frontend/src/components/InitiativeNavCards.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/InitiativeSubNav.jsx
Normal file
31
frontend/src/components/InitiativeSubNav.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
const visibleActions = useMemo(
|
||||
() =>
|
||||
hideDone
|
||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||
: actions
|
||||
|
||||
const actionContextById = Object.fromEntries(
|
||||
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
||||
: 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
|
||||
}
|
||||
68
frontend/src/layout/InitiativeLayout.jsx
Normal file
68
frontend/src/layout/InitiativeLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/ActionDetailPage.jsx
Normal file
241
frontend/src/pages/ActionDetailPage.jsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
70
frontend/src/pages/initiative/InitiativeExecutionPage.jsx
Normal file
70
frontend/src/pages/initiative/InitiativeExecutionPage.jsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
frontend/src/pages/initiative/InitiativeInboxPage.jsx
Normal file
34
frontend/src/pages/initiative/InitiativeInboxPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
57
frontend/src/pages/initiative/InitiativeJourneyPage.jsx
Normal file
57
frontend/src/pages/initiative/InitiativeJourneyPage.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/initiative/InitiativeOverviewPage.jsx
Normal file
78
frontend/src/pages/initiative/InitiativeOverviewPage.jsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
32
frontend/src/pages/initiative/InitiativePlanPage.jsx
Normal file
32
frontend/src/pages/initiative/InitiativePlanPage.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
71
frontend/src/widgets/InitiativePortfolioWidget.jsx
Normal file
71
frontend/src/widgets/InitiativePortfolioWidget.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,48 +7,20 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
|||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||
|
||||
export function NextActionWidget() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getNextActionCandidates(5)
|
||||
setItems(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Nächste sinnvolle Schritte"
|
||||
subtitle="Was bringt deine Vorhaben jetzt am wirksamsten voran?"
|
||||
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 && 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">
|
||||
<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">
|
||||
|
|
@ -57,13 +29,19 @@ export function NextActionWidget() {
|
|||
<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>
|
||||
<p className="list-item-sub recommended-action">→ {item.recommended_action}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta">
|
||||
{item.initiative_id && (
|
||||
{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>
|
||||
|
|
@ -72,7 +50,104 @@ export function NextActionWidget() {
|
|||
</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(scope === 'portfolio')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (scope === 'initiative') return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getNextActionCandidates(limit)
|
||||
setItems(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scope, limit])
|
||||
|
||||
useEffect(() => {
|
||||
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={heading}
|
||||
subtitle={sub}
|
||||
className="widget-card--span-full"
|
||||
actions={
|
||||
scope === 'portfolio' ? (
|
||||
<button type="button" className="btn btn-ghost" onClick={load} disabled={isLoading}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</WidgetCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user