Some checks failed
Deploy Development / deploy (push) Failing after 37s
Test Suite / pytest-backend (push) Successful in 1m41s
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 13s
Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar. Co-authored-by: Cursor <cursoragent@cursor.com>
669 lines
17 KiB
JavaScript
669 lines
17 KiB
JavaScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react'
|
|
import { 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 {
|
|
listInitiativeRoadmapItems,
|
|
createInitiativeRoadmapItem,
|
|
updateRoadmapItem,
|
|
deleteRoadmapItem,
|
|
verifyRoadmapItemReached,
|
|
} from '../api/roadmap.js'
|
|
import {
|
|
listInitiativeProjects,
|
|
createInitiativeProject,
|
|
deleteProject,
|
|
} from '../api/projects.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 { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
|
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
|
import { useActors } from '../hooks/useActors.js'
|
|
import { useSession } from './SessionContext.jsx'
|
|
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
|
|
|
const InitiativeOperationsContext = createContext(null)
|
|
|
|
export function InitiativeOperationsProvider({ children }) {
|
|
const { id } = useParams()
|
|
const { context, loading: sessionLoading } = useSession()
|
|
const { capabilities } = useCapabilities()
|
|
const actorsState = useActors()
|
|
|
|
const [initiative, setInitiative] = useState(null)
|
|
const [actions, setActions] = useState([])
|
|
const [blockers, setBlockers] = useState([])
|
|
const [backlogItems, setBacklogItems] = useState([])
|
|
const [roadmapItems, setRoadmapItems] = useState([])
|
|
const [projects, setProjects] = useState([])
|
|
const [selectedProjectId, setSelectedProjectId] = 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 || !id) 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(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
|
|
loads.push(listInitiativeProjects(id).then(setProjects).catch(() => setProjects([])))
|
|
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 selectedLeafProjectId = useMemo(() => {
|
|
if (!selectedProjectId) return ''
|
|
const match = projects.find((p) => p.id === selectedProjectId)
|
|
if (match?.is_leaf) return selectedProjectId
|
|
return ''
|
|
}, [selectedProjectId, projects])
|
|
|
|
const visibleActions = useMemo(
|
|
() => {
|
|
let list = hideDone
|
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
|
: actions
|
|
if (selectedProjectId) {
|
|
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
|
|
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
|
|
}
|
|
return list
|
|
},
|
|
[actions, hideDone, selectedProjectId, projects]
|
|
)
|
|
|
|
const actionContextById = useMemo(
|
|
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
|
|
[steeringSnapshot]
|
|
)
|
|
|
|
const unlinkedBlockers = useMemo(
|
|
() => blockers.filter((b) => !b.action_id),
|
|
[blockers]
|
|
)
|
|
|
|
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,
|
|
project_id: payload.project_id || selectedLeafProjectId || undefined,
|
|
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,
|
|
project_id: payload.project_id,
|
|
clear_project: payload.project_id === '' || payload.project_id === null,
|
|
roadmap_item_id: payload.roadmap_item_id || undefined,
|
|
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
|
|
})
|
|
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 handleBacklogGate(itemId, roadmapItemId) {
|
|
try {
|
|
await updateBacklogItem(itemId, {
|
|
roadmap_item_id: roadmapItemId || undefined,
|
|
clear_roadmap_item: !roadmapItemId,
|
|
})
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
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 handleCreateRoadmapItem(body) {
|
|
setFormBusy(true)
|
|
try {
|
|
await createInitiativeRoadmapItem(id, body)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setFormBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleRoadmapItemStatus(itemId, status) {
|
|
try {
|
|
await updateRoadmapItem(itemId, { status })
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
async function handleUpdateRoadmapItem(itemId, body) {
|
|
try {
|
|
await updateRoadmapItem(itemId, body)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
async function handleVerifyWithEvidence(itemId, evidenceTitle) {
|
|
setFormBusy(true)
|
|
setError(null)
|
|
try {
|
|
await createInitiativeEvidence(id, {
|
|
title: evidenceTitle,
|
|
roadmap_item_id: itemId,
|
|
status: 'accepted',
|
|
})
|
|
await verifyRoadmapItemReached(itemId)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setFormBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleVerifyRoadmapItem(itemId) {
|
|
setFormBusy(true)
|
|
try {
|
|
await verifyRoadmapItemReached(itemId)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setFormBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteRoadmapItem(itemId) {
|
|
try {
|
|
await deleteRoadmapItem(itemId)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
async function handleCreateProject(body) {
|
|
setFormBusy(true)
|
|
try {
|
|
await createInitiativeProject(id, body)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setFormBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteProject(projectId) {
|
|
try {
|
|
await deleteProject(projectId)
|
|
if (selectedProjectId === projectId) setSelectedProjectId('')
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
}
|
|
}
|
|
|
|
const milestones = useMemo(
|
|
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
|
[roadmapItems]
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
const value = {
|
|
initiativeId: id,
|
|
initiative,
|
|
actions,
|
|
visibleActions,
|
|
blockers,
|
|
unlinkedBlockers,
|
|
backlogItems,
|
|
roadmapItems,
|
|
projects,
|
|
selectedProjectId,
|
|
setSelectedProjectId,
|
|
milestones,
|
|
evidenceItems,
|
|
decisions,
|
|
reviews,
|
|
recurringItems,
|
|
steeringSnapshot,
|
|
steeringSnapshotLoading,
|
|
steeringSnapshotError,
|
|
steeringMethods,
|
|
methodBusy,
|
|
loading,
|
|
error,
|
|
showActionForm,
|
|
setShowActionForm,
|
|
editingAction,
|
|
setEditingAction,
|
|
formBusy,
|
|
hideDone,
|
|
setHideDone,
|
|
actionContextById,
|
|
capabilities,
|
|
actors: actorsState.actors,
|
|
actorsLoading: actorsState.loading,
|
|
actorsError: actorsState.error,
|
|
actorsUsedFallback: actorsState.usedFallback,
|
|
reloadActors: actorsState.reload,
|
|
reload: load,
|
|
handleMethodChange,
|
|
handleCreateAction,
|
|
handleUpdateAction,
|
|
handleQuickStatus,
|
|
handleCreateBlocker,
|
|
handleCreateBlockerForAction,
|
|
handleBlockerStatus,
|
|
handleDeleteBlocker,
|
|
handleCreateBacklog,
|
|
handleBacklogGate,
|
|
handleBacklogStatus,
|
|
handleConvertBacklog,
|
|
handleDeleteBacklog,
|
|
handleCreateRoadmapItem,
|
|
handleRoadmapItemStatus,
|
|
handleUpdateRoadmapItem,
|
|
handleVerifyRoadmapItem,
|
|
handleVerifyWithEvidence,
|
|
handleDeleteRoadmapItem,
|
|
handleCreateMilestone: handleCreateRoadmapItem,
|
|
handleMilestoneStatus: handleRoadmapItemStatus,
|
|
handleDeleteMilestone: handleDeleteRoadmapItem,
|
|
handleCreateProject,
|
|
handleDeleteProject,
|
|
handleCreateEvidence,
|
|
handleEvidenceStatus,
|
|
handleDeleteEvidence,
|
|
handleCreateDecision,
|
|
handleDecisionStatus,
|
|
handleDeleteDecision,
|
|
handleCreateReview,
|
|
handleReviewStatus,
|
|
handleDeleteReview,
|
|
handleCreateRecurring,
|
|
handleRecurringStatus,
|
|
handleDeleteRecurring,
|
|
}
|
|
|
|
return (
|
|
<InitiativeOperationsContext.Provider value={value}>
|
|
{children}
|
|
</InitiativeOperationsContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useInitiativeOperations() {
|
|
const ctx = useContext(InitiativeOperationsContext)
|
|
if (!ctx) {
|
|
throw new Error('useInitiativeOperations must be used within InitiativeOperationsProvider')
|
|
}
|
|
return ctx
|
|
}
|