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 { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
|
||||||
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
|
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
|
||||||
|
import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx'
|
||||||
import { actionTitleById } from '../utils/actionReferences.js'
|
import { actionTitleById } from '../utils/actionReferences.js'
|
||||||
|
|
||||||
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
||||||
|
|
@ -107,6 +108,11 @@ export function BacklogSection({
|
||||||
}, [epicItems])
|
}, [epicItems])
|
||||||
|
|
||||||
const defaultKind = vocabulary.default_kind || 'story'
|
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() {
|
function closeModal() {
|
||||||
setModalMode(null)
|
setModalMode(null)
|
||||||
|
|
@ -247,6 +253,20 @@ export function BacklogSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{sprintCommitEnabled && canManage && !showSprintPlanning && (
|
||||||
<div className="backlog-sprint-setup card-list-item">
|
<div className="backlog-sprint-setup card-list-item">
|
||||||
<p className="backlog-sprint-setup__title">
|
<p className="backlog-sprint-setup__title">
|
||||||
|
|
@ -295,8 +315,11 @@ export function BacklogSection({
|
||||||
</div>
|
</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">
|
<ul className="item-list backlog-reorder-list">
|
||||||
{displayRows.map(({ item, depth }) => {
|
{displayRows.map(({ item, depth }) => {
|
||||||
const isDragging = dragItemId === item.id
|
const isDragging = dragItemId === item.id
|
||||||
|
|
@ -427,6 +450,7 @@ export function BacklogSection({
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
|
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
|
||||||
{modalMode?.kind === 'create' && (
|
{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 { Modal } from '../../components/Modal.jsx'
|
||||||
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
|
||||||
import { InitiativeSteeringMeta } from '../../components/InitiativeSteeringMeta.jsx'
|
import { InitiativeSteeringMeta } from '../../components/InitiativeSteeringMeta.jsx'
|
||||||
|
import { PlanSteeringSummary } from '../../components/PlanSteeringSummary.jsx'
|
||||||
import { PriorityBadge } from '../../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../../components/PriorityBadge.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
|
|
@ -167,6 +168,7 @@ function PlanProfileInner() {
|
||||||
steeringSnapshot={steeringSnapshot}
|
steeringSnapshot={steeringSnapshot}
|
||||||
steeringMethods={steeringMethods}
|
steeringMethods={steeringMethods}
|
||||||
/>
|
/>
|
||||||
|
<PlanSteeringSummary initiativeId={initiative.id} />
|
||||||
<div className="plan-profile__field">
|
<div className="plan-profile__field">
|
||||||
<h3 className="plan-profile__label">Verantwortlich (Owner)</h3>
|
<h3 className="plan-profile__label">Verantwortlich (Owner)</h3>
|
||||||
<p>{ownerName}</p>
|
<p>{ownerName}</p>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* AP2.4 — Steuerungselement-Registry (Element → UI-Semantik).
|
* 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 = {
|
export const STEERING_ELEMENT_UI = {
|
||||||
critical_path: {
|
critical_path: {
|
||||||
nextActionTitle: 'Nächster Schritt am kritischen Pfad',
|
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[] | undefined | null} elements
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
|
|
@ -34,3 +68,23 @@ export function steeringElementUi(elements, key) {
|
||||||
if (!hasSteeringElement(elements, key)) return null
|
if (!hasSteeringElement(elements, key)) return null
|
||||||
return STEERING_ELEMENT_UI[key] || 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 {
|
.next-action-reason {
|
||||||
margin-left: 4px;
|
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