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"
|
DB_SCHEMA_VERSION = "009"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,11 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
|
|
||||||
| Sicht | Stand | Anmerkung |
|
| Sicht | Stand | Anmerkung |
|
||||||
|-------|-------|-----------|
|
|-------|-------|-----------|
|
||||||
| Workspace (Portfolio aller Initiativen) | ◐ | Widgets; keine Initiative-Kacheln mit Steuerungssignal |
|
| Workspace (Portfolio aller Initiativen) | ✓ | Portfolio-Widget |
|
||||||
| Initiative-Übersicht (operative Steuerung) | ✗ | Heute: Omnibus InitiativeDetail |
|
| Initiative-Übersicht (operative Steuerung) | ✓ | AP1.2c |
|
||||||
| Initiative Unterseiten (Plan, Inbox, …) | ✗ | |
|
| Initiative Unterseiten (Plan, Inbox, …) | ✓ | Shell + Pflege verschoben |
|
||||||
| Operational Actor Interface (Vibe-Coder) | ✗ | Actors `agent` in DB; keine Agent-API |
|
| Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative |
|
||||||
|
| Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` |
|
||||||
| Action Detail-Seite | ✗ | |
|
| Action Detail-Seite | ✗ | |
|
||||||
| Gate Detail-Seite | ✗ | |
|
| Gate Detail-Seite | ✗ | |
|
||||||
| Modal-Bearbeitung | ✗ | Inline-Formulare überall |
|
| Modal-Bearbeitung | ✗ | Inline-Formulare überall |
|
||||||
|
|
@ -131,7 +132,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
|
|
||||||
| AP | Erwartete Truth-Table-Änderung |
|
| AP | Erwartete Truth-Table-Änderung |
|
||||||
|----|--------------------------------|
|
|----|--------------------------------|
|
||||||
| AP1.2c | IA-Routen ◐→✓, Modal-Pattern ◐ |
|
| AP1.2c | IA-Skeleton ✓ |
|
||||||
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
|
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
|
||||||
| AP1.5 | Task ○, Project GUI ◐ |
|
| AP1.5 | Task ○, Project GUI ◐ |
|
||||||
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |
|
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.11.2-ap1.1b",
|
"version": "0.12.0-ap1.2c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 { SessionProvider, useSession } from './context/SessionContext.jsx'
|
||||||
import { AppLayout } from './layout/AppLayout.jsx'
|
import { AppLayout } from './layout/AppLayout.jsx'
|
||||||
|
import { InitiativeLayout } from './layout/InitiativeLayout.jsx'
|
||||||
import { AuthPanel } from './pages/AuthPanel.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 { VIEWS } from './registry/viewRegistry.js'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
|
function InitiativeLegacyDetailRedirect() {
|
||||||
|
const { id } = useParams()
|
||||||
|
return <Navigate to={`/initiatives/${id}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated, loading } = useSession()
|
const { isAuthenticated, loading } = useSession()
|
||||||
|
|
||||||
|
|
@ -16,12 +28,24 @@ function AppRoutes() {
|
||||||
return <AuthPanel />
|
return <AuthPanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topLevelViews = VIEWS.filter((v) => v.key !== 'kairo.initiative_detail')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
{VIEWS.map((view) => (
|
{topLevelViews.map((view) => (
|
||||||
<Route key={view.key} path={view.path} element={<view.component />} />
|
<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 path="*" element={<Navigate to="/workspace" replace />} />
|
<Route path="*" element={<Navigate to="/workspace" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { PriorityBadge } from './PriorityBadge.jsx'
|
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||||
|
|
@ -49,6 +50,8 @@ export function ActionHubCard({
|
||||||
actorsError,
|
actorsError,
|
||||||
actorsUsedFallback,
|
actorsUsedFallback,
|
||||||
onReloadActors,
|
onReloadActors,
|
||||||
|
initiativeId,
|
||||||
|
detailMode = false,
|
||||||
}) {
|
}) {
|
||||||
const due = formatDue(action.due_at)
|
const due = formatDue(action.due_at)
|
||||||
const isBlocked = action.status === 'blocked' || context?.has_open_blocker
|
const isBlocked = action.status === 'blocked' || context?.has_open_blocker
|
||||||
|
|
@ -166,6 +169,14 @@ export function ActionHubCard({
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
|
{initiativeId && !detailMode && (
|
||||||
|
<Link
|
||||||
|
to={`/initiatives/${initiativeId}/actions/${action.id}`}
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ActionForm } from './ActionForm.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
export function InitiativeActionsHub({
|
export function InitiativeActionsHub({
|
||||||
|
initiativeId,
|
||||||
actions,
|
actions,
|
||||||
actionContextById,
|
actionContextById,
|
||||||
hideDone,
|
hideDone,
|
||||||
|
|
@ -95,6 +96,7 @@ export function InitiativeActionsHub({
|
||||||
actorsError={actorsError}
|
actorsError={actorsError}
|
||||||
actorsUsedFallback={actorsUsedFallback}
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
onReloadActors={onReloadActors}
|
onReloadActors={onReloadActors}
|
||||||
|
initiativeId={initiativeId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 {
|
||||||
import { Link, useParams } from 'react-router-dom'
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
getInitiative,
|
getInitiative,
|
||||||
getInitiativeSteeringSnapshot,
|
getInitiativeSteeringSnapshot,
|
||||||
|
|
@ -50,36 +57,18 @@ import {
|
||||||
updateRecurring,
|
updateRecurring,
|
||||||
deleteRecurring,
|
deleteRecurring,
|
||||||
} from '../api/recurring.js'
|
} 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 { 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 { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useActors } from '../hooks/useActors.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 { id } = useParams()
|
||||||
const { context, loading: sessionLoading } = useSession()
|
const { context, loading: sessionLoading } = useSession()
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const {
|
const actorsState = useActors()
|
||||||
actors,
|
|
||||||
loading: actorsLoading,
|
|
||||||
error: actorsError,
|
|
||||||
usedFallback: actorsUsedFallback,
|
|
||||||
reload: reloadActors,
|
|
||||||
} = useActors()
|
|
||||||
|
|
||||||
const [initiative, setInitiative] = useState(null)
|
const [initiative, setInitiative] = useState(null)
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
|
|
@ -103,7 +92,7 @@ export function InitiativeDetailPage() {
|
||||||
const [hideDone, setHideDone] = useState(true)
|
const [hideDone, setHideDone] = useState(true)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (sessionLoading) return
|
if (sessionLoading || !id) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -156,27 +145,23 @@ export function InitiativeDetailPage() {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const visibleActions = hideDone
|
const visibleActions = useMemo(
|
||||||
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
() =>
|
||||||
: actions
|
hideDone
|
||||||
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
const actionContextById = Object.fromEntries(
|
: actions,
|
||||||
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
[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 unlinkedBlockers = useMemo(
|
||||||
const c = steeringSnapshot?.counts
|
() => blockers.filter((b) => !b.action_id),
|
||||||
const parts = []
|
[blockers]
|
||||||
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 …'
|
|
||||||
})()
|
|
||||||
|
|
||||||
async function handleMethodChange(methodKey) {
|
async function handleMethodChange(methodKey) {
|
||||||
setMethodBusy(true)
|
setMethodBusy(true)
|
||||||
|
|
@ -481,157 +466,81 @@ export function InitiativeDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const value = {
|
||||||
return (
|
initiativeId: id,
|
||||||
<div className="app-page">
|
initiative,
|
||||||
<LoadingState message="Lade Vorhaben …" />
|
actions,
|
||||||
</div>
|
visibleActions,
|
||||||
)
|
blockers,
|
||||||
}
|
unlinkedBlockers,
|
||||||
if (error && !initiative) {
|
backlogItems,
|
||||||
return (
|
milestones,
|
||||||
<div className="app-page">
|
evidenceItems,
|
||||||
<ErrorState message={error} onRetry={load} />
|
decisions,
|
||||||
</div>
|
reviews,
|
||||||
)
|
recurringItems,
|
||||||
}
|
steeringSnapshot,
|
||||||
if (!initiative) {
|
steeringSnapshotLoading,
|
||||||
return (
|
steeringSnapshotError,
|
||||||
<div className="app-page">
|
steeringMethods,
|
||||||
<ErrorState message="Vorhaben nicht gefunden." />
|
methodBusy,
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="app-page">
|
<InitiativeOperationsContext.Provider value={value}>
|
||||||
<nav className="breadcrumb">
|
{children}
|
||||||
<Link to="/initiatives">Vorhaben</Link>
|
</InitiativeOperationsContext.Provider>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Workspace</h1>
|
<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>
|
</div>
|
||||||
{hasCapability('kairo.initiative.manage') && (
|
{hasCapability('kairo.initiative.manage') && (
|
||||||
<button
|
<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', () => {
|
it('contains expected AP0.6 widgets', () => {
|
||||||
const keys = WIDGETS.map((w) => w.key)
|
const keys = WIDGETS.map((w) => w.key)
|
||||||
expect(keys).toContain('kairo.my_open_actions')
|
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.blocked_actions')
|
||||||
expect(keys).toContain('kairo.tenant_context')
|
expect(keys).toContain('kairo.tenant_context')
|
||||||
expect(keys).toContain('kairo.workspace_summary')
|
expect(keys).toContain('kairo.workspace_summary')
|
||||||
|
|
@ -26,7 +26,7 @@ describe('widgetRegistry', () => {
|
||||||
|
|
||||||
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
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.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)
|
expect(readOnly.some((w) => w.key === 'kairo.attention')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
|
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
|
||||||
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
||||||
import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
|
|
||||||
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
|
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
|
||||||
|
|
||||||
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
|
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
|
||||||
|
|
@ -36,15 +35,6 @@ export const VIEWS = [
|
||||||
component: MyActionsPage,
|
component: MyActionsPage,
|
||||||
showInNav: true,
|
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 { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx'
|
||||||
import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
|
import { InitiativePortfolioWidget } from '../widgets/InitiativePortfolioWidget.jsx'
|
||||||
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
||||||
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
||||||
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
||||||
|
|
@ -85,13 +85,13 @@ export const WIDGETS = [
|
||||||
component: BlockedActionsWidget,
|
component: BlockedActionsWidget,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.active_initiatives',
|
key: 'kairo.initiative_portfolio',
|
||||||
title: 'Aktive Vorhaben',
|
title: 'Portfolio — alle Vorhaben',
|
||||||
description: 'Aktive und pausierte Vorhaben im Tenant',
|
description: 'Alle Initiativen im Tenant — Einstieg in die Steuerungs-Übersicht',
|
||||||
area: 'workspace',
|
area: 'workspace',
|
||||||
requiredCapability: 'kairo.initiative.read',
|
requiredCapability: 'kairo.initiative.read',
|
||||||
defaultOrder: 40,
|
defaultOrder: 1,
|
||||||
component: InitiativesWidget,
|
component: InitiativePortfolioWidget,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -967,3 +967,160 @@
|
||||||
border-style: dashed;
|
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,72 +7,147 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { WidgetCard } from '../components/WidgetCard.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 [items, setItems] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(scope === 'portfolio')
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
if (scope === 'initiative') return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await getNextActionCandidates(5)
|
const data = await getNextActionCandidates(limit)
|
||||||
setItems(Array.isArray(data) ? data : [])
|
setItems(Array.isArray(data) ? data : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [scope, limit])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
if (scope === 'portfolio') {
|
||||||
}, [load])
|
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 (
|
return (
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
title="Nächste sinnvolle Schritte"
|
title={heading}
|
||||||
subtitle="Was bringt deine Vorhaben jetzt am wirksamsten voran?"
|
subtitle={sub}
|
||||||
className="widget-card--span-full"
|
className="widget-card--span-full"
|
||||||
actions={
|
actions={
|
||||||
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
scope === 'portfolio' ? (
|
||||||
Aktualisieren
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={isLoading}>
|
||||||
</button>
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading && <LoadingState />}
|
{body}
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
</WidgetCard>
|
</WidgetCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user