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
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.
638 lines
18 KiB
JavaScript
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>
|
|
)
|
|
}
|