Kairo-Jinkendo/frontend/src/pages/InitiativeDetailPage.jsx
Lars a520d54bcf
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 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
AP1.1b: Vorhaben-Detail als Steuerungsfläche — Action-Hub statt paralleler Listen
Leitfrage oben, Maßnahmen mit verknüpftem Kontext aus dem Snapshot, vollständiges Meilenstein-Formular, Backlog/Entscheidungen/Nachweise unter Erweitert. Version 0.11.2-ap1.1b.
2026-07-05 17:39:18 +02:00

638 lines
18 KiB
JavaScript

import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
getInitiative,
getInitiativeSteeringSnapshot,
listInitiativeActions,
createInitiativeAction,
} from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import {
listInitiativeBlockers,
createInitiativeBlocker,
updateBlocker,
deleteBlocker,
} from '../api/blockers.js'
import {
listInitiativeBacklog,
createInitiativeBacklogItem,
updateBacklogItem,
deleteBacklogItem,
convertBacklogToAction,
} from '../api/backlog.js'
import {
listInitiativeMilestones,
createInitiativeMilestone,
updateMilestone,
deleteMilestone,
} from '../api/milestones.js'
import {
listInitiativeEvidence,
createInitiativeEvidence,
updateEvidence,
deleteEvidence,
} from '../api/evidence.js'
import {
listInitiativeDecisions,
createInitiativeDecision,
updateDecision,
deleteDecision,
} from '../api/decisions.js'
import {
listInitiativeReviews,
createInitiativeReview,
updateReview,
deleteReview,
} from '../api/reviews.js'
import {
listInitiativeRecurring,
createInitiativeRecurring,
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'
export function InitiativeDetailPage() {
const { id } = useParams()
const { context, loading: sessionLoading } = useSession()
const { capabilities } = useCapabilities()
const {
actors,
loading: actorsLoading,
error: actorsError,
usedFallback: actorsUsedFallback,
reload: reloadActors,
} = useActors()
const [initiative, setInitiative] = useState(null)
const [actions, setActions] = useState([])
const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([])
const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
const [recurringItems, setRecurringItems] = useState([])
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
const [steeringMethods, setSteeringMethods] = useState([])
const [methodBusy, setMethodBusy] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false)
const [editingAction, setEditingAction] = useState(null)
const [formBusy, setFormBusy] = useState(false)
const [hideDone, setHideDone] = useState(true)
const load = useCallback(async () => {
if (sessionLoading) return
setLoading(true)
setError(null)
try {
const [initData, actionData] = await Promise.all([
getInitiative(id),
listInitiativeActions(id),
])
setInitiative(initData)
setActions(actionData)
const loads = []
const canReadOm =
capabilities.has('kairo.initiative.read') ||
capabilities.has('kairo.blocker.read')
if (canReadOm) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
loads.push(
listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([]))
)
loads.push(
(async () => {
setSteeringSnapshotLoading(true)
setSteeringSnapshotError(null)
try {
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
} catch (err) {
setSteeringSnapshot(null)
setSteeringSnapshotError(err.message || 'Snapshot fehlgeschlagen')
} finally {
setSteeringSnapshotLoading(false)
}
})()
)
}
await Promise.all(loads)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id, capabilities, sessionLoading])
useEffect(() => {
load()
}, [load])
const visibleActions = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
const actionContextById = Object.fromEntries(
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
)
const unlinkedBlockers = blockers.filter((b) => !b.action_id)
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 …'
})()
async function handleMethodChange(methodKey) {
setMethodBusy(true)
try {
await updateInitiativeSteeringMethod(id, methodKey)
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
} catch (err) {
setSteeringSnapshotError(err.message)
} finally {
setMethodBusy(false)
}
}
async function handleCreateAction(payload) {
setFormBusy(true)
try {
const assigned =
payload.assigned_actor_ids?.length > 0
? payload.assigned_actor_ids
: context?.actor?.id
? [context.actor.id]
: []
await createInitiativeAction(id, { ...payload, assigned_actor_ids: assigned })
setShowActionForm(false)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleUpdateAction(actionId, payload) {
setFormBusy(true)
try {
await updateAction(actionId, {
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(actionId, payload.assigned_actor_ids || [])
}
setEditingAction(null)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleQuickStatus(action, status) {
if (!capabilities.has('kairo.action.manage')) return
try {
await updateAction(action.id, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBlocker(body) {
setFormBusy(true)
try {
await createInitiativeBlocker(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleCreateBlockerForAction(actionId) {
if (!capabilities.has('kairo.blocker.manage')) return
setFormBusy(true)
try {
await createInitiativeBlocker(id, {
title: 'Blocker',
action_id: actionId,
set_action_blocked: true,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBlockerStatus(blockerId, status) {
try {
await updateBlocker(blockerId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteBlocker(blockerId) {
try {
await deleteBlocker(blockerId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBacklog(body) {
setFormBusy(true)
try {
await createInitiativeBacklogItem(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBacklogStatus(itemId, status) {
try {
await updateBacklogItem(itemId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleConvertBacklog(itemId) {
setFormBusy(true)
try {
await convertBacklogToAction(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteBacklog(itemId) {
try {
await deleteBacklogItem(itemId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateMilestone(body) {
setFormBusy(true)
try {
await createInitiativeMilestone(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleMilestoneStatus(milestoneId, status) {
try {
await updateMilestone(milestoneId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteMilestone(milestoneId) {
try {
await deleteMilestone(milestoneId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateEvidence(body) {
setFormBusy(true)
try {
await createInitiativeEvidence(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleEvidenceStatus(evidenceId, status) {
try {
await updateEvidence(evidenceId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteEvidence(evidenceId) {
try {
await deleteEvidence(evidenceId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateDecision(body) {
setFormBusy(true)
try {
await createInitiativeDecision(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDecisionStatus(decisionId, status) {
try {
await updateDecision(decisionId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteDecision(decisionId) {
try {
await deleteDecision(decisionId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateReview(body) {
setFormBusy(true)
try {
await createInitiativeReview(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleReviewStatus(reviewId, status) {
try {
await updateReview(reviewId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteReview(reviewId) {
try {
await deleteReview(reviewId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateRecurring(body) {
setFormBusy(true)
try {
await createInitiativeRecurring(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleRecurringStatus(recurringId, status) {
try {
await updateRecurring(recurringId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteRecurring(recurringId) {
try {
await deleteRecurring(recurringId)
await load()
} catch (err) {
setError(err.message)
}
}
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>
)
}
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>
)
}