2026-07-05 06:41:23 +02:00
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
|
import { Link, useParams } from 'react-router-dom'
|
|
|
|
|
import {
|
|
|
|
|
getInitiative,
|
2026-07-05 15:32:33 +02:00
|
|
|
getInitiativeSteeringSnapshot,
|
2026-07-05 06:41:23 +02:00
|
|
|
listInitiativeActions,
|
|
|
|
|
createInitiativeAction,
|
|
|
|
|
} from '../api/initiatives.js'
|
|
|
|
|
import { updateAction, setActionAssignments } from '../api/actions.js'
|
2026-07-05 15:11:15 +02:00
|
|
|
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'
|
2026-07-05 15:27:03 +02:00
|
|
|
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 { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
2026-07-05 06:41:23 +02:00
|
|
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
|
|
|
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
|
|
|
|
import { ActionForm } from '../components/ActionForm.jsx'
|
2026-07-05 15:11:15 +02:00
|
|
|
import { BlockersSection } from '../components/BlockersSection.jsx'
|
|
|
|
|
import { BacklogSection } from '../components/BacklogSection.jsx'
|
|
|
|
|
import { MilestonesSection } from '../components/MilestonesSection.jsx'
|
2026-07-05 15:27:03 +02:00
|
|
|
import { EvidenceSection } from '../components/EvidenceSection.jsx'
|
|
|
|
|
import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
|
|
|
|
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
|
|
|
|
import { RecurringSection } from '../components/RecurringSection.jsx'
|
2026-07-05 15:32:33 +02:00
|
|
|
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
2026-07-05 06:41:23 +02:00
|
|
|
import { EmptyState } from '../components/EmptyState.jsx'
|
|
|
|
|
import { ErrorState } from '../components/ErrorState.jsx'
|
|
|
|
|
import { LoadingState } from '../components/LoadingState.jsx'
|
|
|
|
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
2026-07-05 07:25:01 +02:00
|
|
|
import { useActors } from '../hooks/useActors.js'
|
2026-07-05 06:41:23 +02:00
|
|
|
import { useSession } from '../context/SessionContext.jsx'
|
|
|
|
|
|
|
|
|
|
export function InitiativeDetailPage() {
|
|
|
|
|
const { id } = useParams()
|
2026-07-05 15:15:27 +02:00
|
|
|
const { context, loading: sessionLoading } = useSession()
|
2026-07-05 15:11:15 +02:00
|
|
|
const { capabilities } = useCapabilities()
|
2026-07-05 07:25:01 +02:00
|
|
|
const {
|
|
|
|
|
actors,
|
|
|
|
|
loading: actorsLoading,
|
|
|
|
|
error: actorsError,
|
|
|
|
|
usedFallback: actorsUsedFallback,
|
|
|
|
|
reload: reloadActors,
|
|
|
|
|
} = useActors()
|
2026-07-05 06:41:23 +02:00
|
|
|
|
|
|
|
|
const [initiative, setInitiative] = useState(null)
|
|
|
|
|
const [actions, setActions] = useState([])
|
2026-07-05 15:11:15 +02:00
|
|
|
const [blockers, setBlockers] = useState([])
|
|
|
|
|
const [backlogItems, setBacklogItems] = useState([])
|
|
|
|
|
const [milestones, setMilestones] = useState([])
|
2026-07-05 15:27:03 +02:00
|
|
|
const [evidenceItems, setEvidenceItems] = useState([])
|
|
|
|
|
const [decisions, setDecisions] = useState([])
|
|
|
|
|
const [reviews, setReviews] = useState([])
|
|
|
|
|
const [recurringItems, setRecurringItems] = useState([])
|
2026-07-05 15:32:33 +02:00
|
|
|
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
|
2026-07-05 15:36:15 +02:00
|
|
|
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
|
|
|
|
|
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
|
2026-07-05 06:41:23 +02:00
|
|
|
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 () => {
|
2026-07-05 15:15:27 +02:00
|
|
|
if (sessionLoading) return
|
2026-07-05 06:41:23 +02:00
|
|
|
setLoading(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const [initData, actionData] = await Promise.all([
|
|
|
|
|
getInitiative(id),
|
|
|
|
|
listInitiativeActions(id),
|
|
|
|
|
])
|
|
|
|
|
setInitiative(initData)
|
|
|
|
|
setActions(actionData)
|
2026-07-05 15:11:15 +02:00
|
|
|
|
|
|
|
|
const loads = []
|
2026-07-05 15:15:27 +02:00
|
|
|
const canReadOm =
|
|
|
|
|
capabilities.has('kairo.initiative.read') ||
|
|
|
|
|
capabilities.has('kairo.blocker.read')
|
|
|
|
|
if (canReadOm) {
|
2026-07-05 15:11:15 +02:00
|
|
|
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
|
|
|
|
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
|
|
|
|
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
|
2026-07-05 15:27:03 +02:00
|
|
|
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([])))
|
2026-07-05 15:32:33 +02:00
|
|
|
loads.push(
|
2026-07-05 15:36:15 +02:00
|
|
|
(async () => {
|
|
|
|
|
setSteeringSnapshotLoading(true)
|
|
|
|
|
setSteeringSnapshotError(null)
|
|
|
|
|
try {
|
|
|
|
|
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setSteeringSnapshot(null)
|
|
|
|
|
setSteeringSnapshotError(err.message || 'Snapshot fehlgeschlagen')
|
|
|
|
|
} finally {
|
|
|
|
|
setSteeringSnapshotLoading(false)
|
|
|
|
|
}
|
|
|
|
|
})()
|
2026-07-05 15:32:33 +02:00
|
|
|
)
|
2026-07-05 15:11:15 +02:00
|
|
|
}
|
|
|
|
|
await Promise.all(loads)
|
2026-07-05 06:41:23 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message)
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
2026-07-05 15:15:27 +02:00
|
|
|
}, [id, capabilities, sessionLoading])
|
2026-07-05 06:41:23 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
load()
|
|
|
|
|
}, [load])
|
|
|
|
|
|
|
|
|
|
const visibleActions = hideDone
|
|
|
|
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
|
|
|
|
: actions
|
|
|
|
|
|
2026-07-05 15:32:33 +02:00
|
|
|
const actionContextById = Object.fromEntries(
|
|
|
|
|
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-05 06:41:23 +02:00
|
|
|
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,
|
2026-07-05 15:27:03 +02:00
|
|
|
due_at: payload.due_at,
|
|
|
|
|
clear_due_at: !payload.due_at,
|
2026-07-05 06:41:23 +02:00
|
|
|
})
|
2026-07-05 15:11:15 +02:00
|
|
|
if (capabilities.has('kairo.action.manage')) {
|
2026-07-05 06:41:23 +02:00
|
|
|
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
|
|
|
|
|
}
|
|
|
|
|
setEditingAction(null)
|
|
|
|
|
await load()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message)
|
|
|
|
|
} finally {
|
|
|
|
|
setFormBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleQuickStatus(action, status) {
|
2026-07-05 15:11:15 +02:00
|
|
|
if (!capabilities.has('kairo.action.manage')) return
|
2026-07-05 06:41:23 +02:00
|
|
|
try {
|
|
|
|
|
await updateAction(action.id, { status })
|
|
|
|
|
await load()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 15:11:15 +02:00
|
|
|
async function handleCreateBlocker(body) {
|
|
|
|
|
setFormBusy(true)
|
|
|
|
|
try {
|
|
|
|
|
await createInitiativeBlocker(id, body)
|
|
|
|
|
await load()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message)
|
|
|
|
|
} finally {
|
|
|
|
|
setFormBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 15:32:33 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 15:11:15 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 15:27:03 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 06:58:48 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-05 06:41:23 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-07-05 06:58:48 +02:00
|
|
|
<div className="app-page">
|
2026-07-05 06:41:23 +02:00
|
|
|
<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>}
|
|
|
|
|
|
2026-07-05 15:32:33 +02:00
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
2026-07-05 15:36:15 +02:00
|
|
|
<SteeringSnapshotPanel
|
|
|
|
|
snapshot={steeringSnapshot}
|
|
|
|
|
loading={steeringSnapshotLoading}
|
|
|
|
|
error={steeringSnapshotError}
|
|
|
|
|
/>
|
2026-07-05 15:32:33 +02:00
|
|
|
)}
|
|
|
|
|
|
2026-07-05 06:41:23 +02:00
|
|
|
<section className="card">
|
|
|
|
|
<div className="section-header">
|
|
|
|
|
<h2>Maßnahmen</h2>
|
|
|
|
|
<div className="section-actions">
|
|
|
|
|
<label className="checkbox-label inline-filter">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={hideDone}
|
|
|
|
|
onChange={(e) => setHideDone(e.target.checked)}
|
|
|
|
|
/>
|
|
|
|
|
Erledigte ausblenden
|
|
|
|
|
</label>
|
2026-07-05 15:11:15 +02:00
|
|
|
{capabilities.has('kairo.action.manage') && (
|
2026-07-05 06:58:48 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-primary btn-block-mobile"
|
|
|
|
|
onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}
|
|
|
|
|
>
|
2026-07-05 06:41:23 +02:00
|
|
|
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showActionForm && (
|
|
|
|
|
<div className="inline-form-block">
|
|
|
|
|
<ActionForm
|
|
|
|
|
actors={actors}
|
2026-07-05 07:25:01 +02:00
|
|
|
actorsLoading={actorsLoading}
|
|
|
|
|
actorsError={actorsError}
|
|
|
|
|
actorsUsedFallback={actorsUsedFallback}
|
|
|
|
|
onReloadActors={reloadActors}
|
2026-07-05 06:41:23 +02:00
|
|
|
onSubmit={handleCreateAction}
|
|
|
|
|
onCancel={() => setShowActionForm(false)}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
submitLabel="Maßnahme anlegen"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{visibleActions.length === 0 && (
|
|
|
|
|
<EmptyState message="Noch keine Maßnahmen in diesem Vorhaben." />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<ul className="item-list actions-detail-list">
|
2026-07-05 15:32:33 +02:00
|
|
|
{visibleActions.map((action) => {
|
|
|
|
|
const ctx = actionContextById[action.id]
|
|
|
|
|
return (
|
2026-07-05 06:41:23 +02:00
|
|
|
<li key={action.id} className="list-item card-list-item">
|
|
|
|
|
{editingAction === action.id ? (
|
|
|
|
|
<ActionForm
|
|
|
|
|
initial={action}
|
|
|
|
|
actors={actors}
|
2026-07-05 07:25:01 +02:00
|
|
|
actorsLoading={actorsLoading}
|
|
|
|
|
actorsError={actorsError}
|
|
|
|
|
actorsUsedFallback={actorsUsedFallback}
|
|
|
|
|
onReloadActors={reloadActors}
|
2026-07-05 06:41:23 +02:00
|
|
|
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
|
|
|
|
|
onCancel={() => setEditingAction(null)}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
submitLabel="Speichern"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="list-item-main">
|
|
|
|
|
<strong>{action.title}</strong>
|
|
|
|
|
{action.description && (
|
|
|
|
|
<p className="list-item-desc">{action.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
{action.assigned_actor_ids?.length > 0 && (
|
|
|
|
|
<p className="muted list-item-sub">
|
|
|
|
|
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-07-05 15:27:03 +02:00
|
|
|
{action.due_at && (
|
|
|
|
|
<p className="muted list-item-sub">
|
|
|
|
|
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-07-05 15:32:33 +02:00
|
|
|
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
|
|
|
|
|
<p className="muted list-item-sub action-links">
|
|
|
|
|
{ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `}
|
|
|
|
|
{ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `}
|
|
|
|
|
{ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-07-05 06:41:23 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="list-item-meta action-controls">
|
|
|
|
|
<StatusBadge status={action.status} />
|
|
|
|
|
<PriorityBadge priority={action.priority} />
|
2026-07-05 15:11:15 +02:00
|
|
|
{capabilities.has('kairo.action.manage') && (
|
2026-07-05 06:41:23 +02:00
|
|
|
<>
|
|
|
|
|
<select
|
|
|
|
|
className="inline-select"
|
|
|
|
|
value={action.status}
|
|
|
|
|
onChange={(e) => handleQuickStatus(action, e.target.value)}
|
|
|
|
|
aria-label="Status ändern"
|
|
|
|
|
>
|
2026-07-05 15:27:03 +02:00
|
|
|
{ACTION_STATUSES.map((s) => (
|
2026-07-05 06:41:23 +02:00
|
|
|
<option key={s} value={s}>
|
|
|
|
|
{ACTION_STATUS_LABELS[s]}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-07-05 15:32:33 +02:00
|
|
|
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
onClick={() => handleCreateBlockerForAction(action.id)}
|
|
|
|
|
disabled={formBusy}
|
|
|
|
|
>
|
|
|
|
|
Blocker
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-07-05 06:41:23 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-05 06:58:48 +02:00
|
|
|
className="btn btn-secondary"
|
2026-07-05 06:41:23 +02:00
|
|
|
onClick={() => {
|
|
|
|
|
setEditingAction(action.id)
|
|
|
|
|
setShowActionForm(false)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Bearbeiten
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
2026-07-05 15:32:33 +02:00
|
|
|
)
|
|
|
|
|
})}
|
2026-07-05 06:41:23 +02:00
|
|
|
</ul>
|
|
|
|
|
</section>
|
2026-07-05 15:11:15 +02:00
|
|
|
|
2026-07-05 15:15:27 +02:00
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
2026-07-05 15:11:15 +02:00
|
|
|
<MilestonesSection
|
|
|
|
|
milestones={milestones}
|
|
|
|
|
canManage={capabilities.has('kairo.milestone.manage')}
|
|
|
|
|
onCreate={handleCreateMilestone}
|
|
|
|
|
onUpdateStatus={handleMilestoneStatus}
|
|
|
|
|
onDelete={handleDeleteMilestone}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-05 15:15:27 +02:00
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
2026-07-05 15:11:15 +02:00
|
|
|
<BacklogSection
|
|
|
|
|
items={backlogItems}
|
|
|
|
|
canManage={capabilities.has('kairo.backlog.manage')}
|
|
|
|
|
onCreate={handleCreateBacklog}
|
|
|
|
|
onUpdateStatus={handleBacklogStatus}
|
|
|
|
|
onConvert={handleConvertBacklog}
|
|
|
|
|
onDelete={handleDeleteBacklog}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-05 15:15:27 +02:00
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
2026-07-05 15:11:15 +02:00
|
|
|
<BlockersSection
|
|
|
|
|
blockers={blockers}
|
|
|
|
|
canManage={capabilities.has('kairo.blocker.manage')}
|
|
|
|
|
onCreate={handleCreateBlocker}
|
|
|
|
|
onUpdateStatus={handleBlockerStatus}
|
|
|
|
|
onDelete={handleDeleteBlocker}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-05 15:27:03 +02:00
|
|
|
|
|
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
|
|
|
|
<EvidenceSection
|
|
|
|
|
items={evidenceItems}
|
|
|
|
|
canManage={capabilities.has('kairo.evidence.manage')}
|
|
|
|
|
onCreate={handleCreateEvidence}
|
|
|
|
|
onUpdateStatus={handleEvidenceStatus}
|
|
|
|
|
onDelete={handleDeleteEvidence}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
|
|
|
|
<DecisionsSection
|
|
|
|
|
items={decisions}
|
|
|
|
|
canManage={capabilities.has('kairo.decision.manage')}
|
|
|
|
|
onCreate={handleCreateDecision}
|
|
|
|
|
onUpdateStatus={handleDecisionStatus}
|
|
|
|
|
onDelete={handleDeleteDecision}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
|
|
|
|
<ReviewsSection
|
|
|
|
|
items={reviews}
|
|
|
|
|
canManage={capabilities.has('kairo.review.manage')}
|
|
|
|
|
onCreate={handleCreateReview}
|
|
|
|
|
onUpdateStatus={handleReviewStatus}
|
|
|
|
|
onDelete={handleDeleteReview}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{capabilities.has('kairo.initiative.read') && (
|
|
|
|
|
<RecurringSection
|
|
|
|
|
items={recurringItems}
|
|
|
|
|
canManage={capabilities.has('kairo.recurring.manage')}
|
|
|
|
|
onCreate={handleCreateRecurring}
|
|
|
|
|
onUpdateStatus={handleRecurringStatus}
|
|
|
|
|
onDelete={handleDeleteRecurring}
|
|
|
|
|
busy={formBusy}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-05 06:41:23 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|