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
Ready/blocked-Badges, Planning Debt, Vorgänger-Verwaltung — getrennt vom Gate-Designer. Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
10 KiB
JavaScript
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 />
|
|
}
|