AP1.12b/d: Gates per Modal und Actions in der Plan-Outline.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled

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 <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 15:41:42 +02:00
parent bcf6f83813
commit b1686f3a98
7 changed files with 275 additions and 181 deletions

View File

@ -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 ? '▾' : '▸'}
</button>
)}
{hasActionList && (
<button
type="button"
className="analysis-split__nav-toggle"
aria-expanded={workOpen}
aria-label={workOpen ? 'Arbeit einklappen' : 'Arbeit ausklappen'}
onClick={() => setWorkOpen((open) => !open)}
>
{workOpen ? '▾' : '▸'}
</button>
)}
<NavLink
to={hrefWithScope(node.to)}
end
className={({ isActive }) =>
'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 && (
<ul className="analysis-split__nav-sublist">
{openActions.map((action) => (
<li key={action.id}>
<NavLink
to={actionPath(action.id)}
className={({ isActive }) =>
'analysis-split__nav-link analysis-split__nav-link--nested' +
(isActive || activeActionId === action.id
? ' analysis-split__nav-link--active'
: '')
}
>
{action.title}
</NavLink>
</li>
))}
</ul>
)}
</li>
)
})}

View File

@ -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 (
<form className="form workspace-form roadmap-item-form" onSubmit={handleSubmit}>
{mode === 'create' && (
<label>
Typ
<select name="item_type" defaultValue={initial.item_type || 'milestone'}>
{ITEM_TYPES.map((t) => (
<option key={t} value={t}>
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
</option>
))}
</select>
</label>
)}
<label>
Titel
<input
name="title"
defaultValue={initial.title || ''}
maxLength={255}
required
placeholder="z. B. MVP nutzbar im Alltag"
disabled={statusLocked}
/>
</label>
<label>
Ziel / Definition of Done
<textarea
name="goal_description"
rows={3}
defaultValue={initial.goal_description || ''}
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
disabled={statusLocked}
/>
</label>
<div className="form-row form-row--2">
<label>
Abfolge
<select
name="sequencing_mode"
defaultValue={initial.sequencing_mode || 'sequential'}
disabled={statusLocked}
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m] || m}
</option>
))}
</select>
</label>
<label>
Zieltermin
<input
type="date"
name="target_date"
defaultValue={initial.target_date || ''}
disabled={statusLocked}
/>
</label>
</div>
{mode === 'edit' && (
<label>
Status
<select name="status" defaultValue={initial.status || 'planned'} disabled={statusLocked}>
{EDITABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{MILESTONE_STATUS_LABELS[s]}
</option>
))}
</select>
</label>
)}
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || statusLocked}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}
</div>
</form>
)
}

View File

@ -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 (
<section className="card">
<section className="card roadmap-plan-section">
<div className="section-header">
<div>
<h2>Zielzustände (Gates)</h2>
<p className="section-lead muted">
Ü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.
</p>
</div>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
onClick={() => setShowCreate(true)}
>
{showForm ? 'Abbrechen' : 'Plan-Element'}
Plan-Element
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
<label>
Typ
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
{ITEM_TYPES.map((t) => (
<option key={t} value={t}>
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
</option>
))}
</select>
</label>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. MVP nutzbar im Alltag"
/>
</label>
<label>
Ziel / Definition of Done
<textarea
value={goalDescription}
onChange={(e) => setGoalDescription(e.target.value)}
rows={3}
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
/>
</label>
<label>
Abfolge
<select
value={sequencingMode}
onChange={(e) => setSequencingMode(e.target.value)}
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m] || m}
</option>
))}
</select>
</label>
<label>
Zieltermin
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)}
@ -153,7 +67,7 @@ export function RoadmapPlanSection({
<div className="list-item-main">
<strong>
{initiativeId ? (
<Link to={gatePath(item.id)}>
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
{item.title}
</Link>
) : (
@ -176,84 +90,35 @@ export function RoadmapPlanSection({
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="milestone" status={item.status} />
{initiativeId && (
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
Öffnen
</Link>
)}
{canManage && item.status !== 'reached' && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Plan-Status"
>
{EDITABLE_STATUSES.map((s) => (
<option key={s} value={s}>
{MILESTONE_STATUS_LABELS[s]}
</option>
))}
</select>
<select
className="inline-select"
value={item.sequencing_mode}
onChange={(e) =>
onUpdateItem(item.id, { sequencing_mode: e.target.value })
}
aria-label="Abfolge"
>
{SEQUENCING_MODES.map((m) => (
<option key={m} value={m}>
{SEQUENCING_MODE_LABELS[m]}
</option>
))}
</select>
{['planned', 'active', 'at_risk'].includes(item.status) && (
<div className="gate-verify-block">
<label className="gate-verify-label">
Nachweis für Verify
<input
type="text"
value={verifyTitles[item.id] ?? ''}
placeholder={`Nachweis: ${item.title}`}
onChange={(e) =>
setVerifyTitles((prev) => ({
...prev,
[item.id]: e.target.value,
}))
}
maxLength={255}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() =>
onVerifyWithEvidence(
item.id,
verifyTitles[item.id]?.trim() || `Nachweis: ${item.title}`
)
}
>
Gate schließen
</button>
<p className="muted gate-verify-hint">
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.
</p>
</div>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => onDelete(item.id)}
disabled={busy}
>
Löschen
</button>
)}
</div>
</li>
))}
</ul>
<Modal open={showCreate} title="Plan-Element anlegen" onClose={() => setShowCreate(false)}>
<RoadmapItemForm
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreate(false)}
busy={busy}
submitLabel="Anlegen"
mode="create"
/>
</Modal>
</section>
)
}

View File

@ -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}
/>

View File

@ -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}
</p>
{item.goal_description && <p>{item.goal_description}</p>}
</div>
<StatusBadge kind="milestone" status={item.status} />
<div className="section-header__actions">
<StatusBadge kind="milestone" status={item.status} />
{canManage && item.status !== 'reached' && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setEditingMeta(true)}
disabled={busy}
>
Stammdaten
</button>
)}
</div>
</div>
<p className="muted">
@ -365,6 +384,26 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
</div>
)}
</div>
<Modal
open={editingMeta}
title="Plan-Element bearbeiten"
onClose={() => setEditingMeta(false)}
>
<RoadmapItemForm
mode="edit"
initial={item}
busy={busy}
submitLabel="Speichern"
onCancel={() => setEditingMeta(false)}
onSubmit={async (payload) => {
await runAction(async () => {
await updateRoadmapItem(itemId, payload)
setEditingMeta(false)
})
}}
/>
</Modal>
</section>
)
}

View File

@ -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
}

View File

@ -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')
})
})