feat(ui): Geführter Eingang und Steuerungskurzinfo auf Plan-Profil
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m0s
Test Suite / lint-backend (push) Successful in 2s
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 18s

BacklogIntakeGuide und PlanSteeringSummary leiten aus Operating Context — keine Archetyp-Ifs, klarere Commit-Pfade A/B.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 10:57:49 +02:00
parent a3b14d9c00
commit a13913f1a0
8 changed files with 578 additions and 16 deletions

View File

@ -0,0 +1,82 @@
import { Link } from 'react-router-dom'
import {
buildBacklogIntakeGuideSteps,
isBacklogIntakeGuideComplete,
} from '../utils/backlogIntakeGuide.js'
import { scopedPath } from '../utils/routes.js'
/**
* Geführter Plan-Eingang Schritte aus Methoden-Vertrag / Backlog-Vokabular.
*/
export function BacklogIntakeGuide({
initiativeId,
vocabulary,
sprintCommitEnabled,
hasPlanableSprint,
hasBacklogItems,
hasCommittableItems,
hasConvertedItems,
directWorkEnabled,
compact = false,
}) {
if (!initiativeId) return null
const steps = buildBacklogIntakeGuideSteps({
vocabulary,
sprintCommitEnabled,
hasPlanableSprint,
hasBacklogItems,
hasCommittableItems,
hasConvertedItems,
directWorkEnabled,
initiativeId,
scopedPath,
})
if (!steps.length) return null
const complete = isBacklogIntakeGuideComplete(steps)
if (compact && complete) {
return (
<p className="backlog-intake-guide backlog-intake-guide--compact muted">
Eingang bereit Items committen oder{' '}
{directWorkEnabled ? (
<Link to={scopedPath('/plan/work', { initiativeId })}>direkt unter Plan Arbeit</Link>
) : (
'committete Arbeit unter Ausführen bearbeiten'
)}
.
</p>
)
}
return (
<div className="backlog-intake-guide card-list-item">
<p className="backlog-intake-guide__title">
<strong>So funktioniert der Eingang</strong>
</p>
<ol className="backlog-intake-guide__steps">
{steps.map((step) => (
<li
key={step.id}
className={`backlog-intake-guide__step backlog-intake-guide__step--${step.state}`}
>
<span className="backlog-intake-guide__step-marker" aria-hidden="true">
{step.state === 'done' ? '✓' : step.state === 'current' ? '→' : '○'}
</span>
<div className="backlog-intake-guide__step-body">
<strong>{step.title}</strong>
<p className="muted backlog-intake-guide__step-detail">{step.detail}</p>
{step.to && step.linkLabel && step.state !== 'done' && (
<Link to={step.to} className="backlog-intake-guide__step-link">
{step.linkLabel}
</Link>
)}
</div>
</li>
))}
</ol>
</div>
)
}

View File

@ -12,6 +12,7 @@ import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBack
import { useMinWidth } from '../hooks/useMinWidth.js'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx'
import { actionTitleById } from '../utils/actionReferences.js'
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
@ -107,6 +108,11 @@ export function BacklogSection({
}, [epicItems])
const defaultKind = vocabulary.default_kind || 'story'
const hasConvertedItems = items.some((item) => item.status === 'converted')
const directWorkEnabled = Boolean(
operatingContext?.ui_profile?.planOutlineKeys?.includes('work'),
)
const guideCompact = sortedItems.length > 0 && (showSprintPlanning || hasConvertedItems)
function closeModal() {
setModalMode(null)
@ -247,6 +253,20 @@ export function BacklogSection({
)}
</div>
{canManage && (
<BacklogIntakeGuide
initiativeId={initiativeId}
vocabulary={vocabulary}
sprintCommitEnabled={sprintCommitEnabled}
hasPlanableSprint={planableSprints.length > 0}
hasBacklogItems={sortedItems.length > 0}
hasCommittableItems={committableItems.length > 0}
hasConvertedItems={hasConvertedItems}
directWorkEnabled={directWorkEnabled}
compact={guideCompact}
/>
)}
{sprintCommitEnabled && canManage && !showSprintPlanning && (
<div className="backlog-sprint-setup card-list-item">
<p className="backlog-sprint-setup__title">
@ -295,8 +315,11 @@ export function BacklogSection({
</div>
)}
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
{sortedItems.length === 0 && !hasConvertedItems && (
<EmptyState message="Noch keine Items im Eingang — Schritt 2 in der Anleitung oben." />
)}
{(sortedItems.length > 0 || hasConvertedItems) && (
<ul className="item-list backlog-reorder-list">
{displayRows.map(({ item, depth }) => {
const isDragging = dragItemId === item.id
@ -427,6 +450,7 @@ export function BacklogSection({
</li>
))}
</ul>
)}
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
{modalMode?.kind === 'create' && (

View File

@ -0,0 +1,110 @@
import { Link } from 'react-router-dom'
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import {
backlogVocabularyProfileLabel,
planSteeringElementLabels,
} from '../registry/steeringElementRegistry.js'
import { methodLabelForKey } from '../utils/archetypes.js'
import { resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
import { scopedPath } from '../utils/routes.js'
const COMPOSITION_MODIFIER_LABELS = {
agile_iteration: 'Agile Iteration (aktiver Sprint)',
}
/**
* Kurzüberblick Steuerung auf Plan-Profil aus Operating Context, nicht Archetyp-If.
*/
export function PlanSteeringSummary({ initiativeId }) {
const {
operatingContext,
steeringElements,
steeringMethods,
activeWorkCycle,
} = useInitiativeOperations()
if (!operatingContext) return null
const methodKey = operatingContext.method_key
const methodLabel = methodLabelForKey(methodKey, steeringMethods)
const vocabulary = resolveBacklogVocabulary(operatingContext.backlog_vocabulary)
const vocabLabel = backlogVocabularyProfileLabel(vocabulary.profile_key)
const elementLabels = planSteeringElementLabels(steeringElements)
const compositionKey = operatingContext.active_composition_modifier
const compositionLabel = compositionKey
? COMPOSITION_MODIFIER_LABELS[compositionKey] || compositionKey
: null
const planOutlineKeys = operatingContext.ui_profile?.planOutlineKeys || []
const showsDirectWork = planOutlineKeys.includes('work')
const showsInbox = planOutlineKeys.includes('inbox')
return (
<section className="plan-steering-summary card-list-item" aria-label="Steuerungsüberblick">
<h3 className="plan-steering-summary__title">Steuerung (aus Methode)</h3>
<dl className="plan-steering-summary__list">
<div className="plan-steering-summary__row">
<dt className="muted">Methode</dt>
<dd>
<strong>{methodLabel}</strong>
{compositionLabel && (
<span className="plan-steering-summary__tag muted"> · {compositionLabel}</span>
)}
</dd>
</div>
{vocabLabel && vocabulary.kinds?.length > 0 && (
<div className="plan-steering-summary__row">
<dt className="muted">Eingang</dt>
<dd>{vocabLabel}</dd>
</div>
)}
{elementLabels.length > 0 && (
<div className="plan-steering-summary__row">
<dt className="muted">Aktive Elemente</dt>
<dd>
<ul className="plan-steering-summary__pills">
{elementLabels.map((label) => (
<li key={label} className="plan-steering-summary__pill">
{label}
</li>
))}
</ul>
</dd>
</div>
)}
{activeWorkCycle?.title && (
<div className="plan-steering-summary__row">
<dt className="muted">Aktiver Sprint</dt>
<dd>
<strong>{activeWorkCycle.title}</strong>
</dd>
</div>
)}
{(showsInbox || showsDirectWork) && (
<div className="plan-steering-summary__row">
<dt className="muted">Commit-Pfade</dt>
<dd className="plan-steering-summary__paths">
{showsInbox && (
<span>
A:{' '}
<Link to={scopedPath('/plan/inbox', { initiativeId })}>Eingang Commit</Link>
</span>
)}
{showsInbox && showsDirectWork && ' · '}
{showsDirectWork && (
<span>
B:{' '}
<Link to={scopedPath('/plan/work', { initiativeId })}>Plan Arbeit (direct)</Link>
</span>
)}
</dd>
</div>
)}
</dl>
<p className="muted plan-steering-summary__footnote">
Details und Methodenwechsel unter{' '}
<Link to={scopedPath('/control/status', { initiativeId })}>Kontrolle Steuerung</Link>.
</p>
</section>
)
}

View File

@ -8,6 +8,7 @@ import { LoadingState } from '../../components/LoadingState.jsx'
import { Modal } from '../../components/Modal.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { InitiativeSteeringMeta } from '../../components/InitiativeSteeringMeta.jsx'
import { PlanSteeringSummary } from '../../components/PlanSteeringSummary.jsx'
import { PriorityBadge } from '../../components/PriorityBadge.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
@ -167,6 +168,7 @@ function PlanProfileInner() {
steeringSnapshot={steeringSnapshot}
steeringMethods={steeringMethods}
/>
<PlanSteeringSummary initiativeId={initiative.id} />
<div className="plan-profile__field">
<h3 className="plan-profile__label">Verantwortlich (Owner)</h3>
<p>{ownerName}</p>

View File

@ -2,7 +2,7 @@
* AP2.4 Steuerungselement-Registry (Element UI-Semantik).
*/
/** @type {Record<string, { nextActionTitle?: string, nextActionSubtitle?: string }>} */
/** @type {Record<string, { nextActionTitle?: string, nextActionSubtitle?: string, planInboxHint?: string }>} */
export const STEERING_ELEMENT_UI = {
critical_path: {
nextActionTitle: 'Nächster Schritt am kritischen Pfad',
@ -18,6 +18,40 @@ export const STEERING_ELEMENT_UI = {
},
}
/** Labels für Steuerungselemente auf Plan-Profil (Anzeige, nicht Verhalten). */
export const STEERING_ELEMENT_PLAN_LABELS = {
work_cycle_scope: 'Sprint-Zeitbox',
backlog_epic_hierarchy: 'Epic-Hierarchie im Eingang',
gate_fulfillment: 'Gate-Fortschritt',
queue_inbox: 'Queue-Eingang',
critical_path: 'Kritischer Pfad',
recurring_rhythm: 'Rhythmen',
maturity_stage: 'Reifegrad-Stufen',
chapter_progression: 'Kapitel-Fortschritt',
dispute_timeline: 'Verfahren / Fristen',
next_action_primary: 'Nächster Schritt',
}
export const BACKLOG_VOCABULARY_PROFILE_LABELS = {
agile_intake: 'Agile Eingang (Epic, Story, Bug, Issue)',
continuous_intake: 'Kontinuierlicher Eingang (Idee, Bug, Störung)',
standard_intake: 'Standard-Eingang (Story, Bug, Issue)',
none: 'Kein Backlog-Eingang',
}
/** Plan-relevante Elemente zuerst. */
const PLAN_ELEMENT_ORDER = [
'work_cycle_scope',
'backlog_epic_hierarchy',
'gate_fulfillment',
'queue_inbox',
'critical_path',
'recurring_rhythm',
'maturity_stage',
'chapter_progression',
'dispute_timeline',
]
/**
* @param {string[] | undefined | null} elements
* @param {string} key
@ -34,3 +68,23 @@ export function steeringElementUi(elements, key) {
if (!hasSteeringElement(elements, key)) return null
return STEERING_ELEMENT_UI[key] || null
}
/**
* @param {string[] | undefined | null} elements
*/
export function planSteeringElementLabels(elements) {
if (!Array.isArray(elements)) return []
const order = new Map(PLAN_ELEMENT_ORDER.map((key, index) => [key, index]))
return elements
.filter((key) => STEERING_ELEMENT_PLAN_LABELS[key])
.sort((a, b) => (order.get(a) ?? 99) - (order.get(b) ?? 99))
.map((key) => STEERING_ELEMENT_PLAN_LABELS[key])
}
/**
* @param {string | undefined | null} profileKey
*/
export function backlogVocabularyProfileLabel(profileKey) {
if (!profileKey) return null
return BACKLOG_VOCABULARY_PROFILE_LABELS[profileKey] || profileKey
}

View File

@ -887,3 +887,130 @@
.next-action-reason {
margin-left: 4px;
}
.backlog-intake-guide {
margin-bottom: 16px;
padding: 12px 14px;
border-left: 3px solid var(--jk-primary);
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
border-radius: 8px;
}
.backlog-intake-guide--compact {
margin-bottom: 12px;
padding: 0;
border: none;
background: transparent;
font-size: 14px;
}
.backlog-intake-guide__title {
margin: 0 0 10px;
}
.backlog-intake-guide__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.backlog-intake-guide__step {
display: flex;
gap: 10px;
align-items: flex-start;
}
.backlog-intake-guide__step-marker {
flex-shrink: 0;
width: 1.25rem;
font-weight: 700;
line-height: 1.4;
}
.backlog-intake-guide__step--current .backlog-intake-guide__step-marker {
color: var(--jk-primary);
}
.backlog-intake-guide__step--done .backlog-intake-guide__step-marker {
color: var(--jk-success, #2e7d32);
}
.backlog-intake-guide__step--pending {
opacity: 0.72;
}
.backlog-intake-guide__step-detail {
margin: 4px 0 0;
font-size: 13px;
}
.backlog-intake-guide__step-link {
display: inline-block;
margin-top: 4px;
font-size: 13px;
}
.plan-steering-summary {
margin: 16px 0;
padding: 14px 16px;
border-radius: 8px;
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
border: 1px solid var(--jk-border, rgba(0, 0, 0, 0.08));
}
.plan-steering-summary__title {
margin: 0 0 12px;
font-size: 1rem;
}
.plan-steering-summary__list {
margin: 0;
}
.plan-steering-summary__row {
display: grid;
grid-template-columns: minmax(7rem, 9rem) 1fr;
gap: 8px 12px;
margin-bottom: 10px;
}
.plan-steering-summary__row dt {
margin: 0;
}
.plan-steering-summary__row dd {
margin: 0;
}
.plan-steering-summary__pills {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.plan-steering-summary__pill {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: var(--jk-surface, #fff);
border: 1px solid var(--jk-border, rgba(0, 0, 0, 0.1));
}
.plan-steering-summary__paths {
font-size: 14px;
}
.plan-steering-summary__footnote {
margin: 12px 0 0;
font-size: 13px;
}
.plan-steering-summary__tag {
font-size: 13px;
}

View File

@ -0,0 +1,119 @@
/**
* Geführter Eingangs-Flow Schritte aus Operating Context, keine Archetyp-Ifs.
*/
/**
* @typedef {'pending' | 'current' | 'done'} GuideStepState
* @typedef {{
* id: string,
* title: string,
* detail: string,
* state: GuideStepState,
* to?: string,
* linkLabel?: string,
* }} BacklogIntakeGuideStep
*/
/**
* @param {{
* vocabulary: import('./resolveBacklogVocabulary.js').BacklogVocabulary,
* sprintCommitEnabled: boolean,
* hasPlanableSprint: boolean,
* hasBacklogItems: boolean,
* hasCommittableItems: boolean,
* hasConvertedItems: boolean,
* directWorkEnabled: boolean,
* initiativeId: string,
* scopedPath: (path: string, opts: { initiativeId: string }) => string,
* }} input
* @returns {BacklogIntakeGuideStep[]}
*/
export function buildBacklogIntakeGuideSteps(input) {
const {
vocabulary,
sprintCommitEnabled,
hasPlanableSprint,
hasBacklogItems,
hasCommittableItems,
hasConvertedItems,
directWorkEnabled,
initiativeId,
scopedPath,
} = input
const epicHierarchy = Boolean(
vocabulary.capabilities?.epic_hierarchy ?? vocabulary.epic_hierarchy,
)
const storyLabel = vocabulary.labels?.story || 'Story'
const epicLabel = vocabulary.labels?.epic || 'Epic'
/** @type {BacklogIntakeGuideStep[]} */
const steps = []
if (sprintCommitEnabled) {
steps.push({
id: 'sprint',
title: 'Sprint anlegen',
detail: 'Unter Plan → Sprint einen Zeitrahmen anlegen (geplant oder aktiv).',
state: hasPlanableSprint ? 'done' : 'current',
to: scopedPath('/plan/sprint', { initiativeId }),
linkLabel: 'Zu Sprint-Planung',
})
}
steps.push({
id: 'intake',
title: epicHierarchy
? `${epicLabel} oder ${storyLabel} im Eingang`
: `${storyLabel} im Eingang erfassen`,
detail: epicHierarchy
? `Optional ${epicLabel} als Container, darunter ${storyLabel}/Bug/Issue zum Refinement.`
: 'Ideen und Störungen sammeln, bevor sie committet werden.',
state: hasBacklogItems
? 'done'
: steps.some((step) => step.state === 'current')
? 'pending'
: 'current',
to: undefined,
linkLabel: undefined,
})
steps.push({
id: 'commit',
title: sprintCommitEnabled ? 'In Sprint committen' : 'In Arbeitspaket umwandeln',
detail: sprintCommitEnabled
? 'Item markieren → Ziel-Sprint wählen → committen. Daraus entsteht ein Arbeitspaket im Ist.'
: 'Freigegebenes Item committen — es wird ein Arbeitspaket im Ist.',
state: hasConvertedItems
? 'done'
: hasCommittableItems && (!sprintCommitEnabled || hasPlanableSprint)
? 'current'
: 'pending',
})
if (directWorkEnabled) {
steps.push({
id: 'direct-ap',
title: 'Alternativ: Arbeitspaket direkt',
detail:
'Ad-hoc-Spike oder Nacharbeit ohne Backlog-Umweg — Plan → Arbeit (Direct Commit, Pfad B).',
state: 'pending',
to: scopedPath('/plan/work', { initiativeId }),
linkLabel: 'Plan → Arbeit',
})
}
if (!steps.some((step) => step.state === 'current')) {
const firstPending = steps.find((step) => step.state === 'pending')
if (firstPending) firstPending.state = 'current'
}
return steps
}
/**
* @param {BacklogIntakeGuideStep[]} steps
*/
export function isBacklogIntakeGuideComplete(steps) {
return steps.length > 0 && steps.every((step) => step.state === 'done' || step.id === 'direct-ap')
}

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { buildBacklogIntakeGuideSteps } from './backlogIntakeGuide.js'
import { DEFAULT_BACKLOG_VOCABULARY } from './resolveBacklogVocabulary.js'
const scope = (path, { initiativeId }) => `${path}?initiative=${initiativeId}`
describe('buildBacklogIntakeGuideSteps', () => {
it('startet mit Sprint-Schritt wenn sprint_commit aktiv', () => {
const steps = buildBacklogIntakeGuideSteps({
vocabulary: {
...DEFAULT_BACKLOG_VOCABULARY,
capabilities: { sprint_commit: true, epic_hierarchy: true },
labels: { epic: 'Epic', story: 'Story' },
},
sprintCommitEnabled: true,
hasPlanableSprint: false,
hasBacklogItems: false,
hasCommittableItems: false,
hasConvertedItems: false,
directWorkEnabled: true,
initiativeId: 'abc',
scopedPath: scope,
})
expect(steps[0].id).toBe('sprint')
expect(steps[0].state).toBe('current')
expect(steps.some((step) => step.id === 'direct-ap')).toBe(true)
})
it('markiert Eingang als erledigt wenn Items vorhanden', () => {
const steps = buildBacklogIntakeGuideSteps({
vocabulary: DEFAULT_BACKLOG_VOCABULARY,
sprintCommitEnabled: false,
hasPlanableSprint: false,
hasBacklogItems: true,
hasCommittableItems: true,
hasConvertedItems: false,
directWorkEnabled: false,
initiativeId: 'abc',
scopedPath: scope,
})
const intake = steps.find((step) => step.id === 'intake')
expect(intake?.state).toBe('done')
})
})