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
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:
parent
a3b14d9c00
commit
a13913f1a0
82
frontend/src/components/BacklogIntakeGuide.jsx
Normal file
82
frontend/src/components/BacklogIntakeGuide.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,10 +315,13 @@ 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." />
|
||||
)}
|
||||
|
||||
<ul className="item-list backlog-reorder-list">
|
||||
{displayRows.map(({ item, depth }) => {
|
||||
{(sortedItems.length > 0 || hasConvertedItems) && (
|
||||
<ul className="item-list backlog-reorder-list">
|
||||
{displayRows.map(({ item, depth }) => {
|
||||
const isDragging = dragItemId === item.id
|
||||
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||
const reorderable = canReorder && item.status !== 'converted'
|
||||
|
|
@ -415,18 +438,19 @@ export function BacklogSection({
|
|||
</li>
|
||||
)
|
||||
})}
|
||||
{items
|
||||
.filter((item) => item.status === 'converted')
|
||||
.map((item) => (
|
||||
<li key={item.id} className="list-item card-list-item backlog-reorder-item--converted">
|
||||
<div className="list-item-main">
|
||||
<strong>{item.title}</strong>
|
||||
<p className="list-item-sub muted">Konvertiert — nicht mehr sortierbar</p>
|
||||
</div>
|
||||
<StatusBadge kind="backlog" status={item.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{items
|
||||
.filter((item) => item.status === 'converted')
|
||||
.map((item) => (
|
||||
<li key={item.id} className="list-item card-list-item backlog-reorder-item--converted">
|
||||
<div className="list-item-main">
|
||||
<strong>{item.title}</strong>
|
||||
<p className="list-item-sub muted">Konvertiert — nicht mehr sortierbar</p>
|
||||
</div>
|
||||
<StatusBadge kind="backlog" status={item.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
|
||||
{modalMode?.kind === 'create' && (
|
||||
|
|
|
|||
110
frontend/src/components/PlanSteeringSummary.jsx
Normal file
110
frontend/src/components/PlanSteeringSummary.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
119
frontend/src/utils/backlogIntakeGuide.js
Normal file
119
frontend/src/utils/backlogIntakeGuide.js
Normal 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')
|
||||
}
|
||||
44
frontend/src/utils/backlogIntakeGuide.test.js
Normal file
44
frontend/src/utils/backlogIntakeGuide.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user