From 252b3f05f220436c85fce4b20c96e02afd15a689 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Jul 2026 18:05:15 +0200 Subject: [PATCH] feat(AP1.9d, AP2.2d): Work-Default-UI, Sprint-Planung und Composition Work-Slots Co-authored-by: Cursor --- .../ADP_UX_Composition_Kernel_v0.1.md | 8 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 1 + docs/product/Kairo_MVP_Execution_Plan_v0.2.md | 2 +- frontend/src/components/BacklogSection.jsx | 62 ++++-- .../src/components/CollapsibleSection.jsx | 62 ++++-- .../src/components/InitiativeActionsHub.jsx | 84 +++++--- .../InitiativeCompositionSurface.jsx | 6 + frontend/src/composition/WorkActionsPanel.jsx | 113 ++++++++++ .../src/composition/compositionProviders.js | 37 +++- .../src/composition/providerComponents.jsx | 2 + .../composition/resolveSteeringComposition.js | 24 +++ .../resolveSteeringComposition.test.js | 65 ++++++ frontend/src/composition/uiSlotRegistry.js | 15 ++ frontend/src/constants/productLanguage.js | 24 +++ frontend/src/pages/modes/PlanSprintPage.jsx | 77 ++++++- frontend/src/pages/modes/WorkSprintPage.jsx | 201 ++++-------------- frontend/src/pages/modes/WorkTodayPage.jsx | 78 ++++++- .../src/registry/steeringElementRegistry.js | 2 +- frontend/src/styles/components.css | 5 + frontend/src/widgets/WorkspaceTodayWidget.jsx | 17 +- 20 files changed, 639 insertions(+), 246 deletions(-) create mode 100644 frontend/src/composition/WorkActionsPanel.jsx create mode 100644 frontend/src/constants/productLanguage.js diff --git a/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md b/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md index 4285b2c..ec2a227 100644 --- a/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md +++ b/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md @@ -71,8 +71,14 @@ Slots sind **stabil benannte Flächen** — unabhängig von React-Page-Struktur. | `plan.inbox.proposals` | plan | inbox | Triage-Vorschläge | | `plan.sprint.proposals` | plan | sprint | Sprint-Commit-Vorschläge | | `plan.gates.proposals` | plan | gates | Gate-Vorschläge | +| `work.today.primary` | work | today | Next Action (Steuerung) | +| `work.today.actions` | work | today | Arbeitspakete (Drill-down) | +| `work.sprint.primary` | work | sprint | Next Action im Sprint | +| `work.sprint.backlog` | work | sprint | Sprint-Backlog (Drill-down) | -**Erweiterung (post-MVP):** `control.plan-ist.read_models`, `plan.inbox.read_models`, Work-Modus-Slots. +**Erweiterung (post-MVP):** `control.plan-ist.read_models`, `plan.inbox.read_models`. + +**AP1.9d (2026-07-27):** Work-Modus-Slots `work.today.*`, `work.sprint.*` — Next Action als Hauptbild, Arbeitspaket-Listen als Drill-down. Layout-neutral für späteres UI-Redesign; Provider-Vertrag bleibt stabil. --- diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index e90aea6..00e3f59 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -232,6 +232,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 | Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) | | AP2.2b | A2 Linear E2E ✓ (2026-07-27) | | AP2.2c | A1 Reifegrad E2E ✓ (2026-07-27) | +| AP1.9d | Work-Composition + Drill-down ◐→✓ (2026-07-27) | | AP2.2c–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | | AP1.7b | Op-API Parität | diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index 4dfbe59..e8cd9a3 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -78,7 +78,7 @@ Phase 0 DOC + Kurskorrektur (dieses Dokument) ✓ Phase 0b AP2.3/AP2.4 Plugin-Architektur (Archetyp ↔ Methode) ✓ Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓ Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐ -Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring) +Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐→✓ (Work-Composition + Drill-down) Phase 3 AP2.2b A2 Linear End-to-End ✓ AP2.2c A1 Reifegrad + AP2.0e ✓ AP2.2d B2b Product + B3 Sprint ✓ diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index 548679e..f14f9af 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -42,6 +42,8 @@ export function BacklogSection({ busy, sectionTitle = 'Product Backlog', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', + defaultSprintTargetId = null, + lockSprintTarget = false, }) { const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations() const [modalMode, setModalMode] = useState(null) @@ -74,6 +76,13 @@ export function BacklogSection({ if (!showSprintPlanning || !initiativeId) { return } + if ( + defaultSprintTargetId + && planableSprints.some((cycle) => cycle.id === defaultSprintTargetId) + ) { + setSprintTargetId(defaultSprintTargetId) + return + } const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId) if (stored && planableSprints.some((cycle) => cycle.id === stored)) { setSprintTargetId(stored) @@ -84,7 +93,13 @@ export function BacklogSection({ } const preferred = activeWorkCycle?.id || planableSprints[0]?.id || '' setSprintTargetId(preferred) - }, [showSprintPlanning, initiativeId, planableSprints, activeWorkCycle?.id]) + }, [ + showSprintPlanning, + initiativeId, + planableSprints, + activeWorkCycle?.id, + defaultSprintTargetId, + ]) useEffect(() => { if (!initiativeId || !sprintTargetId) return @@ -292,21 +307,36 @@ export function BacklogSection({ {showSprintPlanning && canManage && (
- + {lockSprintTarget && selectedSprint ? ( +

+ Ziel-Sprint: {selectedSprint.title} + {selectedSprint.status === 'active' + ? ' (aktiv)' + : selectedSprint.status === 'planned' + ? ' (geplant)' + : ''} +

+ ) : ( + + )} {selectedCommittable.length > 0 && ( - {open &&
{children}
} +
+
+
+ {title &&

{title}

} + {lead &&

{lead}

} +
+ {headerActions} +
+ + {expanded ? ( + <> + {children} +

+ +

+ + ) : ( +

+ +

+ )}
) } diff --git a/frontend/src/components/InitiativeActionsHub.jsx b/frontend/src/components/InitiativeActionsHub.jsx index 971d5eb..a878d17 100644 --- a/frontend/src/components/InitiativeActionsHub.jsx +++ b/frontend/src/components/InitiativeActionsHub.jsx @@ -1,5 +1,6 @@ import { ActionHubCard } from './ActionHubCard.jsx' import { ActionForm } from './ActionForm.jsx' +import { CollapsibleSection } from './CollapsibleSection.jsx' import { EmptyState } from './EmptyState.jsx' export function InitiativeActionsHub({ @@ -34,35 +35,35 @@ export function InitiativeActionsHub({ sectionTitle = 'Arbeitspakete', sectionLead = 'Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews hängen am Arbeitspaket.', allActions = [], + collapsible = false, + defaultCollapsed = true, + expandLabel = 'Alle anzeigen', + collapseLabel = 'Liste einklappen', }) { - return ( -
-
-
-

{sectionTitle}

-

{sectionLead}

-
-
- - {canManage && ( - - )} -
-
+ const headerActions = ( +
+ + {canManage && ( + + )} +
+ ) + const body = ( + <> {showForm && canManage && (
))}
+ + ) + + if (collapsible) { + return ( + + {body} + + ) + } + + return ( +
+
+
+

{sectionTitle}

+

{sectionLead}

+
+ {headerActions} +
+ {body}
) } diff --git a/frontend/src/composition/InitiativeCompositionSurface.jsx b/frontend/src/composition/InitiativeCompositionSurface.jsx index 768427c..d3c314f 100644 --- a/frontend/src/composition/InitiativeCompositionSurface.jsx +++ b/frontend/src/composition/InitiativeCompositionSurface.jsx @@ -33,6 +33,9 @@ export function InitiativeCompositionSurface({ handleMethodChange: ops.handleMethodChange, methodBusy: ops.methodBusy, formBusy: ops.formBusy, + activeWorkCycle: ops.activeWorkCycle, + uiFeatures: ops.uiFeatures, + dataSlices: ops.dataSlices, onAcceptBacklogProposal, }), [ @@ -46,6 +49,9 @@ export function InitiativeCompositionSurface({ ops.handleMethodChange, ops.methodBusy, ops.formBusy, + ops.activeWorkCycle, + ops.uiFeatures, + ops.dataSlices, onAcceptBacklogProposal, ], ) diff --git a/frontend/src/composition/WorkActionsPanel.jsx b/frontend/src/composition/WorkActionsPanel.jsx new file mode 100644 index 0000000..4ad9b8d --- /dev/null +++ b/frontend/src/composition/WorkActionsPanel.jsx @@ -0,0 +1,113 @@ +import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' +import { InitiativeActionsHub } from '../components/InitiativeActionsHub.jsx' +import { EmptyState } from '../components/EmptyState.jsx' +import { PRODUCT_TERMS, actionCountLabel } from '../constants/productLanguage.js' + +/** + * Composition-Provider: committete Arbeitspakete als Drill-down (nicht Hauptbild). + * @param {{ variant?: 'today' | 'sprint' | 'sprint-product', defaultCollapsed?: boolean }} props + */ +export function WorkActionsPanel({ variant = 'today', defaultCollapsed = true }) { + const ops = useInitiativeOperations() + const { + initiativeId, + actions, + visibleActions, + sprintActions, + activeWorkCycle, + uiFeatures, + dataSlices, + projects, + roadmapItems, + actionContextById, + hideDone, + setHideDone, + showActionForm, + setShowActionForm, + editingActionId, + setEditingActionId, + formBusy, + actors, + actorsLoading, + actorsError, + actorsUsedFallback, + reloadActors, + handleCreateAction, + handleUpdateAction, + handleQuickStatus, + handleCreateBlockerForAction, + handleMoveActionToSprint, + handleUnplanAction, + capabilities, + workCycles, + } = ops + + const supportsWorkCycles = dataSlices.includes('work_cycles') + const isProductMode = uiFeatures.continuousProductWorkMode && supportsWorkCycles + + let panelActions = visibleActions + let sectionTitle = PRODUCT_TERMS.actionPlural + let sectionLead = + 'Committete operative Einheit — nur bei Bedarf die volle Liste öffnen.' + + if (variant === 'sprint' && activeWorkCycle) { + panelActions = sprintActions + sectionTitle = PRODUCT_TERMS.sprintBacklog + sectionLead = `Arbeitspakete im ${PRODUCT_TERMS.workCycle} „${activeWorkCycle.title}“.` + } else if (variant === 'sprint-product' && isProductMode && !activeWorkCycle) { + panelActions = visibleActions + sectionTitle = `Committete ${PRODUCT_TERMS.actionPlural}` + sectionLead = `Ohne aktiven ${PRODUCT_TERMS.workCycle} — Product-Ist im Vorhaben.` + } else if (variant === 'sprint' && !activeWorkCycle && !isProductMode) { + return ( + + ) + } + + const openCount = panelActions.filter((a) => a.status !== 'done' && a.status !== 'discarded').length + const expandLabel = + openCount > 0 + ? `${actionCountLabel(openCount)} anzeigen` + : `Alle ${PRODUCT_TERMS.actionPlural} anzeigen` + + return ( + setShowActionForm((v) => !v)} + editingActionId={editingActionId} + onEditAction={setEditingActionId} + onCancelEdit={() => setEditingActionId(null)} + onCreateAction={handleCreateAction} + onUpdateAction={handleUpdateAction} + onQuickStatus={handleQuickStatus} + onCreateBlockerForAction={handleCreateBlockerForAction} + onMoveToSprint={handleMoveActionToSprint} + onUnplanToBacklog={handleUnplanAction} + canManage={capabilities.has('kairo.action.manage')} + canManageBlocker={capabilities.has('kairo.blocker.manage')} + formBusy={formBusy} + actors={actors} + actorsLoading={actorsLoading} + actorsError={actorsError} + actorsUsedFallback={actorsUsedFallback} + onReloadActors={reloadActors} + workCycles={workCycles} + activeWorkCycle={activeWorkCycle} + allActions={actions} + sectionTitle={sectionTitle} + sectionLead={sectionLead} + collapsible + defaultCollapsed={defaultCollapsed} + expandLabel={expandLabel} + /> + ) +} diff --git a/frontend/src/composition/compositionProviders.js b/frontend/src/composition/compositionProviders.js index edb39e2..59c70ce 100644 --- a/frontend/src/composition/compositionProviders.js +++ b/frontend/src/composition/compositionProviders.js @@ -22,6 +22,7 @@ import { WIDGETS } from '../registry/widgetRegistry.js' * @property {string} [proposalKey] * @property {string} [widgetKey] * @property {string} [predicateKey] + * @property {string} [workActionsVariant] */ const WIDGET_PROVIDERS = WIDGETS.filter((w) => w.area === 'workspace').map((widget) => ({ @@ -81,7 +82,7 @@ export const COMPOSITION_PROVIDERS = [ key: 'steering.next_action', kind: 'steering_element', steeringElement: 'next_action_primary', - slotKeys: ['control.status.steering'], + slotKeys: ['control.status.steering', 'work.today.primary', 'work.sprint.primary'], componentKey: 'NextActionWidget', requiresCapability: 'kairo.workspace.read', scopeTypes: ['initiative'], @@ -137,6 +138,40 @@ export const COMPOSITION_PROVIDERS = [ scopeTypes: ['initiative'], order: 0, }, + + // —— Work / Ausführen (AP1.9d — Drill-down, kein Todo-Wand-Hauptbild) —— + { + key: 'work.actions.today', + kind: 'core', + slotKeys: ['work.today.actions'], + componentKey: 'WorkActionsPanel', + workActionsVariant: 'today', + requiresCapability: 'kairo.action.read', + scopeTypes: ['initiative'], + order: 0, + }, + { + key: 'work.actions.sprint', + kind: 'conditional', + predicateKey: 'hasActiveWorkCycle', + slotKeys: ['work.sprint.backlog'], + componentKey: 'WorkActionsPanel', + workActionsVariant: 'sprint', + requiresCapability: 'kairo.action.read', + scopeTypes: ['initiative'], + order: 0, + }, + { + key: 'work.actions.sprint_product', + kind: 'conditional', + predicateKey: 'isProductWithoutActiveSprint', + slotKeys: ['work.sprint.backlog'], + componentKey: 'WorkActionsPanel', + workActionsVariant: 'sprint-product', + requiresCapability: 'kairo.action.read', + scopeTypes: ['initiative'], + order: 5, + }, ] /** diff --git a/frontend/src/composition/providerComponents.jsx b/frontend/src/composition/providerComponents.jsx index 2d449c7..7cb9107 100644 --- a/frontend/src/composition/providerComponents.jsx +++ b/frontend/src/composition/providerComponents.jsx @@ -7,6 +7,7 @@ import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx' import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx' import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx' import { NextActionWidget } from '../widgets/NextActionWidget.jsx' +import { WorkActionsPanel } from './WorkActionsPanel.jsx' /** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */ export function RoadblockersStrip({ initiativeId, actionsBlocked = 0, blockersOpen = 0 }) { @@ -43,6 +44,7 @@ export const PROVIDER_COMPONENTS = { SteeringProposalsPanel, RoadblockersStrip, WidgetHost, + WorkActionsPanel, } /** diff --git a/frontend/src/composition/resolveSteeringComposition.js b/frontend/src/composition/resolveSteeringComposition.js index b5b314f..c043d37 100644 --- a/frontend/src/composition/resolveSteeringComposition.js +++ b/frontend/src/composition/resolveSteeringComposition.js @@ -33,6 +33,25 @@ const CONDITIONAL_PREDICATES = { const counts = ctx.steeringSnapshot?.counts || {} return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0 }, + hasActiveWorkCycle(ctx) { + const cycle = + ctx.steeringSnapshot?.active_work_cycle + || ctx.opsContext?.activeWorkCycle + || null + return Boolean(cycle?.id) + }, + isProductWithoutActiveSprint(ctx) { + if (CONDITIONAL_PREDICATES.hasActiveWorkCycle(ctx)) return false + const features = + ctx.operatingContext?.ui_features + || ctx.opsContext?.uiFeatures + || {} + const slices = + ctx.operatingContext?.data_slices + || ctx.opsContext?.dataSlices + || [] + return Boolean(features.continuousProductWorkMode && slices.includes('work_cycles')) + }, } /** @@ -221,6 +240,11 @@ export function buildProviderProps(provider, input) { const widget = getWidgetByKey(provider.widgetKey) return { widget } } + case 'WorkActionsPanel': + return { + variant: provider.workActionsVariant || 'today', + defaultCollapsed: true, + } default: return {} } diff --git a/frontend/src/composition/resolveSteeringComposition.test.js b/frontend/src/composition/resolveSteeringComposition.test.js index a3b1fde..a6e82df 100644 --- a/frontend/src/composition/resolveSteeringComposition.test.js +++ b/frontend/src/composition/resolveSteeringComposition.test.js @@ -18,6 +18,8 @@ describe('uiSlotRegistry', () => { it('defines MVP surfaces', () => { expect(COMPOSITION_SURFACES['control.status']).toBeTruthy() expect(COMPOSITION_SURFACES['plan.inbox']).toBeTruthy() + expect(COMPOSITION_SURFACES['work.today']).toBeTruthy() + expect(COMPOSITION_SURFACES['work.sprint']).toBeTruthy() expect(COMPOSITION_SURFACES.cockpit).toBeTruthy() }) @@ -258,4 +260,67 @@ describe('resolveSteeringComposition', () => { expect(withGate.showGateHorizon).toBe(true) expect(withoutGate.showGateHorizon).toBe(false) }) + + it('activates work.today next action and actions providers', () => { + const result = resolveSteeringComposition({ + surfaceKey: 'work.today', + scope: 'initiative', + capabilities: CAPS, + steeringElements: ['next_action_primary', 'critical_path'], + steeringSnapshot: { counts: {}, next_actions: [{ kind: 'action', title: 'X' }] }, + opsContext: { initiativeId: 'init-1' }, + }) + expect(result.slots['work.today.primary']?.some((p) => p.key === 'steering.next_action')).toBe( + true, + ) + expect(result.slots['work.today.actions']?.some((p) => p.key === 'work.actions.today')).toBe( + true, + ) + }) + + it('activates sprint backlog provider when active work cycle', () => { + const result = resolveSteeringComposition({ + surfaceKey: 'work.sprint', + scope: 'initiative', + capabilities: CAPS, + steeringElements: ['next_action_primary', 'work_cycle_scope'], + steeringSnapshot: { + counts: {}, + next_actions: [], + active_work_cycle: { id: 'wc-1', title: 'Sprint R1' }, + }, + opsContext: { initiativeId: 'init-1', activeWorkCycle: { id: 'wc-1' } }, + }) + expect(result.slots['work.sprint.backlog']?.some((p) => p.key === 'work.actions.sprint')).toBe( + true, + ) + }) + + it('activates product backlog panel without active sprint', () => { + const result = resolveSteeringComposition({ + surfaceKey: 'work.sprint', + scope: 'initiative', + capabilities: CAPS, + operatingContext: { + ui_features: { continuousProductWorkMode: true }, + data_slices: ['work_cycles'], + }, + steeringSnapshot: { counts: {}, next_actions: [] }, + opsContext: { + initiativeId: 'init-1', + uiFeatures: { continuousProductWorkMode: true }, + dataSlices: ['work_cycles'], + }, + }) + expect( + result.slots['work.sprint.backlog']?.some((p) => p.key === 'work.actions.sprint_product'), + ).toBe(true) + }) + + it('buildProviderProps passes work actions variant', () => { + const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'work.actions.today') + const props = buildProviderProps(provider, { opsContext: {} }) + expect(props.variant).toBe('today') + expect(props.defaultCollapsed).toBe(true) + }) }) diff --git a/frontend/src/composition/uiSlotRegistry.js b/frontend/src/composition/uiSlotRegistry.js index 6893a1b..224e38a 100644 --- a/frontend/src/composition/uiSlotRegistry.js +++ b/frontend/src/composition/uiSlotRegistry.js @@ -26,6 +26,11 @@ export const UI_SLOTS = [ { key: 'plan.inbox.proposals', mode: 'plan', routeKey: 'inbox', label: 'Triage-Vorschläge', order: 0 }, { key: 'plan.sprint.proposals', mode: 'plan', routeKey: 'sprint', label: 'Sprint-Vorschläge', order: 0 }, { key: 'plan.gates.proposals', mode: 'plan', routeKey: 'gates', label: 'Gate-Vorschläge', order: 0 }, + + { key: 'work.today.primary', mode: 'work', routeKey: 'today', label: 'Nächster Schritt', order: 0 }, + { key: 'work.today.actions', mode: 'work', routeKey: 'today', label: 'Arbeitspakete', order: 10 }, + { key: 'work.sprint.primary', mode: 'work', routeKey: 'sprint', label: 'Nächster Schritt', order: 0 }, + { key: 'work.sprint.backlog', mode: 'work', routeKey: 'sprint', label: 'Sprint-Backlog', order: 10 }, ] /** @@ -68,6 +73,16 @@ export const COMPOSITION_SURFACES = { routeKey: 'gates', slotKeys: ['plan.gates.proposals'], }, + 'work.today': { + mode: 'work', + routeKey: 'today', + slotKeys: ['work.today.primary', 'work.today.actions'], + }, + 'work.sprint': { + mode: 'work', + routeKey: 'sprint', + slotKeys: ['work.sprint.primary', 'work.sprint.backlog'], + }, } /** diff --git a/frontend/src/constants/productLanguage.js b/frontend/src/constants/productLanguage.js new file mode 100644 index 0000000..d5c2875 --- /dev/null +++ b/frontend/src/constants/productLanguage.js @@ -0,0 +1,24 @@ +/** + * AP1.9d — zentrale UI-Begriffe (Product Language). + * Spätere UI-Neuimplementierung kann diese Konstanten ersetzen, ohne Pages zu patchen. + * @see docs/product/Kairo_MVP_Definition_v0.3.md §7 + */ + +export const PRODUCT_TERMS = { + action: 'Arbeitspaket', + actionPlural: 'Arbeitspakete', + actionPossessive: 'meine Arbeitspakete', + workCycle: 'Sprint', + workCyclePlural: 'Sprints', + productBacklog: 'Product Backlog', + sprintBacklog: 'Sprint-Backlog', + intake: 'Eingang', +} + +/** + * @param {number} count + */ +export function actionCountLabel(count) { + if (count === 1) return `1 ${PRODUCT_TERMS.action}` + return `${count} ${PRODUCT_TERMS.actionPlural}` +} diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx index 04cdcf2..9f818c1 100644 --- a/frontend/src/pages/modes/PlanSprintPage.jsx +++ b/frontend/src/pages/modes/PlanSprintPage.jsx @@ -2,11 +2,13 @@ import { useEffect, useMemo, useCallback } from 'react' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx' +import { BacklogSection } from '../../components/BacklogSection.jsx' import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx' import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { LoadingState } from '../../components/LoadingState.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { countActionsByWorkCycle } from '../../utils/workCycleActions.js' +import { PRODUCT_TERMS } from '../../constants/productLanguage.js' function PlanSprintInner() { const ops = useInitiativeOperations() @@ -19,8 +21,10 @@ function PlanSprintInner() { selectedSprintActions, workCycles, actions, + backlogItems, projects, roadmapItems, + featureParentActions, capabilities, hideDone, setHideDone, @@ -49,19 +53,50 @@ function PlanSprintInner() { handleMoveActionToSprint, handleUnplanAction, handleConvertBacklog, + handleBulkConvertBacklog, + handleCreateBacklog, + handleUpdateBacklog, + handleReorderBacklog, + handleDeleteBacklog, reloadSlices, } = ops useEffect(() => { - reloadSlices(['steering_snapshot']) + reloadSlices(['steering_snapshot', 'backlog']) + }, [reloadSlices]) + + const reloadAfterSprintCommit = useCallback(async () => { + await reloadSlices(['steering_snapshot', 'actions', 'backlog']) }, [reloadSlices]) const onAcceptBacklogProposal = useCallback( async (itemId, options) => { - await handleConvertBacklog(itemId, options) - await reloadSlices(['steering_snapshot', 'actions', 'backlog']) + await handleConvertBacklog(itemId, { + work_cycle_id: options?.work_cycle_id || selectedWorkCycleId, + }) + await reloadAfterSprintCommit() }, - [handleConvertBacklog, reloadSlices], + [handleConvertBacklog, selectedWorkCycleId, reloadAfterSprintCommit], + ) + + const onConvertToSprint = useCallback( + async (itemId, options) => { + await handleConvertBacklog(itemId, { + work_cycle_id: options?.work_cycle_id || selectedWorkCycleId, + }) + await reloadAfterSprintCommit() + }, + [handleConvertBacklog, selectedWorkCycleId, reloadAfterSprintCommit], + ) + + const onBulkConvertToSprint = useCallback( + async (itemIds, options) => { + await handleBulkConvertBacklog(itemIds, { + work_cycle_id: options?.work_cycle_id || selectedWorkCycleId, + }) + await reloadAfterSprintCommit() + }, + [handleBulkConvertBacklog, selectedWorkCycleId, reloadAfterSprintCommit], ) const proposalFilterContext = useMemo( @@ -113,11 +148,33 @@ function PlanSprintInner() { busy={formBusy} /> {selectedWorkCycleId && ( - + <> + + + )} {selectedWorkCycleId && selectedWorkCycle && ( )} diff --git a/frontend/src/pages/modes/WorkSprintPage.jsx b/frontend/src/pages/modes/WorkSprintPage.jsx index b732945..0c7c9c6 100644 --- a/frontend/src/pages/modes/WorkSprintPage.jsx +++ b/frontend/src/pages/modes/WorkSprintPage.jsx @@ -1,51 +1,26 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { Link } from 'react-router-dom' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' -import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' -import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' +import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx' import { LoadingState } from '../../components/LoadingState.jsx' import { EmptyState } from '../../components/EmptyState.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useProgramScope } from '../../context/ProgramScopeContext.jsx' import { scopedPath } from '../../utils/routes.js' +import { PRODUCT_TERMS } from '../../constants/productLanguage.js' function WorkSprintInner() { const { initiativeId, hrefWithScope } = useProgramScope() const ops = useInitiativeOperations() const { activeWorkCycle, - sprintActions, - projects, - roadmapItems, - capabilities, - hideDone, - setHideDone, loading, error, - formBusy, - showActionForm, - setShowActionForm, - editingActionId, - setEditingActionId, - actionContextById, - actors, - actorsLoading, - actorsError, - actorsUsedFallback, - reloadActors, - handleCreateAction, - handleUpdateAction, - handleQuickStatus, - handleCreateBlockerForAction, - handleMoveActionToSprint, - handleUnplanAction, - initiative, operatingContext, dataSlices, uiFeatures, steeringSnapshot, steeringSnapshotLoading, - visibleActions, reloadSlices, } = ops @@ -65,157 +40,63 @@ function WorkSprintInner() { reloadSlices, ]) - const defaultWorkCycleId = activeWorkCycle?.id || '' - - const createAction = useMemo( - () => async (payload) => { - await handleCreateAction({ - ...payload, - work_cycle_id: payload.work_cycle_id || defaultWorkCycleId || undefined, - }) - }, - [handleCreateAction, defaultWorkCycleId], - ) - if (loading) { - return + return } - const isProductArchetype = isContinuousProductMode + if (!supportsWorkCycles) { + return ( + + ) + } return ( <> {error &&

{error}

} - {!activeWorkCycle && isProductArchetype && ( - <> -
-

Product-Ist (ohne aktiven Sprint)

-

- Kein aktiver Sprint — Kairo steuert nach{' '} - {operatingContext?.method_key || 'continuous_product'} (wirkungsvollster Schritt). Sprint-Planung - unter{' '} - - Plan → Sprint - - . -

-
- - - - setShowActionForm((v) => !v)} - editingActionId={editingActionId} - onEditAction={setEditingActionId} - onCancelEdit={() => setEditingActionId(null)} - onCreateAction={handleCreateAction} - onUpdateAction={handleUpdateAction} - onQuickStatus={handleQuickStatus} - onCreateBlockerForAction={handleCreateBlockerForAction} - onMoveToSprint={handleMoveActionToSprint} - onUnplanToBacklog={handleUnplanAction} - canManage={capabilities.has('kairo.action.manage')} - canManageBlocker={capabilities.has('kairo.blocker.manage')} - formBusy={formBusy} - actors={actors} - actorsLoading={actorsLoading} - actorsError={actorsError} - actorsUsedFallback={actorsUsedFallback} - onReloadActors={reloadActors} - workCycles={ops.workCycles} - activeWorkCycle={activeWorkCycle} - sectionTitle="Committete Arbeitspakete" - sectionLead="Ohne aktiven Sprint — alle offenen Arbeitspakete im Product-Vorhaben." - /> - + {!activeWorkCycle && isContinuousProductMode && ( +
+

Product-Ist (ohne aktiven {PRODUCT_TERMS.workCycle})

+

+ Kein aktiver {PRODUCT_TERMS.workCycle} — Kairo steuert nach{' '} + {operatingContext?.method_key || 'continuous_product'} (wirkungsvollster + Schritt). {PRODUCT_TERMS.workCycle}-Planung unter{' '} + + Plan → Sprint planen + + . +

+
)} {activeWorkCycle && ( - <> -
-

Sprint-Backlog

-

- {activeWorkCycle.title} - {activeWorkCycle.goal_description && ` — ${activeWorkCycle.goal_description}`} - {' · '} - Product Backlog triagierst du unter{' '} - - Plan → Eingang - - . -

-
- - - - setShowActionForm((v) => !v)} - editingActionId={editingActionId} - onEditAction={setEditingActionId} - onCancelEdit={() => setEditingActionId(null)} - onCreateAction={createAction} - onUpdateAction={handleUpdateAction} - onQuickStatus={handleQuickStatus} - onCreateBlockerForAction={handleCreateBlockerForAction} - onMoveToSprint={handleMoveActionToSprint} - onUnplanToBacklog={handleUnplanAction} - canManage={capabilities.has('kairo.action.manage')} - canManageBlocker={capabilities.has('kairo.blocker.manage')} - formBusy={formBusy} - actors={actors} - actorsLoading={actorsLoading} - actorsError={actorsError} - actorsUsedFallback={actorsUsedFallback} - onReloadActors={reloadActors} - workCycles={ops.workCycles} - activeWorkCycle={activeWorkCycle} - allActions={ops.actions} - /> - +
+

{PRODUCT_TERMS.sprintBacklog}

+

+ {activeWorkCycle.title} + {activeWorkCycle.goal_description && ` — ${activeWorkCycle.goal_description}`} + {' · '} + {PRODUCT_TERMS.productBacklog} triagierst du unter{' '} + + Plan → {PRODUCT_TERMS.intake} + + . +

+
)} - {!activeWorkCycle && !supportsWorkCycles && ( - - )} + ) } export function WorkSprintPage() { return ( - + ) diff --git a/frontend/src/pages/modes/WorkTodayPage.jsx b/frontend/src/pages/modes/WorkTodayPage.jsx index d88467a..55382fa 100644 --- a/frontend/src/pages/modes/WorkTodayPage.jsx +++ b/frontend/src/pages/modes/WorkTodayPage.jsx @@ -1,9 +1,79 @@ +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' +import { LoadingState } from '../../components/LoadingState.jsx' +import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { WorkspaceTodayWidget } from '../../widgets/WorkspaceTodayWidget.jsx' +import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx' +import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' +import { PRODUCT_TERMS } from '../../constants/productLanguage.js' -export function WorkTodayPage() { +function WorkTodayPortfolioView() { return ( -
- -
+ <> + +
+ +
+ ) } + +function WorkTodayInitiativeInner() { + const { hrefWithScope } = useProgramScope() + const ops = useInitiativeOperations() + const { loading, error, reloadSlices, uiFeatures } = ops + + useEffect(() => { + reloadSlices(['steering_snapshot']) + }, [reloadSlices]) + + if (loading) { + return + } + + const isProductWithSprint = uiFeatures.continuousProductWorkMode + + return ( + <> + {error &&

{error}

} + {isProductWithSprint && ( +
+

+ Ausführen zeigt den nächsten Schritt — nicht die Gesamtliste.{' '} + {PRODUCT_TERMS.sprintBacklog} unter{' '} + + Ausführen → Sprint + + . +

+
+ )} + + + ) +} + +function WorkTodayInitiativeView() { + return ( + + + + ) +} + +export function WorkTodayPage() { + const { initiativeId } = useProgramScope() + + if (!initiativeId) { + return + } + + return +} diff --git a/frontend/src/registry/steeringElementRegistry.js b/frontend/src/registry/steeringElementRegistry.js index f319ea4..d72b84e 100644 --- a/frontend/src/registry/steeringElementRegistry.js +++ b/frontend/src/registry/steeringElementRegistry.js @@ -39,7 +39,7 @@ 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', + work_cycle_scope: 'Sprint', backlog_epic_hierarchy: 'Epic-Hierarchie im Eingang', gate_fulfillment: 'Gate-Fortschritt', queue_inbox: 'Queue-Eingang', diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 52e032b..6e34c2b 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1093,6 +1093,11 @@ padding: 0 1rem 1rem; } +.collapsible-section__toggle { + margin: 0.75rem 0 0; + padding: 0 1rem 1rem; +} + .initiative-advanced-stack { display: grid; gap: 0.75rem; diff --git a/frontend/src/widgets/WorkspaceTodayWidget.jsx b/frontend/src/widgets/WorkspaceTodayWidget.jsx index 254fff3..8ed6767 100644 --- a/frontend/src/widgets/WorkspaceTodayWidget.jsx +++ b/frontend/src/widgets/WorkspaceTodayWidget.jsx @@ -8,6 +8,7 @@ import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' import { WidgetCard } from '../components/WidgetCard.jsx' import { actionPath, scopedPath } from '../utils/routes.js' +import { PRODUCT_TERMS } from '../constants/productLanguage.js' function formatDue(iso) { if (!iso) return null @@ -23,7 +24,7 @@ function formatDue(iso) { } } -export function WorkspaceTodayWidget() { +export function WorkspaceTodayWidget({ limit = 10 }) { const [actions, setActions] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -32,32 +33,34 @@ export function WorkspaceTodayWidget() { setLoading(true) setError(null) try { - const data = await listWorkspaceTodayActions(10) + const data = await listWorkspaceTodayActions(limit) setActions(Array.isArray(data) ? data : []) } catch (err) { setError(err.message) } finally { setLoading(false) } - }, []) + }, [limit]) useEffect(() => { load() }, [load]) + const hasMoreHint = limit < 10 + return ( Aktualisieren } footer={ - actions.length > 0 ? ( + actions.length > 0 || hasMoreHint ? ( - Alle meine Maßnahmen → + Alle {PRODUCT_TERMS.actionPossessive} → ) : null } @@ -65,7 +68,7 @@ export function WorkspaceTodayWidget() { {loading && } {!loading && error && } {!loading && !error && actions.length === 0 && ( - + )} {!loading && !error && actions.length > 0 && (