diff --git a/frontend/src/components/BacklogIntakeGuide.jsx b/frontend/src/components/BacklogIntakeGuide.jsx new file mode 100644 index 0000000..8d7b7bd --- /dev/null +++ b/frontend/src/components/BacklogIntakeGuide.jsx @@ -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 ( +

+ Eingang bereit — Items committen oder{' '} + {directWorkEnabled ? ( + direkt unter Plan → Arbeit + ) : ( + 'committete Arbeit unter Ausführen bearbeiten' + )} + . +

+ ) + } + + return ( +
+

+ So funktioniert der Eingang +

+
    + {steps.map((step) => ( +
  1. + +
    + {step.title} +

    {step.detail}

    + {step.to && step.linkLabel && step.state !== 'done' && ( + + {step.linkLabel} + + )} +
    +
  2. + ))} +
+
+ ) +} diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index aa671ef..969a4d6 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -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({ )} + {canManage && ( + 0} + hasBacklogItems={sortedItems.length > 0} + hasCommittableItems={committableItems.length > 0} + hasConvertedItems={hasConvertedItems} + directWorkEnabled={directWorkEnabled} + compact={guideCompact} + /> + )} + {sprintCommitEnabled && canManage && !showSprintPlanning && (

@@ -295,10 +315,13 @@ export function BacklogSection({

)} - {items.length === 0 && } + {sortedItems.length === 0 && !hasConvertedItems && ( + + )} -
    - {displayRows.map(({ item, depth }) => { + {(sortedItems.length > 0 || hasConvertedItems) && ( +
      + {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({ ) })} - {items - .filter((item) => item.status === 'converted') - .map((item) => ( -
    • -
      - {item.title} -

      Konvertiert — nicht mehr sortierbar

      -
      - -
    • - ))} -
    + {items + .filter((item) => item.status === 'converted') + .map((item) => ( +
  • +
    + {item.title} +

    Konvertiert — nicht mehr sortierbar

    +
    + +
  • + ))} +
+ )} {modalMode?.kind === 'create' && ( diff --git a/frontend/src/components/PlanSteeringSummary.jsx b/frontend/src/components/PlanSteeringSummary.jsx new file mode 100644 index 0000000..9b6ee76 --- /dev/null +++ b/frontend/src/components/PlanSteeringSummary.jsx @@ -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 ( +
+

Steuerung (aus Methode)

+
+
+
Methode
+
+ {methodLabel} + {compositionLabel && ( + · {compositionLabel} + )} +
+
+ {vocabLabel && vocabulary.kinds?.length > 0 && ( +
+
Eingang
+
{vocabLabel}
+
+ )} + {elementLabels.length > 0 && ( +
+
Aktive Elemente
+
+
    + {elementLabels.map((label) => ( +
  • + {label} +
  • + ))} +
+
+
+ )} + {activeWorkCycle?.title && ( +
+
Aktiver Sprint
+
+ {activeWorkCycle.title} +
+
+ )} + {(showsInbox || showsDirectWork) && ( +
+
Commit-Pfade
+
+ {showsInbox && ( + + A:{' '} + Eingang → Commit + + )} + {showsInbox && showsDirectWork && ' · '} + {showsDirectWork && ( + + B:{' '} + Plan → Arbeit (direct) + + )} +
+
+ )} +
+

+ Details und Methodenwechsel unter{' '} + Kontrolle → Steuerung. +

+
+ ) +} diff --git a/frontend/src/pages/modes/PlanProfilePage.jsx b/frontend/src/pages/modes/PlanProfilePage.jsx index a0181b7..e678a38 100644 --- a/frontend/src/pages/modes/PlanProfilePage.jsx +++ b/frontend/src/pages/modes/PlanProfilePage.jsx @@ -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} /> +

Verantwortlich (Owner)

{ownerName}

diff --git a/frontend/src/registry/steeringElementRegistry.js b/frontend/src/registry/steeringElementRegistry.js index 0456557..ef56272 100644 --- a/frontend/src/registry/steeringElementRegistry.js +++ b/frontend/src/registry/steeringElementRegistry.js @@ -2,7 +2,7 @@ * AP2.4 — Steuerungselement-Registry (Element → UI-Semantik). */ -/** @type {Record} */ +/** @type {Record} */ 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 +} diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index 1d4a0ac..d0aaae7 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -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; +} diff --git a/frontend/src/utils/backlogIntakeGuide.js b/frontend/src/utils/backlogIntakeGuide.js new file mode 100644 index 0000000..e0e301f --- /dev/null +++ b/frontend/src/utils/backlogIntakeGuide.js @@ -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') +} diff --git a/frontend/src/utils/backlogIntakeGuide.test.js b/frontend/src/utils/backlogIntakeGuide.test.js new file mode 100644 index 0000000..615cdf4 --- /dev/null +++ b/frontend/src/utils/backlogIntakeGuide.test.js @@ -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') + }) +})