From b1686f3a98c9e71618cce61df8473cf3615f5ffd Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 10 Jul 2026 15:41:42 +0200
Subject: [PATCH] AP1.12b/d: Gates per Modal und Actions in der Plan-Outline.
RoadmapPlanSection ist read-only mit Modal-Anlage; Stammdaten auf der Gate-Detailseite. Unter Arbeit zeigt die Outline offene Actions wie der Projektbaum unter Struktur.
Co-authored-by: Cursor
---
frontend/src/components/PlanOutlineNav.jsx | 56 ++++-
frontend/src/components/RoadmapItemForm.jsx | 124 +++++++++++
.../src/components/RoadmapPlanSection.jsx | 207 +++---------------
.../pages/initiative/InitiativePlanPage.jsx | 3 -
.../initiative/RoadmapItemDetailPage.jsx | 41 +++-
frontend/src/plan/planOutlineNodes.js | 12 +
frontend/src/plan/planOutlineNodes.test.js | 13 +-
7 files changed, 275 insertions(+), 181 deletions(-)
create mode 100644 frontend/src/components/RoadmapItemForm.jsx
diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx
index c61bd84..2d3c21d 100644
--- a/frontend/src/components/PlanOutlineNav.jsx
+++ b/frontend/src/components/PlanOutlineNav.jsx
@@ -5,11 +5,12 @@ import { listInitiativeActions } from '../api/initiatives.js'
import { listInitiativeBacklog } from '../api/backlog.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { buildProjectsByParent } from '../utils/projectTree.js'
-import { projectPath } from '../utils/routes.js'
+import { projectPath, actionPath } from '../utils/routes.js'
import {
PLAN_OUTLINE_NODES,
resolvePlanOutlineActiveKey,
resolvePlanOutlineProjectId,
+ resolvePlanOutlineActionId,
} from '../plan/planOutlineNodes.js'
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
@@ -52,16 +53,25 @@ export function PlanOutlineNav() {
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
+ const activeActionId = resolvePlanOutlineActionId(location.pathname)
const [counts, setCounts] = useState({ inbox: null, work: null })
const [projects, setProjects] = useState([])
+ const [openActions, setOpenActions] = useState([])
const [structureOpen, setStructureOpen] = useState(
activeKey === 'structure' || Boolean(activeProjectId),
)
+ const [workOpen, setWorkOpen] = useState(
+ activeKey === 'work' || Boolean(activeActionId),
+ )
useEffect(() => {
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
}, [activeKey, activeProjectId])
+ useEffect(() => {
+ setWorkOpen(activeKey === 'work' || Boolean(activeActionId))
+ }, [activeKey, activeActionId])
+
useEffect(() => {
if (!initiativeId) {
setProjects([])
@@ -91,11 +101,13 @@ export function PlanOutlineNav() {
listInitiativeActions(initiativeId).catch(() => []),
]).then(([backlog, actions]) => {
if (cancelled) return
+ const open = actions.filter(
+ (action) => action.status !== 'done' && action.status !== 'discarded',
+ )
+ setOpenActions(open)
setCounts({
inbox: backlog.filter((item) => item.status !== 'converted').length,
- work: actions.filter(
- (action) => action.status !== 'done' && action.status !== 'discarded',
- ).length,
+ work: open.length,
})
})
return () => {
@@ -136,7 +148,9 @@ export function PlanOutlineNav() {
const needsInitiative = node.requiresInitiative && !initiativeId
const isDisabled = node.disabled || needsInitiative
const isStructure = node.key === 'structure'
+ const isWork = node.key === 'work'
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
+ const hasActionList = isWork && openActions.length > 0 && initiativeId
if (isDisabled) {
return (
@@ -169,12 +183,25 @@ export function PlanOutlineNav() {
{structureOpen ? '▾' : '▸'}
)}
+ {hasActionList && (
+ setWorkOpen((open) => !open)}
+ >
+ {workOpen ? '▾' : '▸'}
+
+ )}
'analysis-split__nav-link' +
- (isActive || (isStructure && activeProjectId)
+ (isActive ||
+ (isStructure && activeProjectId) ||
+ (isWork && activeActionId)
? ' analysis-split__nav-link--active'
: '')
}
@@ -189,6 +216,25 @@ export function PlanOutlineNav() {
activeProjectId={activeProjectId}
/>
)}
+ {hasActionList && workOpen && (
+
+ {openActions.map((action) => (
+
+
+ 'analysis-split__nav-link analysis-split__nav-link--nested' +
+ (isActive || activeActionId === action.id
+ ? ' analysis-split__nav-link--active'
+ : '')
+ }
+ >
+ {action.title}
+
+
+ ))}
+
+ )}
)
})}
diff --git a/frontend/src/components/RoadmapItemForm.jsx b/frontend/src/components/RoadmapItemForm.jsx
new file mode 100644
index 0000000..3304f3a
--- /dev/null
+++ b/frontend/src/components/RoadmapItemForm.jsx
@@ -0,0 +1,124 @@
+import {
+ MILESTONE_STATUSES,
+ MILESTONE_STATUS_LABELS,
+ ROADMAP_ITEM_TYPE_LABELS,
+ SEQUENCING_MODE_LABELS,
+} from '../constants/status.js'
+
+const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
+const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
+const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
+ (s) => !['reached', 'moved', 'discarded'].includes(s),
+)
+
+export function RoadmapItemForm({
+ initial = {},
+ onSubmit,
+ onCancel,
+ busy = false,
+ submitLabel = 'Anlegen',
+ mode = 'create',
+}) {
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const form = e.target
+ const payload = {
+ title: form.title.value.trim(),
+ goal_description: form.goal_description.value.trim(),
+ target_date: form.target_date.value || undefined,
+ item_type: form.item_type.value,
+ sequencing_mode: form.sequencing_mode.value,
+ }
+ if (mode === 'edit') {
+ payload.status = form.status.value
+ }
+ await onSubmit(payload)
+ }
+
+ const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status)
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx
index b3c33b5..4d2a1eb 100644
--- a/frontend/src/components/RoadmapPlanSection.jsx
+++ b/frontend/src/components/RoadmapPlanSection.jsx
@@ -2,25 +2,19 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { gatePath } from '../utils/routes.js'
import {
- MILESTONE_STATUSES,
- MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
SEQUENCING_MODE_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
-
-const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
-const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
-const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
- (s) => !['reached', 'moved', 'discarded'].includes(s)
-)
+import { Modal } from './Modal.jsx'
+import { RoadmapItemForm } from './RoadmapItemForm.jsx'
function formatDate(value) {
if (!value) return null
try {
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
- 'de-DE'
+ 'de-DE',
)
} catch {
return value
@@ -32,117 +26,37 @@ export function RoadmapPlanSection({
items,
canManage,
onCreate,
- onUpdateStatus,
- onUpdateItem,
- onVerifyWithEvidence,
onDelete,
busy,
}) {
- const [title, setTitle] = useState('')
- const [goalDescription, setGoalDescription] = useState('')
- const [targetDate, setTargetDate] = useState('')
- const [itemType, setItemType] = useState('milestone')
- const [sequencingMode, setSequencingMode] = useState('sequential')
- const [showForm, setShowForm] = useState(false)
- const [verifyTitles, setVerifyTitles] = useState({})
+ const [showCreate, setShowCreate] = useState(false)
- async function handleSubmit(e) {
- e.preventDefault()
- if (!title.trim()) return
- await onCreate({
- title: title.trim(),
- goal_description: goalDescription.trim(),
- target_date: targetDate || undefined,
- item_type: itemType,
- sequencing_mode: sequencingMode,
- })
- setTitle('')
- setGoalDescription('')
- setTargetDate('')
- setItemType('milestone')
- setSequencingMode('sequential')
- setShowForm(false)
+ async function handleCreateSubmit(payload) {
+ await onCreate(payload)
+ setShowCreate(false)
}
return (
-
+
Zielzustände (Gates)
- Überprüfbare Zielpunkte — optional. Operative Planung läuft unter Ausführung.
- Erreicht nur über Verify (Kriterien, Evidence, Review).
+ Überprüfbare Zielpunkte — optional. Bearbeitung und Verify auf der Gate-Detailseite,
+ nicht inline in der Liste.
{canManage && (
setShowForm((v) => !v)}
+ onClick={() => setShowCreate(true)}
>
- {showForm ? 'Abbrechen' : 'Plan-Element'}
+ Plan-Element
)}
- {showForm && canManage && (
-
-
- Typ
- setItemType(e.target.value)}>
- {ITEM_TYPES.map((t) => (
-
- {ROADMAP_ITEM_TYPE_LABELS[t] || t}
-
- ))}
-
-
-
- Titel
- setTitle(e.target.value)}
- maxLength={255}
- required
- placeholder="z. B. MVP nutzbar im Alltag"
- />
-
-
- Ziel / Definition of Done
- setGoalDescription(e.target.value)}
- rows={3}
- placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
- />
-
-
- Abfolge
- setSequencingMode(e.target.value)}
- >
- {SEQUENCING_MODES.map((m) => (
-
- {SEQUENCING_MODE_LABELS[m] || m}
-
- ))}
-
-
-
- Zieltermin
- setTargetDate(e.target.value)}
- />
-
-
- Anlegen
-
-
- )}
-
{items.length === 0 && (
)}
@@ -153,7 +67,7 @@ export function RoadmapPlanSection({
{initiativeId ? (
-
+
{item.title}
) : (
@@ -176,84 +90,35 @@ export function RoadmapPlanSection({
+ {initiativeId && (
+
+ Öffnen
+
+ )}
{canManage && item.status !== 'reached' && (
- <>
-
onUpdateStatus(item.id, e.target.value)}
- aria-label="Plan-Status"
- >
- {EDITABLE_STATUSES.map((s) => (
-
- {MILESTONE_STATUS_LABELS[s]}
-
- ))}
-
-
- onUpdateItem(item.id, { sequencing_mode: e.target.value })
- }
- aria-label="Abfolge"
- >
- {SEQUENCING_MODES.map((m) => (
-
- {SEQUENCING_MODE_LABELS[m]}
-
- ))}
-
- {['planned', 'active', 'at_risk'].includes(item.status) && (
-
-
- Nachweis für Verify
-
- setVerifyTitles((prev) => ({
- ...prev,
- [item.id]: e.target.value,
- }))
- }
- maxLength={255}
- />
-
-
- onVerifyWithEvidence(
- item.id,
- verifyTitles[item.id]?.trim() || `Nachweis: ${item.title}`
- )
- }
- >
- Gate schließen
-
-
- Legt akzeptiertes Evidence am Plan-Element an und setzt Status auf erreicht.
- Bereits vorhandener Nachweis auf Journey: Plan-Element zuweisen und auf
- Akzeptiert setzen, dann erneut Verify.
-
-
- )}
-
onDelete(item.id)}
- >
- Löschen
-
- >
+
onDelete(item.id)}
+ disabled={busy}
+ >
+ Löschen
+
)}
))}
+
+ setShowCreate(false)}>
+ setShowCreate(false)}
+ busy={busy}
+ submitLabel="Anlegen"
+ mode="create"
+ />
+
)
}
diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx
index 458266e..ac9c2fa 100644
--- a/frontend/src/pages/initiative/InitiativePlanPage.jsx
+++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx
@@ -27,9 +27,6 @@ export function InitiativePlanPage() {
items={roadmapItems}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateRoadmapItem}
- onUpdateStatus={handleRoadmapItemStatus}
- onUpdateItem={handleUpdateRoadmapItem}
- onVerifyWithEvidence={handleVerifyWithEvidence}
onDelete={handleDeleteRoadmapItem}
busy={formBusy}
/>
diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
index 80e89a7..cde0a5e 100644
--- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
+++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
@@ -13,6 +13,7 @@ import {
deferRoadmapCriterion,
verifyRoadmapItemReached,
reopenRoadmapItem,
+ updateRoadmapItem,
} from '../../api/roadmap.js'
import { ErrorState } from '../../components/ErrorState.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
@@ -23,12 +24,15 @@ import {
CRITERION_STATUS_LABELS,
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
+ SEQUENCING_MODE_LABELS,
} from '../../constants/status.js'
import { useCapabilities } from '../../hooks/useCapabilities.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { scopedPath } from '../../utils/routes.js'
import { listGateContributions } from '../../api/journey.js'
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
+import { Modal } from '../../components/Modal.jsx'
+import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
function CriterionDecisionForm({ label, onSubmit, busy }) {
const [title, setTitle] = useState('')
@@ -95,6 +99,7 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
const [contributions, setContributions] = useState(null)
const [contributionsLoading, setContributionsLoading] = useState(true)
const [contributionsError, setContributionsError] = useState(null)
+ const [editingMeta, setEditingMeta] = useState(false)
const loadContributions = useCallback(async () => {
setContributionsLoading(true)
@@ -171,10 +176,24 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
{' · '}
{MILESTONE_STATUS_LABELS[item.status] || item.status}
+ {' · '}
+ {SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
{item.goal_description && {item.goal_description}
}
-
+
+
+ {canManage && item.status !== 'reached' && (
+ setEditingMeta(true)}
+ disabled={busy}
+ >
+ Stammdaten
+
+ )}
+
@@ -365,6 +384,26 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
)}
+
+ setEditingMeta(false)}
+ >
+ setEditingMeta(false)}
+ onSubmit={async (payload) => {
+ await runAction(async () => {
+ await updateRoadmapItem(itemId, payload)
+ setEditingMeta(false)
+ })
+ }}
+ />
+
)
}
diff --git a/frontend/src/plan/planOutlineNodes.js b/frontend/src/plan/planOutlineNodes.js
index c9d1a9b..6772350 100644
--- a/frontend/src/plan/planOutlineNodes.js
+++ b/frontend/src/plan/planOutlineNodes.js
@@ -23,6 +23,9 @@ export function resolvePlanOutlineActiveKey(pathname) {
if (path.startsWith('/projects/')) {
return 'structure'
}
+ if (path.startsWith('/actions/')) {
+ return 'work'
+ }
const match = PLAN_OUTLINE_NODES.find(
(node) => path === node.to || path.startsWith(`${node.to}/`),
)
@@ -37,3 +40,12 @@ export function resolvePlanOutlineProjectId(pathname) {
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
return match?.[1] ?? null
}
+
+/**
+ * @param {string} pathname
+ * @returns {string | null}
+ */
+export function resolvePlanOutlineActionId(pathname) {
+ const match = (pathname || '').match(/^\/actions\/([^/?]+)/)
+ return match?.[1] ?? null
+}
diff --git a/frontend/src/plan/planOutlineNodes.test.js b/frontend/src/plan/planOutlineNodes.test.js
index 51132fc..d52cad9 100644
--- a/frontend/src/plan/planOutlineNodes.test.js
+++ b/frontend/src/plan/planOutlineNodes.test.js
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { PLAN_OUTLINE_NODES, resolvePlanOutlineActiveKey } from './planOutlineNodes.js'
+import {
+ PLAN_OUTLINE_NODES,
+ resolvePlanOutlineActiveKey,
+ resolvePlanOutlineActionId,
+ resolvePlanOutlineProjectId,
+} from './planOutlineNodes.js'
describe('planOutlineNodes', () => {
it('lists plan sections in product order', () => {
@@ -20,5 +25,11 @@ describe('planOutlineNodes', () => {
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
+ expect(resolvePlanOutlineActiveKey('/actions/act-1')).toBe('work')
+ })
+
+ it('resolves project and action ids from pathname', () => {
+ expect(resolvePlanOutlineProjectId('/projects/abc-123')).toBe('abc-123')
+ expect(resolvePlanOutlineActionId('/actions/act-1')).toBe('act-1')
})
})