Kairo-Jinkendo/frontend/src/pages/ActionDetailPage.jsx
Lars 8546c7021b
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m19s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
AP1.16c: Durchführungsplan in Plan-Outline und Action-Detail.
Ready/blocked-Badges, Planning Debt, Vorgänger-Verwaltung — getrennt vom Gate-Designer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:25:55 +02:00

331 lines
10 KiB
JavaScript

import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js'
import {
getInitiativeSteeringSnapshot,
listInitiativeActions,
} from '../api/initiatives.js'
import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
import { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeRoadmapItems } from '../api/roadmap.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx'
import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { TasksSection } from '../components/TasksSection.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { recordRecentObject } from '../utils/recentObjects.js'
import { actionPath, scopedPath } from '../utils/routes.js'
function ActionDetailBody({
action,
context,
editing,
onEdit,
onCancelEdit,
onSubmit,
onQuickStatus,
onCreateBlocker,
formBusy,
capabilities,
actorsState,
projects = [],
roadmapItems = [],
allActions = [],
executionGraph = null,
}) {
if (editing) {
return (
<ActionForm
initial={action}
projects={projects}
roadmapItems={roadmapItems}
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
/>
<TasksSection
actionId={action.id}
canManage={capabilities.has('kairo.action.manage')}
roadmapItems={roadmapItems}
/>
<ActionDependenciesSection
initiativeId={action.initiative_id}
action={action}
actions={allActions}
graphState={executionGraph}
canManage={capabilities.has('kairo.action.manage')}
/>
</>
)
}
function ActionDetailStandalone() {
const { actionId } = useParams()
const { applyFromAction } = useProgramScope()
const { capabilities } = useCapabilities()
const actorsState = useActors()
const [action, setAction] = useState(null)
const [projects, setProjects] = useState([])
const [roadmapItems, setRoadmapItems] = useState([])
const [allActions, setAllActions] = useState([])
const [executionGraph, setExecutionGraph] = 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)
applyFromAction(actionData)
recordRecentObject({
type: 'action',
id: actionData.id,
title: actionData.title,
href: actionPath(actionData.id),
initiativeId: actionData.initiative_id,
})
const [snap, projectData, roadmapData, actionList, graphState] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []),
listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
listInitiativeActions(actionData.initiative_id).catch(() => []),
getInitiativeExecutionGraphState(actionData.initiative_id).catch(() => null),
])
setProjects(projectData)
setRoadmapItems(roadmapData)
setAllActions(Array.isArray(actionList) ? actionList : [])
setExecutionGraph(graphState)
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [actionId, applyFromAction])
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,
project_id: payload.project_id,
clear_project: payload.project_id === '' || payload.project_id === null,
roadmap_item_id: payload.roadmap_item_id,
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
})
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="/cockpit">Cockpit</Link>
<span aria-hidden="true"> / </span>
<Link to={scopedPath('/control/status', { initiativeId: action.initiative_id })}>
Vorhaben
</Link>
<span aria-hidden="true"> / </span>
<Link to={scopedPath('/work/today', { initiativeId: action.initiative_id })}>
Ausführen
</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}
projects={projects}
roadmapItems={roadmapItems}
allActions={allActions}
executionGraph={executionGraph}
/>
</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)
const [executionGraph, setExecutionGraph] = useState(null)
useEffect(() => {
if (!ops.initiativeId) {
setExecutionGraph(null)
return undefined
}
let cancelled = false
getInitiativeExecutionGraphState(ops.initiativeId)
.then((state) => {
if (!cancelled) setExecutionGraph(state)
})
.catch(() => {
if (!cancelled) setExecutionGraph(null)
})
return () => {
cancelled = true
}
}, [ops.initiativeId, ops.actions.length])
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,
}}
projects={ops.projects}
roadmapItems={ops.roadmapItems}
allActions={ops.actions}
executionGraph={executionGraph}
/>
</section>
)
}
export function ActionDetailPage({ nested = false }) {
if (nested) {
return <ActionDetailNested />
}
return <ActionDetailStandalone />
}